use crate::config::paths::EnvelopePaths;
use crate::config::settings::Settings;
use crate::models::{AccountId, BudgetPeriod, CategoryGroupId, CategoryId, TransactionId};
use crate::storage::Storage;
use super::dialogs::account::AccountFormState;
use super::dialogs::adjustment::AdjustmentDialogState;
use super::dialogs::budget::BudgetDialogState;
use super::dialogs::bulk_categorize::BulkCategorizeState;
use super::dialogs::category::CategoryFormState;
use super::dialogs::group::GroupFormState;
use super::dialogs::income::IncomeFormState;
use super::dialogs::move_funds::MoveFundsState;
use super::dialogs::reconcile_start::ReconcileStartState;
use super::dialogs::transaction::TransactionFormState;
use super::dialogs::unlock_confirm::UnlockConfirmState;
use super::views::reconcile::ReconciliationState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActiveView {
#[default]
Accounts,
Register,
Budget,
Reports,
Reconcile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BudgetHeaderDisplay {
#[default]
AvailableToBudget,
Checking,
Savings,
Credit,
Cash,
Investment,
LineOfCredit,
Other,
}
impl BudgetHeaderDisplay {
pub fn next(self) -> Self {
match self {
Self::AvailableToBudget => Self::Checking,
Self::Checking => Self::Savings,
Self::Savings => Self::Credit,
Self::Credit => Self::Cash,
Self::Cash => Self::Investment,
Self::Investment => Self::LineOfCredit,
Self::LineOfCredit => Self::Other,
Self::Other => Self::AvailableToBudget,
}
}
pub fn prev(self) -> Self {
match self {
Self::AvailableToBudget => Self::Other,
Self::Checking => Self::AvailableToBudget,
Self::Savings => Self::Checking,
Self::Credit => Self::Savings,
Self::Cash => Self::Credit,
Self::Investment => Self::Cash,
Self::LineOfCredit => Self::Investment,
Self::Other => Self::LineOfCredit,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::AvailableToBudget => "Available to Assign",
Self::Checking => "Checking",
Self::Savings => "Savings",
Self::Credit => "Credit Cards",
Self::Cash => "Cash",
Self::Investment => "Investment",
Self::LineOfCredit => "Line of Credit",
Self::Other => "Other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusedPanel {
#[default]
Sidebar,
Main,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
Normal,
Editing,
Command,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ActiveDialog {
#[default]
None,
AddTransaction,
EditTransaction(TransactionId),
AddAccount,
EditAccount(AccountId),
AddCategory,
EditCategory(CategoryId),
AddGroup,
EditGroup(CategoryGroupId),
MoveFunds,
CommandPalette,
Help,
Confirm(String),
BulkCategorize,
ReconcileStart,
UnlockConfirm(UnlockConfirmState),
Adjustment,
Budget,
Income,
}
pub struct App<'a> {
pub storage: &'a Storage,
pub settings: &'a Settings,
pub paths: &'a EnvelopePaths,
pub should_quit: bool,
pub active_view: ActiveView,
pub focused_panel: FocusedPanel,
pub input_mode: InputMode,
pub active_dialog: ActiveDialog,
pub selected_account: Option<AccountId>,
pub selected_account_index: usize,
pub selected_transaction: Option<TransactionId>,
pub selected_transaction_index: usize,
pub selected_category: Option<CategoryId>,
pub selected_category_index: usize,
pub current_period: BudgetPeriod,
pub budget_header_display: BudgetHeaderDisplay,
pub show_archived: bool,
pub multi_select_mode: bool,
pub selected_transactions: Vec<TransactionId>,
pub scroll_offset: usize,
pub status_message: Option<String>,
pub command_input: String,
pub command_results: Vec<usize>,
pub selected_command_index: usize,
pub transaction_form: TransactionFormState,
pub move_funds_state: MoveFundsState,
pub bulk_categorize_state: BulkCategorizeState,
pub reconciliation_state: ReconciliationState,
pub reconcile_start_state: ReconcileStartState,
pub adjustment_dialog_state: AdjustmentDialogState,
pub account_form: AccountFormState,
pub category_form: CategoryFormState,
pub group_form: GroupFormState,
pub budget_dialog_state: BudgetDialogState,
pub income_form: IncomeFormState,
pub pending_g: bool,
}
impl<'a> App<'a> {
pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
let selected_account = storage
.accounts
.get_active()
.ok()
.and_then(|accounts| accounts.first().map(|a| a.id));
Self {
storage,
settings,
paths,
should_quit: false,
active_view: ActiveView::default(),
focused_panel: FocusedPanel::default(),
input_mode: InputMode::default(),
active_dialog: ActiveDialog::default(),
selected_account,
selected_account_index: 0,
selected_transaction: None,
selected_transaction_index: 0,
selected_category: None,
selected_category_index: 0,
current_period: BudgetPeriod::current_month(),
budget_header_display: BudgetHeaderDisplay::default(),
show_archived: false,
multi_select_mode: false,
selected_transactions: Vec::new(),
scroll_offset: 0,
status_message: None,
command_input: String::new(),
command_results: Vec::new(),
selected_command_index: 0,
transaction_form: TransactionFormState::new(),
move_funds_state: MoveFundsState::new(),
bulk_categorize_state: BulkCategorizeState::new(),
reconciliation_state: ReconciliationState::new(),
reconcile_start_state: ReconcileStartState::new(),
adjustment_dialog_state: AdjustmentDialogState::default(),
account_form: AccountFormState::new(),
category_form: CategoryFormState::new(),
group_form: GroupFormState::new(),
budget_dialog_state: BudgetDialogState::new(),
income_form: IncomeFormState::new(),
pending_g: false,
}
}
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
}
pub fn clear_status(&mut self) {
self.status_message = None;
}
pub fn switch_view(&mut self, view: ActiveView) {
self.active_view = view;
self.scroll_offset = 0;
match view {
ActiveView::Accounts => {
self.selected_account_index = 0;
if let Ok(accounts) = self.storage.accounts.get_active() {
self.selected_account = accounts.first().map(|a| a.id);
}
}
ActiveView::Register => {
self.selected_transaction_index = 0;
if let Some(account_id) = self.selected_account {
let mut txns = self
.storage
.transactions
.get_by_account(account_id)
.unwrap_or_default();
txns.sort_by(|a, b| b.date.cmp(&a.date));
self.selected_transaction = txns.first().map(|t| t.id);
}
}
ActiveView::Budget => {
self.selected_category_index = 0;
let groups = self.storage.categories.get_all_groups().unwrap_or_default();
let all_categories = self
.storage
.categories
.get_all_categories()
.unwrap_or_default();
for group in &groups {
if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
self.selected_category = Some(cat.id);
break;
}
}
}
ActiveView::Reports => {}
ActiveView::Reconcile => {
if let Some(account_id) = self.selected_account {
self.reconciliation_state.init_for_account(account_id);
}
}
}
}
pub fn toggle_panel_focus(&mut self) {
self.focused_panel = match self.focused_panel {
FocusedPanel::Sidebar => FocusedPanel::Main,
FocusedPanel::Main => FocusedPanel::Sidebar,
};
if self.focused_panel == FocusedPanel::Main {
self.ensure_selection_initialized();
}
}
pub fn ensure_selection_initialized(&mut self) {
match self.active_view {
ActiveView::Accounts => {
if self.selected_account.is_none() {
if let Ok(accounts) = self.storage.accounts.get_active() {
self.selected_account = accounts.first().map(|a| a.id);
}
}
}
ActiveView::Register => {
if self.selected_transaction.is_none() {
if let Some(account_id) = self.selected_account {
let mut txns = self
.storage
.transactions
.get_by_account(account_id)
.unwrap_or_default();
txns.sort_by(|a, b| b.date.cmp(&a.date));
self.selected_transaction = txns.first().map(|t| t.id);
}
}
}
ActiveView::Budget => {
if self.selected_category.is_none() {
let groups = self.storage.categories.get_all_groups().unwrap_or_default();
let all_categories = self
.storage
.categories
.get_all_categories()
.unwrap_or_default();
for group in &groups {
if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
self.selected_category = Some(cat.id);
break;
}
}
}
}
_ => {}
}
}
pub fn open_dialog(&mut self, dialog: ActiveDialog) {
self.active_dialog = dialog.clone();
match &dialog {
ActiveDialog::CommandPalette => {
self.command_input.clear();
self.input_mode = InputMode::Command;
}
ActiveDialog::AddTransaction => {
self.transaction_form = TransactionFormState::new();
self.transaction_form
.set_focus(super::dialogs::transaction::TransactionField::Date);
self.input_mode = InputMode::Editing;
}
ActiveDialog::EditTransaction(txn_id) => {
if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
let categories: Vec<_> = self
.storage
.categories
.get_all_categories()
.unwrap_or_default()
.iter()
.map(|c| (c.id, c.name.clone()))
.collect();
self.transaction_form =
TransactionFormState::from_transaction(&txn, &categories);
self.transaction_form
.set_focus(super::dialogs::transaction::TransactionField::Date);
}
self.input_mode = InputMode::Editing;
}
ActiveDialog::AddAccount => {
self.account_form = AccountFormState::new();
self.account_form
.set_focus(super::dialogs::account::AccountField::Name);
self.input_mode = InputMode::Editing;
}
ActiveDialog::EditAccount(account_id) => {
if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
self.account_form = AccountFormState::from_account(&account);
self.account_form
.set_focus(super::dialogs::account::AccountField::Name);
}
self.input_mode = InputMode::Editing;
}
ActiveDialog::AddCategory => {
self.category_form = CategoryFormState::new();
let groups: Vec<_> = self
.storage
.categories
.get_all_groups()
.unwrap_or_default()
.into_iter()
.map(|g| (g.id, g.name))
.collect();
self.category_form.init_with_groups(groups);
self.input_mode = InputMode::Editing;
}
ActiveDialog::EditCategory(category_id) => {
if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
let groups: Vec<_> = self
.storage
.categories
.get_all_groups()
.unwrap_or_default()
.into_iter()
.map(|g| (g.id, g.name.clone()))
.collect();
self.category_form.init_for_edit(&category, groups);
}
self.input_mode = InputMode::Editing;
}
ActiveDialog::AddGroup => {
self.group_form = GroupFormState::new();
self.input_mode = InputMode::Editing;
}
ActiveDialog::EditGroup(group_id) => {
if let Ok(Some(group)) = self.storage.categories.get_group(*group_id) {
self.group_form = GroupFormState::new();
self.group_form.init_for_edit(&group);
}
self.input_mode = InputMode::Editing;
}
ActiveDialog::Budget => {
if let Some(category_id) = self.selected_category {
if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
let budget_service = crate::services::BudgetService::new(self.storage);
let summary = budget_service
.get_category_summary(category_id, &self.current_period)
.unwrap_or_else(|_| {
crate::models::CategoryBudgetSummary::empty(category_id)
});
let suggested = budget_service
.get_suggested_budget_with_progress(category_id, &self.current_period)
.ok()
.flatten();
let existing_target = self
.storage
.targets
.get_for_category(category_id)
.ok()
.flatten();
self.budget_dialog_state.init_for_category(
category_id,
category.name,
summary.budgeted,
suggested,
existing_target.as_ref(),
);
self.input_mode = InputMode::Editing;
}
}
}
ActiveDialog::Income => {
self.income_form
.init_for_period(&self.current_period, self.storage);
self.input_mode = InputMode::Editing;
}
_ => {}
}
}
pub fn close_dialog(&mut self) {
self.active_dialog = ActiveDialog::None;
self.input_mode = InputMode::Normal;
}
pub fn has_dialog(&self) -> bool {
!matches!(self.active_dialog, ActiveDialog::None)
}
pub fn move_up(&mut self) {
match self.focused_panel {
FocusedPanel::Sidebar => {
if self.selected_account_index > 0 {
self.selected_account_index -= 1;
}
}
FocusedPanel::Main => match self.active_view {
ActiveView::Register => {
if self.selected_transaction_index > 0 {
self.selected_transaction_index -= 1;
}
}
ActiveView::Budget => {
if self.selected_category_index > 0 {
self.selected_category_index -= 1;
}
}
_ => {}
},
}
}
pub fn move_down(&mut self, max: usize) {
match self.focused_panel {
FocusedPanel::Sidebar => {
if self.selected_account_index < max.saturating_sub(1) {
self.selected_account_index += 1;
}
}
FocusedPanel::Main => match self.active_view {
ActiveView::Register => {
if self.selected_transaction_index < max.saturating_sub(1) {
self.selected_transaction_index += 1;
}
}
ActiveView::Budget => {
if self.selected_category_index < max.saturating_sub(1) {
self.selected_category_index += 1;
}
}
_ => {}
},
}
}
pub fn prev_period(&mut self) {
self.current_period = self.current_period.prev();
}
pub fn next_period(&mut self) {
self.current_period = self.current_period.next();
}
pub fn toggle_multi_select(&mut self) {
self.multi_select_mode = !self.multi_select_mode;
if !self.multi_select_mode {
self.selected_transactions.clear();
}
}
pub fn toggle_transaction_selection(&mut self) {
if let Some(txn_id) = self.selected_transaction {
if self.selected_transactions.contains(&txn_id) {
self.selected_transactions.retain(|&id| id != txn_id);
} else {
self.selected_transactions.push(txn_id);
}
}
}
}