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
245impl<'a> App<'a> {
246 pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
248 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 pub fn quit(&mut self) {
295 self.should_quit = true;
296 }
297
298 pub fn set_status(&mut self, message: impl Into<String>) {
300 self.status_message = Some(message.into());
301 }
302
303 pub fn clear_status(&mut self) {
305 self.status_message = None;
306 }
307
308 pub fn switch_view(&mut self, view: ActiveView) {
310 self.active_view = view;
311 self.scroll_offset = 0;
312
313 match view {
315 ActiveView::Accounts => {
316 self.selected_account_index = 0;
317 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 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 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 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 if let Some(account_id) = self.selected_account {
356 self.reconciliation_state.init_for_account(account_id);
357 }
358 }
359 }
360 }
361
362 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 if self.focused_panel == FocusedPanel::Main {
370 self.ensure_selection_initialized();
371 }
372 }
373
374 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 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 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 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 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 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 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 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 self.group_form = GroupFormState::new();
498 self.input_mode = InputMode::Editing;
499 }
500 ActiveDialog::EditGroup(group_id) => {
501 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 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 pub fn close_dialog(&mut self) {
545 self.active_dialog = ActiveDialog::None;
546 self.input_mode = InputMode::Normal;
547 }
548
549 pub fn has_dialog(&self) -> bool {
551 !matches!(self.active_dialog, ActiveDialog::None)
552 }
553
554 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 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 pub fn prev_period(&mut self) {
604 self.current_period = self.current_period.prev();
605 }
606
607 pub fn next_period(&mut self) {
609 self.current_period = self.current_period.next();
610 }
611
612 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 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}