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::income::IncomeFormState;
17use super::dialogs::move_funds::MoveFundsState;
18use super::dialogs::reconcile_start::ReconcileStartState;
19use super::dialogs::transaction::TransactionFormState;
20use super::dialogs::unlock_confirm::UnlockConfirmState;
21use super::views::reconcile::ReconciliationState;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum ActiveView {
26 #[default]
27 Accounts,
28 Register,
29 Budget,
30 Reports,
31 Reconcile,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum BudgetHeaderDisplay {
37 #[default]
39 AvailableToBudget,
40 Checking,
42 Savings,
44 Credit,
46 Cash,
48 Investment,
50 LineOfCredit,
52 Other,
54}
55
56impl BudgetHeaderDisplay {
57 pub fn next(self) -> Self {
59 match self {
60 Self::AvailableToBudget => Self::Checking,
61 Self::Checking => Self::Savings,
62 Self::Savings => Self::Credit,
63 Self::Credit => Self::Cash,
64 Self::Cash => Self::Investment,
65 Self::Investment => Self::LineOfCredit,
66 Self::LineOfCredit => Self::Other,
67 Self::Other => Self::AvailableToBudget,
68 }
69 }
70
71 pub fn prev(self) -> Self {
73 match self {
74 Self::AvailableToBudget => Self::Other,
75 Self::Checking => Self::AvailableToBudget,
76 Self::Savings => Self::Checking,
77 Self::Credit => Self::Savings,
78 Self::Cash => Self::Credit,
79 Self::Investment => Self::Cash,
80 Self::LineOfCredit => Self::Investment,
81 Self::Other => Self::LineOfCredit,
82 }
83 }
84
85 pub fn label(&self) -> &'static str {
87 match self {
88 Self::AvailableToBudget => "Available to Assign",
89 Self::Checking => "Checking",
90 Self::Savings => "Savings",
91 Self::Credit => "Credit Cards",
92 Self::Cash => "Cash",
93 Self::Investment => "Investment",
94 Self::LineOfCredit => "Line of Credit",
95 Self::Other => "Other",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum FocusedPanel {
103 #[default]
104 Sidebar,
105 Main,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum InputMode {
111 #[default]
112 Normal,
113 Editing,
114 Command,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub enum ActiveDialog {
120 #[default]
121 None,
122 AddTransaction,
123 EditTransaction(TransactionId),
124 AddAccount,
125 EditAccount(AccountId),
126 AddCategory,
127 EditCategory(CategoryId),
128 AddGroup,
129 EditGroup(CategoryGroupId),
130 MoveFunds,
131 CommandPalette,
132 Help,
133 Confirm(String),
134 BulkCategorize,
135 ReconcileStart,
136 UnlockConfirm(UnlockConfirmState),
137 Adjustment,
138 Budget,
139 Income,
140}
141
142pub struct App<'a> {
144 pub storage: &'a Storage,
146
147 pub settings: &'a Settings,
149
150 pub paths: &'a EnvelopePaths,
152
153 pub should_quit: bool,
155
156 pub active_view: ActiveView,
158
159 pub focused_panel: FocusedPanel,
161
162 pub input_mode: InputMode,
164
165 pub active_dialog: ActiveDialog,
167
168 pub selected_account: Option<AccountId>,
170
171 pub selected_account_index: usize,
173
174 pub selected_transaction: Option<TransactionId>,
176
177 pub selected_transaction_index: usize,
179
180 pub selected_category: Option<CategoryId>,
182
183 pub selected_category_index: usize,
185
186 pub current_period: BudgetPeriod,
188
189 pub budget_header_display: BudgetHeaderDisplay,
191
192 pub show_archived: bool,
194
195 pub multi_select_mode: bool,
197
198 pub selected_transactions: Vec<TransactionId>,
200
201 pub scroll_offset: usize,
203
204 pub status_message: Option<String>,
206
207 pub command_input: String,
209
210 pub command_results: Vec<usize>,
212
213 pub selected_command_index: usize,
215
216 pub transaction_form: TransactionFormState,
218
219 pub move_funds_state: MoveFundsState,
221
222 pub bulk_categorize_state: BulkCategorizeState,
224
225 pub reconciliation_state: ReconciliationState,
227
228 pub reconcile_start_state: ReconcileStartState,
230
231 pub adjustment_dialog_state: AdjustmentDialogState,
233
234 pub account_form: AccountFormState,
236
237 pub category_form: CategoryFormState,
239
240 pub group_form: GroupFormState,
242
243 pub budget_dialog_state: BudgetDialogState,
245
246 pub income_form: IncomeFormState,
248
249 pub pending_g: bool,
251}
252
253impl<'a> App<'a> {
254 pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
256 let selected_account = storage
258 .accounts
259 .get_active()
260 .ok()
261 .and_then(|accounts| accounts.first().map(|a| a.id));
262
263 Self {
264 storage,
265 settings,
266 paths,
267 should_quit: false,
268 active_view: ActiveView::default(),
269 focused_panel: FocusedPanel::default(),
270 input_mode: InputMode::default(),
271 active_dialog: ActiveDialog::default(),
272 selected_account,
273 selected_account_index: 0,
274 selected_transaction: None,
275 selected_transaction_index: 0,
276 selected_category: None,
277 selected_category_index: 0,
278 current_period: BudgetPeriod::current_month(),
279 budget_header_display: BudgetHeaderDisplay::default(),
280 show_archived: false,
281 multi_select_mode: false,
282 selected_transactions: Vec::new(),
283 scroll_offset: 0,
284 status_message: None,
285 command_input: String::new(),
286 command_results: Vec::new(),
287 selected_command_index: 0,
288 transaction_form: TransactionFormState::new(),
289 move_funds_state: MoveFundsState::new(),
290 bulk_categorize_state: BulkCategorizeState::new(),
291 reconciliation_state: ReconciliationState::new(),
292 reconcile_start_state: ReconcileStartState::new(),
293 adjustment_dialog_state: AdjustmentDialogState::default(),
294 account_form: AccountFormState::new(),
295 category_form: CategoryFormState::new(),
296 group_form: GroupFormState::new(),
297 budget_dialog_state: BudgetDialogState::new(),
298 income_form: IncomeFormState::new(),
299 pending_g: false,
300 }
301 }
302
303 pub fn quit(&mut self) {
305 self.should_quit = true;
306 }
307
308 pub fn set_status(&mut self, message: impl Into<String>) {
310 self.status_message = Some(message.into());
311 }
312
313 pub fn clear_status(&mut self) {
315 self.status_message = None;
316 }
317
318 pub fn switch_view(&mut self, view: ActiveView) {
320 self.active_view = view;
321 self.scroll_offset = 0;
322
323 match view {
325 ActiveView::Accounts => {
326 self.selected_account_index = 0;
327 if let Ok(accounts) = self.storage.accounts.get_active() {
329 self.selected_account = accounts.first().map(|a| a.id);
330 }
331 }
332 ActiveView::Register => {
333 self.selected_transaction_index = 0;
334 if let Some(account_id) = self.selected_account {
336 let mut txns = self
337 .storage
338 .transactions
339 .get_by_account(account_id)
340 .unwrap_or_default();
341 txns.sort_by(|a, b| b.date.cmp(&a.date));
342 self.selected_transaction = txns.first().map(|t| t.id);
343 }
344 }
345 ActiveView::Budget => {
346 self.selected_category_index = 0;
347 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
349 let all_categories = self
350 .storage
351 .categories
352 .get_all_categories()
353 .unwrap_or_default();
354 for group in &groups {
356 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
357 self.selected_category = Some(cat.id);
358 break;
359 }
360 }
361 }
362 ActiveView::Reports => {}
363 ActiveView::Reconcile => {
364 if let Some(account_id) = self.selected_account {
366 self.reconciliation_state.init_for_account(account_id);
367 }
368 }
369 }
370 }
371
372 pub fn toggle_panel_focus(&mut self) {
374 self.focused_panel = match self.focused_panel {
375 FocusedPanel::Sidebar => FocusedPanel::Main,
376 FocusedPanel::Main => FocusedPanel::Sidebar,
377 };
378 if self.focused_panel == FocusedPanel::Main {
380 self.ensure_selection_initialized();
381 }
382 }
383
384 pub fn ensure_selection_initialized(&mut self) {
386 match self.active_view {
387 ActiveView::Accounts => {
388 if self.selected_account.is_none() {
389 if let Ok(accounts) = self.storage.accounts.get_active() {
390 self.selected_account = accounts.first().map(|a| a.id);
391 }
392 }
393 }
394 ActiveView::Register => {
395 if self.selected_transaction.is_none() {
396 if let Some(account_id) = self.selected_account {
397 let mut txns = self
398 .storage
399 .transactions
400 .get_by_account(account_id)
401 .unwrap_or_default();
402 txns.sort_by(|a, b| b.date.cmp(&a.date));
403 self.selected_transaction = txns.first().map(|t| t.id);
404 }
405 }
406 }
407 ActiveView::Budget => {
408 if self.selected_category.is_none() {
409 let groups = self.storage.categories.get_all_groups().unwrap_or_default();
410 let all_categories = self
411 .storage
412 .categories
413 .get_all_categories()
414 .unwrap_or_default();
415 for group in &groups {
416 if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
417 self.selected_category = Some(cat.id);
418 break;
419 }
420 }
421 }
422 }
423 _ => {}
424 }
425 }
426
427 pub fn open_dialog(&mut self, dialog: ActiveDialog) {
429 self.active_dialog = dialog.clone();
430 match &dialog {
431 ActiveDialog::CommandPalette => {
432 self.command_input.clear();
433 self.input_mode = InputMode::Command;
434 }
435 ActiveDialog::AddTransaction => {
436 self.transaction_form = TransactionFormState::new();
438 self.transaction_form
439 .set_focus(super::dialogs::transaction::TransactionField::Date);
440 self.input_mode = InputMode::Editing;
441 }
442 ActiveDialog::EditTransaction(txn_id) => {
443 if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
445 let categories: Vec<_> = self
446 .storage
447 .categories
448 .get_all_categories()
449 .unwrap_or_default()
450 .iter()
451 .map(|c| (c.id, c.name.clone()))
452 .collect();
453 self.transaction_form =
454 TransactionFormState::from_transaction(&txn, &categories);
455 self.transaction_form
456 .set_focus(super::dialogs::transaction::TransactionField::Date);
457 }
458 self.input_mode = InputMode::Editing;
459 }
460 ActiveDialog::AddAccount => {
461 self.account_form = AccountFormState::new();
463 self.account_form
464 .set_focus(super::dialogs::account::AccountField::Name);
465 self.input_mode = InputMode::Editing;
466 }
467 ActiveDialog::EditAccount(account_id) => {
468 if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
470 self.account_form = AccountFormState::from_account(&account);
471 self.account_form
472 .set_focus(super::dialogs::account::AccountField::Name);
473 }
474 self.input_mode = InputMode::Editing;
475 }
476 ActiveDialog::AddCategory => {
477 self.category_form = CategoryFormState::new();
479 let groups: Vec<_> = self
480 .storage
481 .categories
482 .get_all_groups()
483 .unwrap_or_default()
484 .into_iter()
485 .map(|g| (g.id, g.name))
486 .collect();
487 self.category_form.init_with_groups(groups);
488 self.input_mode = InputMode::Editing;
489 }
490 ActiveDialog::EditCategory(category_id) => {
491 if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
493 let groups: Vec<_> = self
494 .storage
495 .categories
496 .get_all_groups()
497 .unwrap_or_default()
498 .into_iter()
499 .map(|g| (g.id, g.name.clone()))
500 .collect();
501 self.category_form.init_for_edit(&category, groups);
502 }
503 self.input_mode = InputMode::Editing;
504 }
505 ActiveDialog::AddGroup => {
506 self.group_form = GroupFormState::new();
508 self.input_mode = InputMode::Editing;
509 }
510 ActiveDialog::EditGroup(group_id) => {
511 if let Ok(Some(group)) = self.storage.categories.get_group(*group_id) {
513 self.group_form = GroupFormState::new();
514 self.group_form.init_for_edit(&group);
515 }
516 self.input_mode = InputMode::Editing;
517 }
518 ActiveDialog::Budget => {
519 if let Some(category_id) = self.selected_category {
521 if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
522 let budget_service = crate::services::BudgetService::new(self.storage);
523 let summary = budget_service
524 .get_category_summary(category_id, &self.current_period)
525 .unwrap_or_else(|_| {
526 crate::models::CategoryBudgetSummary::empty(category_id)
527 });
528 let suggested = budget_service
529 .get_suggested_budget_with_progress(category_id, &self.current_period)
530 .ok()
531 .flatten();
532 let existing_target = self
533 .storage
534 .targets
535 .get_for_category(category_id)
536 .ok()
537 .flatten();
538 self.budget_dialog_state.init_for_category(
539 category_id,
540 category.name,
541 summary.budgeted,
542 suggested,
543 existing_target.as_ref(),
544 );
545 self.input_mode = InputMode::Editing;
546 }
547 }
548 }
549 ActiveDialog::Income => {
550 self.income_form
552 .init_for_period(&self.current_period, self.storage);
553 self.input_mode = InputMode::Editing;
554 }
555 _ => {}
556 }
557 }
558
559 pub fn close_dialog(&mut self) {
561 self.active_dialog = ActiveDialog::None;
562 self.input_mode = InputMode::Normal;
563 }
564
565 pub fn has_dialog(&self) -> bool {
567 !matches!(self.active_dialog, ActiveDialog::None)
568 }
569
570 pub fn move_up(&mut self) {
572 match self.focused_panel {
573 FocusedPanel::Sidebar => {
574 if self.selected_account_index > 0 {
575 self.selected_account_index -= 1;
576 }
577 }
578 FocusedPanel::Main => match self.active_view {
579 ActiveView::Register => {
580 if self.selected_transaction_index > 0 {
581 self.selected_transaction_index -= 1;
582 }
583 }
584 ActiveView::Budget => {
585 if self.selected_category_index > 0 {
586 self.selected_category_index -= 1;
587 }
588 }
589 _ => {}
590 },
591 }
592 }
593
594 pub fn move_down(&mut self, max: usize) {
596 match self.focused_panel {
597 FocusedPanel::Sidebar => {
598 if self.selected_account_index < max.saturating_sub(1) {
599 self.selected_account_index += 1;
600 }
601 }
602 FocusedPanel::Main => match self.active_view {
603 ActiveView::Register => {
604 if self.selected_transaction_index < max.saturating_sub(1) {
605 self.selected_transaction_index += 1;
606 }
607 }
608 ActiveView::Budget => {
609 if self.selected_category_index < max.saturating_sub(1) {
610 self.selected_category_index += 1;
611 }
612 }
613 _ => {}
614 },
615 }
616 }
617
618 pub fn prev_period(&mut self) {
620 self.current_period = self.current_period.prev();
621 }
622
623 pub fn next_period(&mut self) {
625 self.current_period = self.current_period.next();
626 }
627
628 pub fn toggle_multi_select(&mut self) {
630 self.multi_select_mode = !self.multi_select_mode;
631 if !self.multi_select_mode {
632 self.selected_transactions.clear();
633 }
634 }
635
636 pub fn toggle_transaction_selection(&mut self) {
638 if let Some(txn_id) = self.selected_transaction {
639 if self.selected_transactions.contains(&txn_id) {
640 self.selected_transactions.retain(|&id| id != txn_id);
641 } else {
642 self.selected_transactions.push(txn_id);
643 }
644 }
645 }
646}