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 FocusedPanel {
36 #[default]
37 Sidebar,
38 Main,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum InputMode {
44 #[default]
45 Normal,
46 Editing,
47 Command,
48}
49
50#[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
73pub struct App<'a> {
75 pub storage: &'a Storage,
77
78 pub settings: &'a Settings,
80
81 pub paths: &'a EnvelopePaths,
83
84 pub should_quit: bool,
86
87 pub active_view: ActiveView,
89
90 pub focused_panel: FocusedPanel,
92
93 pub input_mode: InputMode,
95
96 pub active_dialog: ActiveDialog,
98
99 pub selected_account: Option<AccountId>,
101
102 pub selected_account_index: usize,
104
105 pub selected_transaction: Option<TransactionId>,
107
108 pub selected_transaction_index: usize,
110
111 pub selected_category: Option<CategoryId>,
113
114 pub selected_category_index: usize,
116
117 pub current_period: BudgetPeriod,
119
120 pub show_archived: bool,
122
123 pub multi_select_mode: bool,
125
126 pub selected_transactions: Vec<TransactionId>,
128
129 pub scroll_offset: usize,
131
132 pub status_message: Option<String>,
134
135 pub command_input: String,
137
138 pub command_results: Vec<usize>,
140
141 pub selected_command_index: usize,
143
144 pub transaction_form: TransactionFormState,
146
147 pub move_funds_state: MoveFundsState,
149
150 pub bulk_categorize_state: BulkCategorizeState,
152
153 pub reconciliation_state: ReconciliationState,
155
156 pub reconcile_start_state: ReconcileStartState,
158
159 pub adjustment_dialog_state: AdjustmentDialogState,
161
162 pub account_form: AccountFormState,
164
165 pub category_form: CategoryFormState,
167
168 pub group_form: GroupFormState,
170
171 pub budget_dialog_state: BudgetDialogState,
173}
174
175impl<'a> App<'a> {
176 pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
178 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 pub fn quit(&mut self) {
224 self.should_quit = true;
225 }
226
227 pub fn set_status(&mut self, message: impl Into<String>) {
229 self.status_message = Some(message.into());
230 }
231
232 pub fn clear_status(&mut self) {
234 self.status_message = None;
235 }
236
237 pub fn switch_view(&mut self, view: ActiveView) {
239 self.active_view = view;
240 self.scroll_offset = 0;
241
242 match view {
244 ActiveView::Accounts => {
245 self.selected_account_index = 0;
246 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 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 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 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 if let Some(account_id) = self.selected_account {
285 self.reconciliation_state.init_for_account(account_id);
286 }
287 }
288 }
289 }
290
291 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 if self.focused_panel == FocusedPanel::Main {
299 self.ensure_selection_initialized();
300 }
301 }
302
303 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 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 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 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 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 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 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 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 self.group_form = GroupFormState::new();
427 self.input_mode = InputMode::Editing;
428 }
429 ActiveDialog::Budget => {
430 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 pub fn close_dialog(&mut self) {
466 self.active_dialog = ActiveDialog::None;
467 self.input_mode = InputMode::Normal;
468 }
469
470 pub fn has_dialog(&self) -> bool {
472 !matches!(self.active_dialog, ActiveDialog::None)
473 }
474
475 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 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 pub fn prev_period(&mut self) {
525 self.current_period = self.current_period.prev();
526 }
527
528 pub fn next_period(&mut self) {
530 self.current_period = self.current_period.next();
531 }
532
533 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 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}