1use 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#[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 MoveFunds,
129 CommandPalette,
130 Help,
131 Confirm(String),
132 BulkCategorize,
133 ReconcileStart,
134 UnlockConfirm(UnlockConfirmState),
135 Adjustment,
136 Budget,
137}
138
139pub struct App<'a> {
141 pub storage: &'a Storage,
143
144 pub settings: &'a Settings,
146
147 pub paths: &'a EnvelopePaths,
149
150 pub should_quit: bool,
152
153 pub active_view: ActiveView,
155
156 pub focused_panel: FocusedPanel,
158
159 pub input_mode: InputMode,
161
162 pub active_dialog: ActiveDialog,
164
165 pub selected_account: Option<AccountId>,
167
168 pub selected_account_index: usize,
170
171 pub selected_transaction: Option<TransactionId>,
173
174 pub selected_transaction_index: usize,
176
177 pub selected_category: Option<CategoryId>,
179
180 pub selected_category_index: usize,
182
183 pub current_period: BudgetPeriod,
185
186 pub budget_header_display: BudgetHeaderDisplay,
188
189 pub show_archived: bool,
191
192 pub multi_select_mode: bool,
194
195 pub selected_transactions: Vec<TransactionId>,
197
198 pub scroll_offset: usize,
200
201 pub status_message: Option<String>,
203
204 pub command_input: String,
206
207 pub command_results: Vec<usize>,
209
210 pub selected_command_index: usize,
212
213 pub transaction_form: TransactionFormState,
215
216 pub move_funds_state: MoveFundsState,
218
219 pub bulk_categorize_state: BulkCategorizeState,
221
222 pub reconciliation_state: ReconciliationState,
224
225 pub reconcile_start_state: ReconcileStartState,
227
228 pub adjustment_dialog_state: AdjustmentDialogState,
230
231 pub account_form: AccountFormState,
233
234 pub category_form: CategoryFormState,
236
237 pub group_form: GroupFormState,
239
240 pub budget_dialog_state: BudgetDialogState,
242}
243
244impl<'a> App<'a> {
245 pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
247 let selected_account = storage
249 .accounts
250 .get_active()
251 .ok()
252 .and_then(|accounts| accounts.first().map(|a| a.id));
253
254 Self {
255 storage,
256 settings,
257 paths,
258 should_quit: false,
259 active_view: ActiveView::default(),
260 focused_panel: FocusedPanel::default(),
261 input_mode: InputMode::default(),
262 active_dialog: ActiveDialog::default(),
263 selected_account,
264 selected_account_index: 0,
265 selected_transaction: None,
266 selected_transaction_index: 0,
267 selected_category: None,
268 selected_category_index: 0,
269 current_period: BudgetPeriod::current_month(),
270 budget_header_display: BudgetHeaderDisplay::default(),
271 show_archived: false,
272 multi_select_mode: false,
273 selected_transactions: Vec::new(),
274 scroll_offset: 0,
275 status_message: None,
276 command_input: String::new(),
277 command_results: Vec::new(),
278 selected_command_index: 0,
279 transaction_form: TransactionFormState::new(),
280 move_funds_state: MoveFundsState::new(),
281 bulk_categorize_state: BulkCategorizeState::new(),
282 reconciliation_state: ReconciliationState::new(),
283 reconcile_start_state: ReconcileStartState::new(),
284 adjustment_dialog_state: AdjustmentDialogState::default(),
285 account_form: AccountFormState::new(),
286 category_form: CategoryFormState::new(),
287 group_form: GroupFormState::new(),
288 budget_dialog_state: BudgetDialogState::new(),
289 }
290 }
291
292 pub fn quit(&mut self) {
294 self.should_quit = true;
295 }
296
297 pub fn set_status(&mut self, message: impl Into<String>) {
299 self.status_message = Some(message.into());
300 }
301
302 pub fn clear_status(&mut self) {
304 self.status_message = None;
305 }
306
307 pub fn switch_view(&mut self, view: ActiveView) {
309 self.active_view = view;
310 self.scroll_offset = 0;
311
312 match view {
314 ActiveView::Accounts => {
315 self.selected_account_index = 0;
316 if let Ok(accounts) = self.storage.accounts.get_active() {
318 self.selected_account = accounts.first().map(|a| a.id);
319 }
320 }
321 ActiveView::Register => {
322 self.selected_transaction_index = 0;
323 if let Some(account_id) = self.selected_account {
325 let mut txns = self
326 .storage
327 .transactions
328 .get_by_account(account_id)
329 .unwrap_or_default();
330 txns.sort_by(|a, b| b.date.cmp(&a.date));
331 self.selected_transaction = txns.first().map(|t| t.id);
332 }
333 }
334 ActiveView::Budget => {
335 self.selected_category_index = 0;
336 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
338 let all_categories = self
339 .storage
340 .categories
341 .get_all_categories()
342 .unwrap_or_default();
343 for group in &groups {
345 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
346 self.selected_category = Some(cat.id);
347 break;
348 }
349 }
350 }
351 ActiveView::Reports => {}
352 ActiveView::Reconcile => {
353 if let Some(account_id) = self.selected_account {
355 self.reconciliation_state.init_for_account(account_id);
356 }
357 }
358 }
359 }
360
361 pub fn toggle_panel_focus(&mut self) {
363 self.focused_panel = match self.focused_panel {
364 FocusedPanel::Sidebar => FocusedPanel::Main,
365 FocusedPanel::Main => FocusedPanel::Sidebar,
366 };
367 if self.focused_panel == FocusedPanel::Main {
369 self.ensure_selection_initialized();
370 }
371 }
372
373 pub fn ensure_selection_initialized(&mut self) {
375 match self.active_view {
376 ActiveView::Accounts => {
377 if self.selected_account.is_none() {
378 if let Ok(accounts) = self.storage.accounts.get_active() {
379 self.selected_account = accounts.first().map(|a| a.id);
380 }
381 }
382 }
383 ActiveView::Register => {
384 if self.selected_transaction.is_none() {
385 if let Some(account_id) = self.selected_account {
386 let mut txns = self
387 .storage
388 .transactions
389 .get_by_account(account_id)
390 .unwrap_or_default();
391 txns.sort_by(|a, b| b.date.cmp(&a.date));
392 self.selected_transaction = txns.first().map(|t| t.id);
393 }
394 }
395 }
396 ActiveView::Budget => {
397 if self.selected_category.is_none() {
398 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
399 let all_categories = self
400 .storage
401 .categories
402 .get_all_categories()
403 .unwrap_or_default();
404 for group in &groups {
405 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
406 self.selected_category = Some(cat.id);
407 break;
408 }
409 }
410 }
411 }
412 _ => {}
413 }
414 }
415
416 pub fn open_dialog(&mut self, dialog: ActiveDialog) {
418 self.active_dialog = dialog.clone();
419 match &dialog {
420 ActiveDialog::CommandPalette => {
421 self.command_input.clear();
422 self.input_mode = InputMode::Command;
423 }
424 ActiveDialog::AddTransaction => {
425 self.transaction_form = TransactionFormState::new();
427 self.transaction_form
428 .set_focus(super::dialogs::transaction::TransactionField::Date);
429 self.input_mode = InputMode::Editing;
430 }
431 ActiveDialog::EditTransaction(txn_id) => {
432 if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
434 let categories: Vec<_> = self
435 .storage
436 .categories
437 .get_all_categories()
438 .unwrap_or_default()
439 .iter()
440 .map(|c| (c.id, c.name.clone()))
441 .collect();
442 self.transaction_form =
443 TransactionFormState::from_transaction(&txn, &categories);
444 self.transaction_form
445 .set_focus(super::dialogs::transaction::TransactionField::Date);
446 }
447 self.input_mode = InputMode::Editing;
448 }
449 ActiveDialog::AddAccount => {
450 self.account_form = AccountFormState::new();
452 self.account_form
453 .set_focus(super::dialogs::account::AccountField::Name);
454 self.input_mode = InputMode::Editing;
455 }
456 ActiveDialog::EditAccount(account_id) => {
457 if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
459 self.account_form = AccountFormState::from_account(&account);
460 self.account_form
461 .set_focus(super::dialogs::account::AccountField::Name);
462 }
463 self.input_mode = InputMode::Editing;
464 }
465 ActiveDialog::AddCategory => {
466 self.category_form = CategoryFormState::new();
468 let groups: Vec<_> = self
469 .storage
470 .categories
471 .get_all_groups()
472 .unwrap_or_default()
473 .into_iter()
474 .map(|g| (g.id, g.name))
475 .collect();
476 self.category_form.init_with_groups(groups);
477 self.input_mode = InputMode::Editing;
478 }
479 ActiveDialog::EditCategory(category_id) => {
480 if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
482 let groups: Vec<_> = self
483 .storage
484 .categories
485 .get_all_groups()
486 .unwrap_or_default()
487 .into_iter()
488 .map(|g| (g.id, g.name.clone()))
489 .collect();
490 self.category_form.init_for_edit(&category, groups);
491 }
492 self.input_mode = InputMode::Editing;
493 }
494 ActiveDialog::AddGroup => {
495 self.group_form = GroupFormState::new();
497 self.input_mode = InputMode::Editing;
498 }
499 ActiveDialog::Budget => {
500 if let Some(category_id) = self.selected_category {
502 if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
503 let budget_service = crate::services::BudgetService::new(self.storage);
504 let summary = budget_service
505 .get_category_summary(category_id, &self.current_period)
506 .unwrap_or_else(|_| {
507 crate::models::CategoryBudgetSummary::empty(category_id)
508 });
509 let suggested = budget_service
510 .get_suggested_budget(category_id, &self.current_period)
511 .ok()
512 .flatten();
513 let existing_target = self
514 .storage
515 .targets
516 .get_for_category(category_id)
517 .ok()
518 .flatten();
519 self.budget_dialog_state.init_for_category(
520 category_id,
521 category.name,
522 summary.budgeted,
523 suggested,
524 existing_target.as_ref(),
525 );
526 self.input_mode = InputMode::Editing;
527 }
528 }
529 }
530 _ => {}
531 }
532 }
533
534 pub fn close_dialog(&mut self) {
536 self.active_dialog = ActiveDialog::None;
537 self.input_mode = InputMode::Normal;
538 }
539
540 pub fn has_dialog(&self) -> bool {
542 !matches!(self.active_dialog, ActiveDialog::None)
543 }
544
545 pub fn move_up(&mut self) {
547 match self.focused_panel {
548 FocusedPanel::Sidebar => {
549 if self.selected_account_index > 0 {
550 self.selected_account_index -= 1;
551 }
552 }
553 FocusedPanel::Main => match self.active_view {
554 ActiveView::Register => {
555 if self.selected_transaction_index > 0 {
556 self.selected_transaction_index -= 1;
557 }
558 }
559 ActiveView::Budget => {
560 if self.selected_category_index > 0 {
561 self.selected_category_index -= 1;
562 }
563 }
564 _ => {}
565 },
566 }
567 }
568
569 pub fn move_down(&mut self, max: usize) {
571 match self.focused_panel {
572 FocusedPanel::Sidebar => {
573 if self.selected_account_index < max.saturating_sub(1) {
574 self.selected_account_index += 1;
575 }
576 }
577 FocusedPanel::Main => match self.active_view {
578 ActiveView::Register => {
579 if self.selected_transaction_index < max.saturating_sub(1) {
580 self.selected_transaction_index += 1;
581 }
582 }
583 ActiveView::Budget => {
584 if self.selected_category_index < max.saturating_sub(1) {
585 self.selected_category_index += 1;
586 }
587 }
588 _ => {}
589 },
590 }
591 }
592
593 pub fn prev_period(&mut self) {
595 self.current_period = self.current_period.prev();
596 }
597
598 pub fn next_period(&mut self) {
600 self.current_period = self.current_period.next();
601 }
602
603 pub fn toggle_multi_select(&mut self) {
605 self.multi_select_mode = !self.multi_select_mode;
606 if !self.multi_select_mode {
607 self.selected_transactions.clear();
608 }
609 }
610
611 pub fn toggle_transaction_selection(&mut self) {
613 if let Some(txn_id) = self.selected_transaction {
614 if self.selected_transactions.contains(&txn_id) {
615 self.selected_transactions.retain(|&id| id != txn_id);
616 } else {
617 self.selected_transactions.push(txn_id);
618 }
619 }
620 }
621}