use super::state::App;
use crate::app::state::AppMode;
use crate::db::category_store::CategoryStore;
use crate::db::transaction_store::TransactionStore;
use crate::model::{CategoryDraft, CategoryRecord, TransactionType};
use chrono::Duration;
impl App {
pub(crate) fn open_category_catalog(&mut self) {
if let Err(err) = self.reload_categories_from_store() {
self.set_status_message(format!("Error loading categories: {}", err), None);
return;
}
self.mode = AppMode::CategoryCatalog;
self.editing_category_id = None;
self.category_delete_id = None;
let selection = if self.category_records.is_empty() {
None
} else {
Some(
self.category_table_state
.selected()
.unwrap_or(0)
.min(self.category_records.len() - 1),
)
};
self.category_table_state.select(selection);
self.clear_status_message();
}
pub(crate) fn exit_category_catalog(&mut self) {
self.mode = AppMode::Settings;
self.category_delete_id = None;
self.clear_status_message();
}
pub(crate) fn next_category_record(&mut self) {
let len = self.category_records.len();
if len == 0 {
return;
}
let index = match self.category_table_state.selected() {
Some(current) if current + 1 < len => current + 1,
_ => 0,
};
self.category_table_state.select(Some(index));
}
pub(crate) fn previous_category_record(&mut self) {
let len = self.category_records.len();
if len == 0 {
return;
}
let index = match self.category_table_state.selected() {
Some(0) | None => len - 1,
Some(current) => current - 1,
};
self.category_table_state.select(Some(index));
}
pub(crate) fn start_adding_category(&mut self) {
self.mode = AppMode::CategoryEditor;
self.editing_category_id = None;
self.current_category_field = 0;
self.category_edit_fields = [
TransactionType::Expense.to_string(),
String::new(),
String::new(),
String::new(),
String::new(),
];
self.category_edit_cursor = self.category_edit_fields[0].len();
self.clear_status_message();
}
pub(crate) fn start_editing_category(&mut self) {
let Some(record) = self.selected_category_record().cloned() else {
self.set_status_message("Select a category first.", None);
return;
};
self.mode = AppMode::CategoryEditor;
self.editing_category_id = Some(record.id);
self.current_category_field = 0;
let draft = record.to_draft();
let target_budget = if draft.transaction_type == TransactionType::Expense {
draft
.target_budget
.map(|value| format!("{value:.2}"))
.unwrap_or_default()
} else {
String::new()
};
self.category_edit_fields = [
draft.transaction_type.to_string(),
draft.category,
draft.subcategory,
draft.tag.unwrap_or_default(),
target_budget,
];
self.category_edit_cursor = self.category_edit_fields[0].len();
self.clear_status_message();
}
pub(crate) fn exit_category_editor(&mut self, cancelled: bool) {
self.mode = AppMode::CategoryCatalog;
self.editing_category_id = None;
self.current_category_field = 0;
self.category_edit_fields = Default::default();
self.category_edit_cursor = 0;
if cancelled {
self.set_status_message("Category edit cancelled.", Some(Duration::seconds(3)));
} else {
self.clear_status_message();
}
}
pub(crate) fn next_category_field(&mut self) {
self.current_category_field =
(self.current_category_field + 1) % self.category_edit_fields.len();
self.category_edit_cursor = self.category_edit_fields[self.current_category_field].len();
}
pub(crate) fn previous_category_field(&mut self) {
if self.current_category_field == 0 {
self.current_category_field = self.category_edit_fields.len() - 1;
} else {
self.current_category_field -= 1;
}
self.category_edit_cursor = self.category_edit_fields[self.current_category_field].len();
}
pub(crate) fn toggle_category_transaction_type(&mut self) {
if self.current_category_field != 0 {
return;
}
let switching_to_income = !self.category_edit_fields[0].eq_ignore_ascii_case("income");
self.category_edit_fields[0] = if switching_to_income {
self.category_edit_fields[4].clear();
TransactionType::Income.to_string()
} else {
TransactionType::Expense.to_string()
};
self.category_edit_cursor = self.category_edit_fields[0].len();
}
pub(crate) fn prepare_delete_category(&mut self) {
let Some(record) = self.selected_category_record().cloned() else {
self.set_status_message("Select a category first.", None);
return;
};
self.category_delete_id = Some(record.id);
self.mode = AppMode::ConfirmCategoryDelete;
self.set_status_message(
format!(
"Delete {} / {}? Press y to confirm.",
record.category,
if record.subcategory.is_empty() {
"(No subcategory)"
} else {
record.subcategory.as_str()
}
),
None,
);
}
pub(crate) fn cancel_delete_category(&mut self) {
self.mode = AppMode::CategoryCatalog;
self.category_delete_id = None;
self.clear_status_message();
}
pub(crate) fn confirm_delete_category(&mut self) {
let Some(id) = self.category_delete_id else {
self.cancel_delete_category();
return;
};
let Some(record) = self
.category_records
.iter()
.find(|record| record.id == id)
.cloned()
else {
self.cancel_delete_category();
self.set_status_message("The selected category no longer exists.", None);
return;
};
let store = self.category_store();
if let Err(err) = store.delete(id) {
self.set_status_message(format!("Error deleting category: {}", err), None);
return;
}
if let Err(err) = self.transaction_store().apply_category_clear(&record) {
self.set_status_message(
format!(
"Category deleted, but updating transactions failed: {}",
err
),
None,
);
return;
}
if let Err(err) = self.reload_transactions_from_db() {
self.set_status_message(
format!(
"Category deleted, but reloading transactions failed: {}",
err
),
None,
);
return;
}
if let Err(err) = self.reload_categories_from_store() {
self.set_status_message(
format!("Category deleted, but refresh failed: {}", err),
None,
);
return;
}
self.mode = AppMode::CategoryCatalog;
self.category_delete_id = None;
self.clamp_category_catalog_selection();
self.set_status_message("Category deleted successfully.", Some(Duration::seconds(3)));
}
pub(crate) fn save_category(&mut self) {
let mut draft = match self.build_category_draft_from_editor() {
Ok(draft) => draft,
Err(message) => {
self.set_status_message(format!("Error: {}", message), None);
return;
}
};
let existing_record = self.editing_category_id.and_then(|id| {
self.category_records
.iter()
.find(|record| record.id == id)
.cloned()
});
let editing_category_id = self.editing_category_id;
if draft.transaction_type == TransactionType::Income
&& draft.target_budget.is_none()
&& let Some(old_record) = existing_record.as_ref()
&& old_record.transaction_type == TransactionType::Income
{
draft.target_budget = old_record.target_budget;
}
let store = self.category_store();
let result = if let Some(id) = editing_category_id {
store.update(id, &draft).map(|_| id)
} else {
store.insert(&draft).map(|record| record.id)
};
let saved_id = match result {
Ok(saved_id) => saved_id,
Err(err) => {
self.set_status_message(format!("Error saving category: {}", err), None);
return;
}
};
if let Some(old_record) = existing_record {
if let Err(err) = self
.transaction_store()
.apply_category_rename(&old_record, &draft)
{
self.set_status_message(
format!("Category saved, but updating transactions failed: {}", err),
None,
);
return;
}
if let Err(err) = self.reload_transactions_from_db() {
self.set_status_message(
format!("Category saved, but reloading transactions failed: {}", err),
None,
);
return;
}
}
if let Err(err) = self.reload_categories_from_store() {
self.set_status_message(format!("Category saved, but refresh failed: {}", err), None);
return;
}
self.mode = AppMode::CategoryCatalog;
self.editing_category_id = Some(saved_id);
self.current_category_field = 0;
self.category_edit_fields = Default::default();
self.category_edit_cursor = 0;
self.select_saved_category();
self.editing_category_id = None;
self.set_status_message("Category saved successfully.", Some(Duration::seconds(3)));
}
fn selected_category_record(&self) -> Option<&CategoryRecord> {
self.category_table_state
.selected()
.and_then(|index| self.category_records.get(index))
}
fn clamp_category_catalog_selection(&mut self) {
let selection = if self.category_records.is_empty() {
None
} else {
Some(
self.category_table_state
.selected()
.unwrap_or(0)
.min(self.category_records.len() - 1),
)
};
self.category_table_state.select(selection);
}
fn select_saved_category(&mut self) {
let selection = self.editing_category_id.and_then(|id| {
self.category_records
.iter()
.position(|record| record.id == id)
});
if selection.is_some() {
self.category_table_state.select(selection);
} else {
self.clamp_category_catalog_selection();
}
}
fn build_category_draft_from_editor(&self) -> Result<CategoryDraft, String> {
let transaction_type = TransactionType::try_from(self.category_edit_fields[0].trim())
.map_err(|_| "Transaction type must be Income or Expense.".to_string())?;
let category = self.category_edit_fields[1].trim().to_string();
let subcategory = self.category_edit_fields[2].trim().to_string();
let tag = self.category_edit_fields[3].trim();
let target_budget_str = self.category_edit_fields[4].trim();
if category.is_empty() {
return Err("Category cannot be empty.".to_string());
}
let target_budget = if target_budget_str.is_empty() {
None
} else {
Some(crate::validation::validate_amount_string(
target_budget_str,
)?)
};
if transaction_type == TransactionType::Income && target_budget.is_some() {
return Err("Target budget is only available for expense categories.".to_string());
}
Ok(CategoryDraft {
transaction_type,
category,
subcategory,
tag: if tag.is_empty() {
None
} else {
Some(tag.to_string())
},
target_budget,
})
}
}