Add feature batch 2, subscription/recurring sync, smooth charts, and app icon

This commit is contained in:
2026-03-03 21:18:37 +02:00
parent f9e293c30e
commit 577cd54a9e
10102 changed files with 107853 additions and 1318 deletions

View File

@@ -1,43 +1,89 @@
use adw::prelude::*;
use gtk::glib;
use chrono::Datelike;
use gtk::{gdk, glib};
use outlay_core::db::Database;
use std::cell::Cell;
use std::rc::Rc;
use crate::budgets_view::BudgetsView;
use crate::icon_theme;
use crate::charts_view::ChartsView;
use crate::credit_cards_view::CreditCardsView;
use crate::forecast_view::ForecastView;
use crate::insights_view::InsightsView;
use crate::goals_view::GoalsView;
use crate::history_view::HistoryView;
use crate::log_view::LogView;
use crate::recurring_view::RecurringView;
use crate::settings_view::SettingsView;
use crate::subscriptions_view::SubscriptionsView;
use crate::wishlist_view::WishlistView;
pub struct MainWindow {
pub window: adw::ApplicationWindow,
pub split_view: adw::NavigationSplitView,
pub content_stack: gtk::Stack,
pub log_view: LogView,
pub log_view: Rc<LogView>,
pub history_view: Rc<HistoryView>,
pub charts_view: Rc<ChartsView>,
pub budgets_view: Rc<BudgetsView>,
pub insights_view: Rc<InsightsView>,
sidebar_list: gtk::ListBox,
content_stack: gtk::Stack,
content_page: adw::NavigationPage,
}
struct SidebarItem {
id: &'static str,
label: &'static str,
icon: &'static str,
color: &'static str, // CSS color visible in both light and dark modes
}
const SIDEBAR_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "log", label: "Log", icon: "list-add-symbolic" },
SidebarItem { id: "history", label: "History", icon: "document-open-recent-symbolic" },
SidebarItem { id: "charts", label: "Charts", icon: "utilities-system-monitor-symbolic" },
SidebarItem { id: "budgets", label: "Budgets", icon: "wallet2-symbolic" },
SidebarItem { id: "recurring", label: "Recurring", icon: "view-refresh-symbolic" },
SidebarItem { id: "settings", label: "Settings", icon: "emblem-system-symbolic" },
struct SidebarSection {
label: &'static str,
items: &'static [SidebarItem],
}
const SIDEBAR_SECTIONS: &[SidebarSection] = &[
SidebarSection {
label: "TRACKING",
items: &[
SidebarItem { id: "log", label: "Log", icon: "outlay-log", color: "#4dabf7" },
SidebarItem { id: "history", label: "History", icon: "outlay-history", color: "#9775fa" },
SidebarItem { id: "charts", label: "Charts", icon: "outlay-charts", color: "#ff8787" },
],
},
SidebarSection {
label: "PLANNING",
items: &[
SidebarItem { id: "budgets", label: "Budgets", icon: "outlay-budgets", color: "#69db7c" },
SidebarItem { id: "goals", label: "Goals", icon: "outlay-goals", color: "#ffd43b" },
SidebarItem { id: "forecast", label: "Forecast", icon: "outlay-forecast", color: "#74c0fc" },
],
},
SidebarSection {
label: "MANAGEMENT",
items: &[
SidebarItem { id: "recurring", label: "Recurring", icon: "outlay-recurring", color: "#38d9a9" },
SidebarItem { id: "subscriptions", label: "Subscriptions", icon: "outlay-subscriptions", color: "#e599f7" },
SidebarItem { id: "wishlist", label: "Wishlist", icon: "outlay-wishlist", color: "#ffa94d" },
SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards", color: "#a9e34b" },
SidebarItem { id: "insights", label: "Insights", icon: "outlay-insights", color: "#f783ac" },
],
},
];
const SETTINGS_ITEM: SidebarItem = SidebarItem { id: "settings", label: "Settings", icon: "outlay-settings", color: "#adb5bd" };
fn all_sidebar_items() -> Vec<&'static SidebarItem> {
SIDEBAR_SECTIONS.iter().flat_map(|s| s.items.iter()).collect()
}
impl MainWindow {
pub fn new(app: &adw::Application, db: Rc<Database>) -> Self {
let content_stack = gtk::Stack::new();
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
let log_view = LogView::new(db.clone(), app);
let log_view = Rc::new(LogView::new(db.clone(), app));
let log_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&log_view.container)
@@ -57,45 +103,107 @@ impl MainWindow {
let budgets_view = BudgetsView::new(db.clone());
content_stack.add_named(&budgets_view.container, Some("budgets"));
let recurring_view = RecurringView::new(db.clone());
let goals_view = GoalsView::new(db.clone());
content_stack.add_named(&goals_view.container, Some("goals"));
let recurring_view = Rc::new(RecurringView::new(db.clone()));
content_stack.add_named(&recurring_view.container, Some("recurring"));
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
// Cross-view refresh: changes in subscriptions refresh recurring and vice versa
{
let rec_ref = recurring_view.clone();
subscriptions_view.set_on_change(move || {
rec_ref.refresh();
});
}
{
let sub_ref = subscriptions_view.clone();
recurring_view.set_on_change(move || {
sub_ref.refresh();
});
}
let wishlist_view = WishlistView::new(db.clone());
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
let forecast_view = ForecastView::new(db.clone());
content_stack.add_named(&forecast_view.container, Some("forecast"));
let insights_view = InsightsView::new(db.clone());
content_stack.add_named(&insights_view.container, Some("insights"));
let credit_cards_view = CreditCardsView::new(db.clone());
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
let settings_view = SettingsView::new(db.clone(), app);
content_stack.add_named(&settings_view.container, Some("settings"));
// Main sidebar items (top) - grouped by section with headers
let sidebar_list = gtk::ListBox::new();
sidebar_list.set_selection_mode(gtk::SelectionMode::Single);
sidebar_list.add_css_class("navigation-sidebar");
for item in SIDEBAR_ITEMS {
let all_items = all_sidebar_items();
for item in &all_items {
let row = Self::make_sidebar_row(item);
sidebar_list.append(&row);
}
let content_stack_ref = content_stack.clone();
sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let idx = row.index() as usize;
if idx < SIDEBAR_ITEMS.len() {
content_stack_ref.set_visible_child_name(SIDEBAR_ITEMS[idx].id);
}
}
});
// Select the first row by default
if let Some(first_row) = sidebar_list.row_at_index(0) {
sidebar_list.select_row(Some(&first_row));
// Compute section boundary indices: the row index where each section starts
// TRACKING: 0, PLANNING: 3, MANAGEMENT: 6
let mut section_starts: Vec<(i32, &'static str)> = Vec::new();
let mut idx = 0i32;
for section in SIDEBAR_SECTIONS {
section_starts.push((idx, section.label));
idx += section.items.len() as i32;
}
let sidebar_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&sidebar_list)
.build();
sidebar_list.set_header_func(move |row, _before| {
let ri = row.index();
for &(start, label) in &section_starts {
if ri == start {
let header_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
// Add separator before all sections except the first
if start > 0 {
let sep = gtk::Separator::new(gtk::Orientation::Horizontal);
sep.set_margin_top(6);
sep.set_margin_bottom(2);
sep.set_margin_start(12);
sep.set_margin_end(12);
header_box.append(&sep);
}
let section_label = gtk::Label::new(Some(label));
section_label.add_css_class("sidebar-section-label");
section_label.set_halign(gtk::Align::Start);
header_box.append(&section_label);
row.set_header(Some(&header_box));
return;
}
}
row.set_header(gtk::Widget::NONE);
});
// Settings item (bottom)
let settings_list = gtk::ListBox::new();
settings_list.set_selection_mode(gtk::SelectionMode::Single);
settings_list.add_css_class("navigation-sidebar");
settings_list.append(&Self::make_sidebar_row(&SETTINGS_ITEM));
let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
sidebar_list.set_vexpand(true);
sidebar_box.append(&sidebar_list);
sidebar_box.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
sidebar_box.append(&settings_list);
let sidebar_toolbar = adw::ToolbarView::new();
sidebar_toolbar.add_top_bar(&adw::HeaderBar::new());
sidebar_toolbar.set_content(Some(&sidebar_scroll));
sidebar_toolbar.set_content(Some(&sidebar_box));
let sidebar_page = adw::NavigationPage::builder()
.title("Outlay")
@@ -107,10 +215,114 @@ impl MainWindow {
content_toolbar.set_content(Some(&content_stack));
let content_page = adw::NavigationPage::builder()
.title("Outlay")
.title("Log")
.child(&content_toolbar)
.build();
// Wire sidebar selection to switch content, update title, and refresh views
let history_view_ref = Rc::new(history_view);
let charts_view_ref = Rc::new(charts_view);
let budgets_view_ref = Rc::new(budgets_view);
let insights_view_ref = Rc::new(insights_view);
// Wire up data reset callback to refresh all views
{
let log_ref = log_view.clone();
let hist_ref = history_view_ref.clone();
let chart_ref = charts_view_ref.clone();
let budget_ref = budgets_view_ref.clone();
let insights_ref = insights_view_ref.clone();
settings_view.set_on_data_reset(move || {
log_ref.refresh_categories();
hist_ref.refresh();
chart_ref.refresh();
let today = chrono::Local::now().date_naive();
budget_ref.set_month(today.year(), today.month());
insights_ref.refresh();
});
}
// Shared month state for syncing across views
let shared_month: Rc<Cell<(i32, u32)>> = {
let today = chrono::Local::now().date_naive();
Rc::new(Cell::new((today.year(), today.month())))
};
// Main list selection - deselect settings
{
let content_stack_ref = content_stack.clone();
let content_page_ref = content_page.clone();
let history_ref = history_view_ref.clone();
let charts_ref = charts_view_ref.clone();
let budgets_ref = budgets_view_ref.clone();
let log_ref = log_view.clone();
let insights_ref = insights_view_ref.clone();
let settings_list_ref = settings_list.clone();
let shared = shared_month.clone();
sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
settings_list_ref.unselect_all();
let idx = row.index() as usize;
if idx < all_sidebar_items().len() {
// Save month from the view we're leaving
let current_view = content_stack_ref.visible_child_name();
if let Some(ref name) = current_view {
let month = match name.as_str() {
"history" => Some(history_ref.get_month()),
"charts" => Some(charts_ref.get_month()),
"budgets" => Some(budgets_ref.get_month()),
_ => None,
};
if let Some(m) = month {
shared.set(m);
}
}
content_stack_ref.set_visible_child_name(all_sidebar_items()[idx].id);
content_page_ref.set_title(all_sidebar_items()[idx].label);
// Sync month to the view we're entering and refresh
let (sy, sm) = shared.get();
match all_sidebar_items()[idx].id {
"log" => log_ref.refresh_categories(),
"history" => {
history_ref.set_month(sy, sm);
}
"charts" => {
charts_ref.set_month(sy, sm);
}
"budgets" => {
budgets_ref.set_month(sy, sm);
}
"insights" => {
insights_ref.refresh();
}
_ => {}
}
}
}
});
}
// Settings list selection - deselect main list
{
let content_stack_ref = content_stack.clone();
let content_page_ref = content_page.clone();
let sidebar_list_ref = sidebar_list.clone();
settings_list.connect_row_selected(move |_, row| {
if row.is_some() {
sidebar_list_ref.unselect_all();
content_stack_ref.set_visible_child_name(SETTINGS_ITEM.id);
content_page_ref.set_title(SETTINGS_ITEM.label);
}
});
}
// Select the first row by default
if let Some(first_row) = sidebar_list.row_at_index(0) {
sidebar_list.select_row(Some(&first_row));
}
let split_view = adw::NavigationSplitView::new();
split_view.set_sidebar(Some(&sidebar_page));
split_view.set_content(Some(&content_page));
@@ -147,7 +359,7 @@ impl MainWindow {
window.maximize();
}
// Save window size on close
// Hide window on close instead of quitting
{
let db_ref = db.clone();
window.connect_close_request(move |win| {
@@ -157,28 +369,260 @@ impl MainWindow {
db_ref
.set_setting("window_maximized", if win.is_maximized() { "true" } else { "false" })
.ok();
glib::Propagation::Proceed
win.set_visible(false);
glib::Propagation::Stop
});
}
// Keyboard shortcuts
{
let key_ctrl = gtk::EventControllerKey::new();
let sidebar_ref = sidebar_list.clone();
let settings_list_ref = settings_list.clone();
let content_ref = content_stack.clone();
let page_ref = content_page.clone();
let log_ref = log_view.clone();
let hist_ref = history_view_ref.clone();
let chart_ref = charts_view_ref.clone();
let window_ref = window.clone();
key_ctrl.connect_key_pressed(move |_, key, _, modifier| {
if !modifier.contains(gdk::ModifierType::CONTROL_MASK) {
return glib::Propagation::Proceed;
}
match key {
gdk::Key::_1 | gdk::Key::_2 | gdk::Key::_3 | gdk::Key::_4 | gdk::Key::_5 | gdk::Key::_6 | gdk::Key::_7 | gdk::Key::_8 | gdk::Key::_9 | gdk::Key::_0 => {
let idx = match key {
gdk::Key::_1 => 0,
gdk::Key::_2 => 1,
gdk::Key::_3 => 2,
gdk::Key::_4 => 3,
gdk::Key::_5 => 4,
gdk::Key::_6 => 5,
gdk::Key::_7 => 6,
gdk::Key::_8 => 7,
gdk::Key::_9 => 8,
gdk::Key::_0 => 9,
_ => 0,
};
if let Some(row) = sidebar_ref.row_at_index(idx) {
settings_list_ref.unselect_all();
sidebar_ref.select_row(Some(&row));
content_ref.set_visible_child_name(all_sidebar_items()[idx as usize].id);
page_ref.set_title(all_sidebar_items()[idx as usize].label);
if idx == 0 { log_ref.refresh_categories(); }
if idx == 1 { hist_ref.refresh(); }
if idx == 2 { chart_ref.refresh(); }
}
}
gdk::Key::comma => {
sidebar_ref.unselect_all();
settings_list_ref.select_row(settings_list_ref.row_at_index(0).as_ref());
content_ref.set_visible_child_name(SETTINGS_ITEM.id);
page_ref.set_title(SETTINGS_ITEM.label);
}
gdk::Key::e => {
if let Some(row) = sidebar_ref.row_at_index(0) {
settings_list_ref.unselect_all();
sidebar_ref.select_row(Some(&row));
}
content_ref.set_visible_child_name("log");
page_ref.set_title("Log");
log_ref.refresh_categories();
log_ref.set_income_mode(false);
log_ref.focus_amount();
}
gdk::Key::i => {
if let Some(row) = sidebar_ref.row_at_index(0) {
settings_list_ref.unselect_all();
sidebar_ref.select_row(Some(&row));
}
content_ref.set_visible_child_name("log");
page_ref.set_title("Log");
log_ref.refresh_categories();
log_ref.set_income_mode(true);
log_ref.focus_amount();
}
gdk::Key::question => {
Self::show_shortcuts_window(&window_ref);
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(key_ctrl);
}
MainWindow {
window,
split_view,
content_stack,
log_view,
history_view: history_view_ref,
charts_view: charts_view_ref,
budgets_view: budgets_view_ref,
insights_view: insights_view_ref,
sidebar_list,
content_stack,
content_page,
}
}
pub fn show(&self) {
self.window.set_visible(true);
self.window.present();
}
pub fn switch_to_log(&self, income: bool) {
if let Some(row) = self.sidebar_list.row_at_index(0) {
self.sidebar_list.select_row(Some(&row));
}
self.content_stack.set_visible_child_name("log");
self.content_page.set_title("Log");
self.log_view.refresh_categories();
self.log_view.set_income_mode(income);
self.log_view.focus_amount();
}
pub fn switch_to_history_filtered(&self, category_id: i64) {
if let Some(row) = self.sidebar_list.row_at_index(1) {
self.sidebar_list.select_row(Some(&row));
}
self.content_stack.set_visible_child_name("history");
self.content_page.set_title("History");
self.history_view.refresh();
self.history_view.set_category_filter(category_id);
}
pub fn switch_to_insights(&self) {
// Find the sidebar index for "insights"
let idx = all_sidebar_items().iter().position(|item| item.id == "insights");
if let Some(i) = idx {
if let Some(row) = self.sidebar_list.row_at_index(i as i32) {
self.sidebar_list.select_row(Some(&row));
}
}
self.content_stack.set_visible_child_name("insights");
self.content_page.set_title("Insights");
self.insights_view.refresh();
}
pub fn save_window_state(&self, db: &Database) {
let (width, height) = self.window.default_size();
db.set_setting("window_width", &width.to_string()).ok();
db.set_setting("window_height", &height.to_string()).ok();
db.set_setting(
"window_maximized",
if self.window.is_maximized() { "true" } else { "false" },
)
.ok();
}
fn show_shortcuts_window(parent: &adw::ApplicationWindow) {
let ui = r#"
<interface>
<object class="GtkShortcutsWindow" id="shortcuts_window">
<property name="modal">true</property>
<child>
<object class="GtkShortcutsSection">
<property name="title">Outlay</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title">Navigation</property>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;1</property>
<property name="title">Log</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;2</property>
<property name="title">History</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;3</property>
<property name="title">Charts</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;4</property>
<property name="title">Budgets</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;5</property>
<property name="title">Goals</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;6</property>
<property name="title">Recurring</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;7</property>
<property name="title">Subscriptions</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;8</property>
<property name="title">Wishlist</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;9</property>
<property name="title">Forecast</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;0</property>
<property name="title">Insights</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;comma</property>
<property name="title">Settings</property>
</object></child>
</object>
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="title">Transaction Entry</property>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;e</property>
<property name="title">New expense</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;i</property>
<property name="title">New income</property>
</object></child>
</object>
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="title">General</property>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;question</property>
<property name="title">Keyboard shortcuts</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;q</property>
<property name="title">Quit</property>
</object></child>
</object>
</child>
</object>
</child>
</object>
</interface>"#;
let builder = gtk::Builder::from_string(ui);
let win: gtk::ShortcutsWindow = builder.object("shortcuts_window").unwrap();
win.set_transient_for(Some(parent));
win.present();
}
fn make_sidebar_row(item: &SidebarItem) -> gtk::ListBoxRow {
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 12);
hbox.set_margin_top(8);
hbox.set_margin_bottom(8);
hbox.set_margin_start(12);
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 14);
hbox.set_margin_top(12);
hbox.set_margin_bottom(12);
hbox.set_margin_start(16);
hbox.set_margin_end(12);
let icon = gtk::Image::from_icon_name(item.icon);
let tinted = icon_theme::get_tinted_icon_name(item.icon, item.color);
let icon = gtk::Image::from_icon_name(&tinted);
icon.set_pixel_size(24);
let label = gtk::Label::new(Some(item.label));
label.set_halign(gtk::Align::Start);
label.add_css_class("sidebar-label");
hbox.append(&icon);
hbox.append(&label);