Compare commits

..

36 Commits

Author SHA1 Message Date
lashman eec84fa1bc Update README.md 2026-03-09 16:31:49 +02:00
lashman 1849537c0c Add Makefile with install target for distro packaging
Adds build, install, and uninstall targets with standard PREFIX/DESTDIR
support. Installs binary, desktop file, metainfo, GSettings schema,
app icons, scalable action icons (outlay + tabler), and bundled fonts.
This allows distro packagers (Nix, Arch, Debian, etc.) to properly
install icons alongside the binary so GTK can find them at runtime.
2026-03-04 23:44:00 +02:00
lashman bdf200211b Change app ID to com.outlay.app, add AppStream metadata, fix toast visibility
- Change app ID from io.github.outlay to com.outlay.app across all files
- Add AppStream metainfo with full feature description, 16 screenshots, and v0.1.0 release
- Update desktop file with expanded metadata (StartupNotify, SingleMainWindow)
- Add summary and description fields to GSchema keys
- Move toast overlay outside ScrolledWindow so notifications stay visible in viewport
- Embed tray icon as ARGB pixmap data for reliable system tray display
- Register hicolor icon theme path for taskbar icon on Wayland
- Remove unused icon variants (old naming, web favicons, SVG, ICO, shadow)
- Add screenshots to data/screenshots/
- Update build script with metainfo and screenshot bundling
2026-03-03 22:15:59 +02:00
lashman f46a86134a Bust icon cache in README 2026-03-03 21:35:50 +02:00
lashman a6626bfbe3 Add emojis to README section headers 2026-03-03 21:33:56 +02:00
lashman c6677979fe Fix icon exports to have transparent background instead of checkerboard 2026-03-03 21:31:05 +02:00
lashman 3c5f96dc49 Add project README with full feature documentation 2026-03-03 21:26:07 +02:00
lashman 10a76e3003 Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
2026-03-03 21:18:37 +02:00
lashman 773dae4684 Add feature batch 2 implementation plan (21 tasks)
Detailed step-by-step plan covering schema migration, core modules
(NLP, Sankey, PDF import), shared utilities, new views (Insights,
Credit Cards), and modifications to existing views.
2026-03-03 16:30:49 +02:00
lashman 524672a42e Add feature batch 2 design doc (16 features)
Covers custom budget cycles, bulk operations, monthly recap,
natural language entry, Sankey diagram, what-if sandbox, tags UI,
splits UI, templates UI, auto-categorization rules UI, searchable
category picker, PDF import, spending streaks, goal projections,
credit card tracking, and anomaly alerts.
2026-03-03 16:24:55 +02:00
lashman e0a668600e Add AppImage build script 2026-03-02 00:59:27 +02:00
lashman 0c4ae673a8 Add desktop integration files and window state persistence 2026-03-02 00:58:21 +02:00
lashman ed5a5e231f Add settings view with theme, categories, export, and backup 2026-03-02 00:57:05 +02:00
lashman 2fff781a53 Add full state backup and restore with ZIP archives 2026-03-02 00:53:17 +02:00
lashman 341e31ed3b Add PDF monthly report generation 2026-03-02 00:51:31 +02:00
lashman e53301421e Add JSON export for full data dump 2026-03-02 00:43:23 +02:00
lashman 987ab925ef Add CSV export for transactions with date filtering 2026-03-02 00:41:34 +02:00
lashman f12edd8ea1 Add recurring transactions view and launch catch-up 2026-03-02 00:37:30 +02:00
lashman 6adf10c41b Add recurring transaction logic with catch-up generation 2026-03-02 00:34:34 +02:00
lashman d247c56cfa Add desktop notifications for budget threshold crossings
After saving an expense, checks if the category budget reaches 75%,
90%, or 100% thresholds. Sends gio::Notification for each newly
crossed threshold. Tracks sent notifications in database to prevent
duplicates within the same month.
2026-03-02 00:28:43 +02:00
lashman 2741df45ad Add budgets view with progress bars and management
Month selector, summary of total budgeted vs spent, per-category
progress bars with green/yellow/red color coding based on percentage.
Add budget dialog with category picker and amount. Edit and delete
budgets with confirmation.
2026-03-02 00:26:09 +02:00
lashman b772870d98 Add budget CRUD operations and progress calculation
set/get/list/delete budgets per category per month. Budget progress
calculates spent vs budgeted percentage. Copy budgets from one month
to the next. Seven new unit tests covering all operations.
2026-03-02 00:24:08 +02:00
lashman 29d86a5241 Add charts view with pie, bar, and line charts
Pie chart shows expense breakdown by category with color-coded slices.
Bar chart shows income vs expense for the last 6 months. Line chart
shows cumulative daily net trend. All charts render via plotters
BitMapBackend to GDK MemoryTexture for display. Month navigation
shared across all three charts.
2026-03-02 00:22:19 +02:00
lashman 0a198db8c6 Add currency selection with live exchange rate fetching
Currency dropdown now uses 30 currencies from ExchangeRateService.
Defaults to base currency from settings. Selecting a different
currency fetches the exchange rate asynchronously and displays it.
Rate is stored with the transaction on save.
2026-03-02 00:16:47 +02:00
lashman e342272dbe Add exchange rate service with caching and fallback API
ExchangeRateService fetches rates from fawazahmed0/exchange-api with
Frankfurter API as fallback. Rates are cached in SQLite for 24 hours.
Includes response parsers, 30 supported currencies, and 8 unit tests
covering parsing, caching, and same-currency identity.
2026-03-02 00:14:50 +02:00
lashman a3ffc531b9 Add edit and delete for transactions in history view
Click a transaction row to open an edit dialog with pre-filled amount,
date, and note fields. Save updates the database and refreshes the
list. Delete button shows a confirmation dialog before removing the
transaction. Toast notifications for all actions.
2026-03-02 00:13:04 +02:00
lashman 854c3469b9 Add history view with day-grouped transaction list
Month navigation with prev/next buttons, transactions grouped by date
with headers (Today/Yesterday/formatted date), category icons, amounts
color-coded green for income and red for expenses, and daily net totals.
2026-03-02 00:09:21 +02:00
lashman 00de036de8 Wire log view to database for transaction persistence
Initialize SQLite database at XDG data directory on app startup.
Categories now load from database with emoji icons. Save button
validates input, inserts transaction, shows toast notification,
clears the form, and refreshes the recent transactions list.
2026-03-02 00:07:39 +02:00
lashman bc4a3453f8 Add log view UI with transaction entry form
Expense/Income toggle, amount entry, currency dropdown, category
dropdown (filtered by transaction type), date picker with calendar
popover, optional note field, save button, and recent transactions
placeholder section.
2026-03-02 00:05:10 +02:00
lashman 6daec1ea38 Add main window with sidebar navigation
AdwNavigationSplitView with six sidebar items (Log, History, Charts,
Budgets, Recurring, Settings) and placeholder StatusPage content that
switches via crossfade transition on selection.
2026-03-02 00:02:53 +02:00
lashman a5f578844f Add minimal AdwApplication with empty window 2026-03-01 23:59:43 +02:00
lashman 61ced2d482 Add CRUD operations for transactions, categories, and aggregation queries 2026-03-01 23:59:15 +02:00
lashman 9865f8288f Add database layer with schema creation and default categories 2026-03-01 23:57:37 +02:00
lashman 26d0be20c2 Add core data models for transactions, categories, budgets 2026-03-01 23:56:41 +02:00
lashman 2ca1f87bf3 Scaffold cargo workspace with core and gtk crates 2026-03-01 23:54:45 +02:00
lashman 2b984c3f5d Initial commit - add gitignore 2026-03-01 23:31:06 +02:00
28 changed files with 2380 additions and 35 deletions
+65
View File
@@ -0,0 +1,65 @@
# This file
.gitignore
# Build artifacts
/target/
outlay-core/target/
outlay-gtk/target/
# Rust
**/*.rs.bk
*.pdb
# AppImage build artifacts
AppDir/
*.AppImage
linuxdeploy-*
linuxdeploy-plugin-*
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Trash
.trash/
# OS
.DS_Store
Thumbs.db
# Docs/plans (local only)
docs/
# LLM/AI/agent files and directories
.claude/
.cursor/
.copilot/
.github/copilot/
.aider*
.continue/
.codeium/
.tabnine/
.sourcery/
.windsurf/
.cody/
.supermaven/
.codex/
.replit/
.bolt/
.v0/
.devin/
.idx/
.ai/
.llm/
.agent/
.agents/
.prompts/
AGENTS.md
CLAUDE.md
.cursorrules
.cursorignore
copilot-instructions.md
.github/copilot-instructions.md
+35 -35
View File
@@ -32,19 +32,19 @@ Released into the public domain under CC0. Take it, use it, change it, share it.
---
## Table of contents
## Table of Contents
- [Features at a glance](#-features-at-a-glance)
- [Features at a Glance](#-features-at-a-glance)
- [Installation](#-installation)
- [Building from source](#%EF%B8%8F-building-from-source)
- [Views and functionality](#-views-and-functionality)
- [Import and export](#-import-and-export)
- [Multi-currency support](#-multi-currency-support)
- [Building from Source](#%EF%B8%8F-building-from-source)
- [Views and Functionality](#-views-and-functionality)
- [Import and Export](#-import-and-export)
- [Multi-Currency Support](#-multi-currency-support)
- [Notifications](#-notifications)
- [System tray](#-system-tray)
- [Keyboard and workflow](#%EF%B8%8F-keyboard-and-workflow)
- [System Tray](#-system-tray)
- [Keyboard and Workflow](#%EF%B8%8F-keyboard-and-workflow)
- [Architecture](#-architecture)
- [Data storage](#-data-storage)
- [Data Storage](#-data-storage)
- [Packaging](#-packaging)
- [Contributing](#-contributing)
- [License](#-license)
@@ -55,11 +55,11 @@ Released into the public domain under CC0. Take it, use it, change it, share it.
Outlay is a native Linux application for personal income and expense tracking. It is built from scratch in Rust using GTK4 and libadwaita 1.8 for a modern GNOME-native experience. Everything runs locally - the database is a single SQLite file on your disk, exchange rates are cached after fetching, and there is no telemetry or network activity beyond currency conversion lookups.
The goal is simple: give people a tool that is genuinely useful for managing their own money, without any of the usual strings attached. No venture capital, no growth metrics, no engagement loops, no investor exit strategy. Just a straightforward app that does what it says.
The goal is simple: give people a tool that is genuinely useful for managing their own money, without any of the usual strings attached. No venture capital, no growth metrics, no engagement loops. Just a straightforward app that does what it says.
---
## ✨ Features at a glance
## ✨ Features at a Glance
| Area | What it does |
|---|---|
@@ -89,7 +89,7 @@ The goal is simple: give people a tool that is genuinely useful for managing the
## 📦 Installation
### AppImage (recommended)
### AppImage (Recommended)
Download the latest AppImage from the releases page, make it executable, and run:
@@ -100,13 +100,13 @@ chmod +x Outlay-*.AppImage
No installation required. It carries everything it needs. Works on any modern Linux distribution.
### From source
### From Source
See [Building from source](#-building-from-source) below.
See [Building from Source](#-building-from-source) below.
---
## 🛠️ Building from source
## 🛠️ Building from Source
### Dependencies
@@ -136,7 +136,7 @@ On Arch:
sudo pacman -S gtk4 libadwaita sqlite pkg-config
```
### Build and run
### Build and Run
```bash
git clone https://git.lashman.live/lashman/outlay.git
@@ -145,7 +145,7 @@ cargo build --release
./target/release/outlay-gtk
```
### Demo data
### Demo Data
To populate the app with two years of realistic sample data for testing:
@@ -157,9 +157,9 @@ This wipes the existing database and creates a fresh one filled with transaction
---
## 🖥 Views and functionality
## 🖥 Views and Functionality
### 📝 Log view
### 📝 Log View
The default view. A quick-entry form for logging transactions.
@@ -223,7 +223,7 @@ Set spending limits per category and track progress through the month.
Budgets are a tool for self-awareness, not punishment. The pace indicator in particular is useful - it tells you "at your current rate, here is where you will end up" so you can make informed choices about the rest of the month.
### 🔄 Recurring transactions
### 🔄 Recurring Transactions
Automate regular income and expenses.
@@ -254,7 +254,7 @@ Track your subscription services separately from raw recurring transactions.
The subscription view gives you a clear answer to "how much am I paying per month for services?" - a number that tends to creep up quietly if nobody is watching.
### 🎯 Savings goals
### 🎯 Savings Goals
Set targets and track your progress toward them.
@@ -267,7 +267,7 @@ Set targets and track your progress toward them.
Goals work as a simple ledger - you set a target, and manually update your saved amount as you make progress. This keeps it flexible enough for any kind of saving, whether it is a vacation fund, an emergency cushion, or pooling resources with others for a shared purpose.
### 💳 Credit cards
### 💳 Credit Cards
Keep tabs on credit card balances and utilization.
@@ -304,7 +304,7 @@ Project your future cash flow based on existing patterns.
The forecast makes no assumptions about growth or lifestyle inflation. It simply extends your current patterns forward so you can see where things are heading and adjust if needed.
### 🏆 Insights and achievements
### 🏆 Insights and Achievements
Analytics and light gamification to encourage healthy financial habits.
@@ -339,7 +339,7 @@ Analytics and light gamification to encourage healthy financial habits.
The achievements are meant to be encouraging without being manipulative. There are no daily login rewards, no streaks designed to create anxiety, and no leaderboards. Just quiet recognition when you hit a milestone that matters to you.
### 📅 Calendar heatmap
### 📅 Calendar Heatmap
A visual grid showing daily spending intensity.
@@ -364,11 +364,11 @@ Configure Outlay to work the way you need.
---
## 📤 Import and export
## 📤 Import and Export
Outlay believes your data should flow freely. No lock-in, no proprietary formats, no friction for leaving.
### Export formats
### Export Formats
| Format | Description |
|--------|-------------|
@@ -378,7 +378,7 @@ Outlay believes your data should flow freely. No lock-in, no proprietary formats
| **QIF** | Quicken Interchange Format. Legacy compatibility with older financial software. |
| **PDF** | Formatted monthly financial report with summary, category breakdown, top expenses, and budget comparison. |
### Import formats
### Import Formats
| Format | Description |
|--------|-------------|
@@ -388,7 +388,7 @@ Outlay believes your data should flow freely. No lock-in, no proprietary formats
| **QIF** | Import from Quicken or other apps that export QIF. |
| **PDF** | Extract transactions from PDF bank statements using text parsing. Multi-page support. |
### Full backup
### Full Backup
The `.outlay` backup format is a ZIP archive containing:
@@ -400,7 +400,7 @@ Create a backup, copy it to another machine, restore it, and you have an exact r
---
## 🌍 Multi-currency support
## 🌍 Multi-Currency Support
Outlay supports 40+ currencies with automatic exchange rate conversion.
@@ -488,7 +488,7 @@ Notifications are de-duplicated (you will not get the same budget warning twice)
---
## 🗔 System tray
## 🗔 System Tray
Outlay integrates with the freedesktop.org system tray specification via DBus.
@@ -504,7 +504,7 @@ The Quick Add popup is designed for those moments when you just bought something
---
## ⌨️ Keyboard and workflow
## ⌨️ Keyboard and Workflow
The interface follows standard GTK4 and GNOME conventions.
@@ -585,7 +585,7 @@ outlay/
plans/ # Design documentation
```
### Key technical decisions
### Key Technical Decisions
- **Raw GTK4 bindings** instead of a framework like Relm4. More verbose, but no hidden abstractions.
- **SQLite with WAL mode** for reliable concurrent reads during UI updates.
@@ -619,7 +619,7 @@ The core dependencies, all freely licensed:
---
## 💾 Data storage
## 💾 Data Storage
All data lives in a single SQLite file:
@@ -656,7 +656,7 @@ Build it yourself:
The resulting AppImage is a single file that runs on any modern Linux distribution without installation. Download it, make it executable, run it. When a new version comes out, replace the file. No package managers, no repositories, no gatekeepers.
### Environment variables
### Environment Variables
The AppImage sets:
- `GDK_BACKEND=wayland,x11` - Wayland-first with X11 fallback
@@ -705,6 +705,6 @@ See [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) for
Built with care for people who want to understand where their money goes.<br>
No tracking. No ads. No subscriptions. No data collection. No corporate interests.<br>
Just software that belongs to the commons.<br>
<i>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</i>
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
</sub>
</p>
@@ -0,0 +1,234 @@
# Feature Batch 2 Design - 16 Features
Date: 2026-03-03
## Overview
16 features sourced from Reddit community research on personal finance app wishlists, filtered against Outlay's existing feature set.
## Features
### 5. Custom Budget Cycle
Users can choose how budget periods are defined:
- **Calendar month** (default, current behavior): 1st to last day of month
- **Payday offset**: budget runs from day X to day X-1 of next month (e.g. 15th to 14th)
- **Rolling N-day**: budget period is a fixed window of N days from a configurable start date
New settings: `budget_cycle_mode` (calendar/payday/rolling), `budget_cycle_start_day` (1-31), `budget_cycle_days` (e.g. 30).
Affects: Budgets view period labels ("Mar 15 - Apr 14"), navigation step size, all budget queries, safe-to-spend calculation, and budget threshold notifications. Charts and History remain calendar-month based to avoid confusion.
Cycle configuration accessible via a gear icon popover next to the month nav in Budgets view.
### 6. Bulk Transaction Operations
Selection mode in History view:
- "Select" button in header bar activates selection mode
- Checkboxes appear on each transaction row
- Long-press also enters selection mode
- Action bar: "Delete (N)", "Recategorize", "Tag", "Select All", "Cancel"
- Delete shows confirmation dialog with count
- Recategorize opens a category picker, applies to all selected
- Tag opens a multi-select tag picker, applies to all selected
### 7. Monthly/Yearly Recap
Part of the new Insights view (see below). Toggle between "This Month" and "This Year".
Monthly recap: total income, expenses, net, transaction count. Per-category rows showing amount, percentage of total, and change vs previous month with directional arrows.
Yearly recap: month-by-month summary with sparklines, year-over-year comparison.
"Share as PDF" button reuses existing PDF export.
### 8. Natural Language Entry
Dedicated smart entry bar at the top of Log view and in Quick Add popup.
`adw::EntryRow` titled "Quick entry" with placeholder "e.g. Coffee 4.50 at Starbucks".
Parser (`outlay-core/src/nlp.rs`):
- `parse_transaction(input, categories) -> Option<ParsedTransaction>`
- Extracts amount (required), category (fuzzy match), note, payee (after "at"/"from"/"to")
- Patterns: "Coffee 4.50", "Lunch 12.50 at Subway", "4.50 groceries milk", "$25 gas"
Preview row shows parsed result below the entry. Press Enter to save. Existing detailed form remains below for manual entry.
### 9. Sankey Diagram
New section in Charts view after existing charts. Title: "Money Flow".
Cairo-drawn Sankey layout:
- Left nodes: income categories
- Right nodes: expense categories
- Center node: "Available" (net)
- Flow widths proportional to amounts
- Income flows colored green, expense flows use category colors
- Hover shows amount labels
Layout computation in `outlay-core/src/sankey.rs`:
- `compute_sankey_layout(income_sources, expense_categories) -> SankeyLayout`
- Returns node positions and bezier curve control points
Month navigation synced with other charts.
### 10. What-If / Sandbox Mode
Toggle button in Budgets view header bar.
When active:
- Header tinted amber, label shows "Sandbox Mode"
- All budget amounts become inline-editable
- Changes stored in temporary `HashMap<i64, f64>`, NOT written to DB
- Progress bars and safe-to-spend recalculate live
- Modified rows get a visual indicator
- "Apply Changes" button writes sandbox values to DB
- "Discard" button exits sandbox, reverts to real data
### 11. Tags UI
Database tables already exist (`tags`, `transaction_tags`). Need GTK surfaces:
- History view: tag chips in FlowBox alongside category chips (different visual style - outlined vs filled). Clicking a tag chip filters to transactions with that tag.
- Log view: tags already have an entry field. Ensure it works with `get_or_create_tag()` and `set_transaction_tags()`.
- Edit dialog: show and edit tags on existing transactions.
- Bulk operations: "Tag" action in selection mode.
### 12. Transaction Splits UI
Database tables already exist (`transaction_splits`). Need GTK surfaces:
- Log view / Edit dialog: "Split" toggle button next to category row
- When enabled: category row replaced by split list
- Each split row: category dropdown + amount entry + optional note
- "Add Split" button for more rows
- Validation: split total must equal transaction amount ("Remaining: X.XX" label)
- On save: `insert_splits()` called after transaction insert
- Edit dialog: if `has_splits()`, pre-fills split rows
### 13. Transaction Templates UI
Database tables already exist (`transaction_templates`). Need GTK surfaces:
- Log view: "Templates" icon button in header opens a popover
- Lists saved templates as `adw::ActionRow` items (name, amount, category icon, type badge)
- Tapping a template fills the log form
- "Save as Template" button at bottom of form when amount + category are filled
- Opens dialog for template name, saves via `insert_template()`
### 14. Auto-Categorization Rules UI
Database tables already exist (`categorization_rules`). Need GTK surface in Settings:
- New "Categorization Rules" `adw::PreferencesGroup` between Categories and Import/Export
- Each rule: `adw::ActionRow` showing field (Note/Payee), pattern, target category
- "Add Rule" button opens dialog: field dropdown, pattern entry, category dropdown, priority spinner
- Delete button per row
- Rules applied during import and optionally during manual entry
### 15. Searchable Category Picker
Replace all `adw::ComboRow` category dropdowns with a searchable version:
- Custom `gtk::FilterListModel` backed by `gtk::StringFilter`
- Search entry at top of dropdown popup
- Typing filters category list in real-time
- Shared utility: `fn make_searchable_category_combo(...) -> adw::ComboRow`
- Used in: log view, edit dialog, budget dialog, quick add, split rows, rules dialog
### 16. PDF Bank Statement Import
New "PDF" import button in Settings alongside existing importers.
Two-phase extraction (`outlay-core/src/import_pdf.rs`):
1. Text extraction via `pdf-extract` crate - look for tabular date/description/amount patterns
2. If no extractable text found, fall back to existing Tesseract OCR pipeline
Preview dialog shows extracted transactions in an editable list. Each row: date, description, amount, category (auto-matched via categorization rules, fallback "Uncategorized"). Import All / Import Selected buttons. Merge/replace mode toggle.
New dependency: `pdf-extract` crate.
### 17. Spending Streaks / Gamification
Dedicated Insights view (new sidebar item) with three sections:
**Streaks:**
- No-spend streak: consecutive days with zero expenses, flame icon, "Best: X days"
- Under-budget streak: consecutive months staying under total budget cap
- Savings streak: consecutive months with goal contributions
**Achievements:**
- Grid of achievement badges, earned ones colored, unearned dimmed
- Starter set: "First Transaction", "7-Day No-Spend", "30-Day No-Spend", "Month Under Budget", "3 Months Under Budget", "First Goal Completed", "100 Transactions", "Budget Streak 6mo"
- Checked and awarded on app launch
New tables: `streaks`, `achievements`.
### 18. Financial Goal Projections
Enhance existing Goals view:
- Each active goal row gets subtitle: "At current rate, reachable by [date]"
- Based on average monthly contributions over last 3 months
- States: "On track - X months ahead", "Behind schedule - need X.XX/month", "Start contributing to see projection"
- Expandable section per goal: mini line chart showing saved amount over time with projected line to target
### 19. Credit Card Billing Cycle Tracking
Dedicated Credit Cards view (new sidebar item).
**Summary card:** total balance, total limit, utilization bar, next due date.
**Card list:** each card is `adw::ExpanderRow`:
- Collapsed: name, balance, due countdown, utilization mini-bar
- Expanded: statement close date, minimum payment, payment history (last 3 months)
- Actions: "Record Payment" (creates expense transaction, reduces balance), Edit, Delete
New table: `credit_cards` (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color, active).
### 24. Spending Anomaly Alerts in UI
Surface existing `detect_anomalies()` in two places:
- **History view:** `adw::Banner` at top when anomalies exist for the displayed month: "N spending insights for this month". Tapping navigates to Insights view.
- **Insights view:** Anomaly Alerts section renders each anomaly as `adw::ActionRow` with warning/info icons. Clickable to navigate to category in History.
- **Startup:** toast on first launch of the day if current month has insights.
## Schema Migration (v8 -> v9)
New table: `credit_cards` - see feature #19 above.
New table: `streaks` - see feature #17 above.
New table: `achievements` - see feature #17 above.
New settings: `budget_cycle_mode`, `budget_cycle_start_day`, `budget_cycle_days`.
## New Files
- `outlay-core/src/nlp.rs` - natural language transaction parser
- `outlay-core/src/import_pdf.rs` - PDF statement import
- `outlay-core/src/sankey.rs` - Sankey layout computation
- `outlay-gtk/src/insights_view.rs` - Insights view (streaks, achievements, recap, anomalies)
- `outlay-gtk/src/credit_cards_view.rs` - Credit Cards view
## New Dependencies
- `pdf-extract` crate for PDF text extraction
## Modified Files
- `outlay-core/src/db.rs` - migration v9, new CRUD methods for credit cards / streaks / achievements / recap queries / budget period computation
- `outlay-core/src/models.rs` - new structs (CreditCard, Achievement, Streak, ParsedTransaction, SankeyLayout, etc.)
- `outlay-core/src/lib.rs` - register new modules
- `outlay-gtk/src/main.rs` - register new views, startup achievement checks
- `outlay-gtk/src/window.rs` - add Insights and Credit Cards to sidebar navigation
- `outlay-gtk/src/history_view.rs` - bulk operations, tag chips, anomaly banner
- `outlay-gtk/src/log_view.rs` - NL entry bar, templates button, split toggle
- `outlay-gtk/src/edit_dialog.rs` - splits editing, tags editing
- `outlay-gtk/src/budgets_view.rs` - custom cycle, what-if sandbox
- `outlay-gtk/src/charts_view.rs` - Sankey diagram section
- `outlay-gtk/src/goals_view.rs` - projection subtitles and mini charts
- `outlay-gtk/src/settings_view.rs` - categorization rules section, PDF import button
- `outlay-gtk/src/quick_add.rs` - NL entry bar, searchable category
- `outlay-gtk/Cargo.toml` - add pdf-extract dependency
File diff suppressed because it is too large Load Diff
+5
View File
@@ -67,9 +67,11 @@ pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, Ba
rusqlite::params![temp_db_path.to_str().unwrap()],
)?;
// Read the vacuumed database
let db_bytes = std::fs::read(&temp_db_path)?;
std::fs::remove_file(&temp_db_path)?;
// Gather metadata
let txn_count: i64 = db.conn.query_row(
"SELECT COUNT(*) FROM transactions",
[],
@@ -92,6 +94,7 @@ pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, Ba
let meta_json = serde_json::to_string_pretty(&meta)?;
// Create ZIP archive
let file = std::fs::File::create(output_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default()
@@ -138,6 +141,7 @@ pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta,
meta
};
// Extract database
let mut db_file = archive.by_name("outlay.db").map_err(|_| {
BackupError::InvalidBackup("outlay.db not found in backup".to_string())
})?;
@@ -145,6 +149,7 @@ pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta,
let mut db_bytes = Vec::new();
db_file.read_to_end(&mut db_bytes)?;
// Write the restored database
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
+16
View File
@@ -352,6 +352,7 @@ impl Database {
}
if self.get_schema_version()? < 7 {
// Add is_bill and reminder_days to recurring_transactions
let has_is_bill: bool = self.conn
.prepare("SELECT is_bill FROM recurring_transactions LIMIT 0")
.is_ok();
@@ -997,12 +998,14 @@ impl Database {
pub fn get_safe_to_spend(&self, month: &str) -> SqlResult<(f64, f64, i32)> {
use chrono::{Datelike, Local, NaiveDate};
// Total budget for this month
let total_budget: f64 = self.conn.query_row(
"SELECT COALESCE(SUM(amount), 0.0) FROM budgets WHERE month = ?1",
params![month],
|row| row.get(0),
)?;
// Total spent this month
let total_spent: f64 = self.conn.query_row(
"SELECT COALESCE(SUM(amount * exchange_rate), 0.0) FROM transactions
WHERE date LIKE ?1 AND type = 'expense'",
@@ -1010,10 +1013,12 @@ impl Database {
|row| row.get(0),
)?;
// Compute days left in the month
let today = Local::now().date_naive();
let year: i32 = month[..4].parse().unwrap_or(today.year());
let mon: u32 = month[5..7].parse().unwrap_or(today.month());
// Last day of month
let last_day = if mon == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
@@ -1113,6 +1118,7 @@ impl Database {
"UPDATE recurring_transactions SET active = ?1, resume_date = NULL WHERE id = ?2",
params![active, id],
)?;
// Sync linked subscription
self.conn.execute(
"UPDATE subscriptions SET active = ?1 WHERE recurring_id = ?2",
params![active, id],
@@ -1125,6 +1131,7 @@ impl Database {
"UPDATE recurring_transactions SET active = 0, resume_date = ?1 WHERE id = ?2",
params![resume_date.map(|d| d.format("%Y-%m-%d").to_string()), id],
)?;
// Sync linked subscription
self.conn.execute(
"UPDATE subscriptions SET active = 0 WHERE recurring_id = ?1",
params![id],
@@ -1715,6 +1722,7 @@ impl Database {
"UPDATE transactions SET recurring_id = NULL WHERE recurring_id = ?1",
params![id],
)?;
// Delete linked subscriptions
self.conn.execute(
"DELETE FROM subscriptions WHERE recurring_id = ?1",
params![id],
@@ -1733,6 +1741,7 @@ impl Database {
(SELECT id FROM recurring_transactions WHERE subscription_id = ?1)",
params![id],
)?;
// Delete linked recurring transactions
self.conn.execute(
"DELETE FROM recurring_transactions WHERE subscription_id = ?1",
params![id],
@@ -1750,8 +1759,10 @@ impl Database {
sub_cat_id: i64,
name: &str,
) -> SqlResult<i64> {
// Insert the recurring transaction first
let rec_id = self.insert_recurring(rec)?;
// Create a matching subscription linked to it
let sub = NewSubscription {
name: name.to_string(),
amount: rec.amount,
@@ -1765,6 +1776,7 @@ impl Database {
};
let sub_id = self.insert_subscription(&sub)?;
// Link back: recurring -> subscription
self.conn.execute(
"UPDATE recurring_transactions SET subscription_id = ?1 WHERE id = ?2",
params![sub_id, rec_id],
@@ -1778,8 +1790,10 @@ impl Database {
sub: &NewSubscription,
expense_cat_id: i64,
) -> SqlResult<i64> {
// Insert the subscription first
let sub_id = self.insert_subscription(sub)?;
// Create a matching recurring transaction linked to it
let rec = NewRecurringTransaction {
amount: sub.amount,
transaction_type: TransactionType::Expense,
@@ -1795,6 +1809,7 @@ impl Database {
};
let rec_id = self.insert_recurring(&rec)?;
// Link back: subscription -> recurring
self.conn.execute(
"UPDATE subscriptions SET recurring_id = ?1 WHERE id = ?2",
params![rec_id, sub_id],
@@ -3511,6 +3526,7 @@ mod tests {
db.set_budget(cat_id, "2026-03", 500.0, false).unwrap();
// Add some expenses
let txn = NewTransaction {
amount: 200.0,
transaction_type: TransactionType::Expense,
+2
View File
@@ -40,11 +40,13 @@ impl<'a> ExchangeRateService<'a> {
}
}
// Fetch fresh rates
let rates = match self.fetch_from_primary(&base_lower).await {
Ok(r) => r,
Err(_) => self.fetch_from_fallback(&base_lower).await?,
};
// Cache all fetched rates
self.db.cache_rates(&base_lower, &rates)?;
rates
+2
View File
@@ -67,6 +67,7 @@ pub fn export_ofx<W: Write>(
.flatten()
.unwrap_or_else(|| "USD".to_string());
// OFX SGML headers
writeln!(writer, "OFXHEADER:100")?;
writeln!(writer, "DATA:OFXSGML")?;
writeln!(writer, "VERSION:160")?;
@@ -78,6 +79,7 @@ pub fn export_ofx<W: Write>(
writeln!(writer, "NEWFILEUID:NONE")?;
writeln!(writer)?;
// OFX body
writeln!(writer, "<OFX>")?;
writeln!(writer, "<SIGNONMSGSRSV1>")?;
writeln!(writer, "<SONRS>")?;
+12
View File
@@ -77,6 +77,7 @@ pub fn generate_monthly_report(
decorator.set_margins(15);
doc.set_page_decorator(decorator);
// Title
let title = format!(
"Outlay - {} {} Report",
month_name(month),
@@ -85,12 +86,14 @@ pub fn generate_monthly_report(
doc.push(elements::Paragraph::new(&title).styled(style::Style::new().bold().with_font_size(18)));
doc.push(elements::Break::new(1.5));
// Fetch data
let month_str = format!("{:04}-{:02}", year, month);
let transactions = db.list_transactions_by_month(year, month)?;
let mut total_income = 0.0_f64;
let mut total_expenses = 0.0_f64;
// Category breakdown
let mut category_totals: std::collections::HashMap<i64, (String, f64)> =
std::collections::HashMap::new();
@@ -115,6 +118,7 @@ pub fn generate_monthly_report(
let net = total_income - total_expenses;
// Summary section
doc.push(elements::Paragraph::new("Summary").styled(style::Style::new().bold().with_font_size(14)));
doc.push(elements::Break::new(0.5));
@@ -132,6 +136,7 @@ pub fn generate_monthly_report(
)));
doc.push(elements::Break::new(1.0));
// Category breakdown table
if !category_totals.is_empty() {
doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled(
style::Style::new().bold().with_font_size(14),
@@ -141,6 +146,7 @@ pub fn generate_monthly_report(
let mut table = elements::TableLayout::new(vec![3, 2, 1]);
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
// Header
table
.row()
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
@@ -171,6 +177,7 @@ pub fn generate_monthly_report(
doc.push(elements::Break::new(1.0));
}
// Top 5 expenses
let mut expense_txns: Vec<_> = transactions
.iter()
.filter(|t| t.transaction_type == TransactionType::Expense)
@@ -220,6 +227,7 @@ pub fn generate_monthly_report(
doc.push(elements::Break::new(1.0));
}
// Budget vs actual
let budgets = db.list_budgets_for_month(&month_str)?;
if !budgets.is_empty() {
doc.push(elements::Paragraph::new("Budget vs Actual").styled(
@@ -261,6 +269,7 @@ pub fn generate_monthly_report(
doc.push(table);
}
// Footer
doc.push(elements::Break::new(1.5));
doc.push(
elements::Paragraph::new(format!("Generated by Outlay on {}", chrono::Local::now().format("%Y-%m-%d %H:%M")))
@@ -292,6 +301,7 @@ mod tests {
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
// Add sample transactions
let txns = vec![
NewTransaction {
amount: 45.0,
@@ -332,6 +342,7 @@ mod tests {
db.insert_transaction(txn).unwrap();
}
// Set a budget
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
@@ -341,6 +352,7 @@ mod tests {
let metadata = std::fs::metadata(&tmp).unwrap();
assert!(metadata.len() > 100); // PDF should be non-trivial size
// Cleanup
std::fs::remove_file(&tmp).ok();
}
+3
View File
@@ -105,6 +105,7 @@ pub fn parse_transaction(input: &str, categories: &[Category]) -> Option<ParsedT
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
let query_lower = query.to_lowercase();
// Exact match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase() == query_lower)
@@ -112,6 +113,7 @@ fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&
return Some(cat);
}
// Prefix match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase().starts_with(&query_lower))
@@ -119,6 +121,7 @@ fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&
return Some(cat);
}
// Contains match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase().contains(&query_lower))
+5
View File
@@ -17,6 +17,7 @@ pub fn compute_sankey_layout(
let padding = 4.0;
// Layout left (income) nodes
let mut left_nodes = Vec::new();
let mut y = 0.0;
let income_count = income_sources.len().max(1);
@@ -34,6 +35,7 @@ pub fn compute_sankey_layout(
y += h + padding;
}
// Layout right (expense) nodes
let mut right_nodes = Vec::new();
y = 0.0;
let expense_count = expense_categories.len().max(1);
@@ -51,9 +53,11 @@ pub fn compute_sankey_layout(
y += h + padding;
}
// Center node (net/available)
let center_height = (total_income / max_side) * available_left;
let center_y = 0.0;
// Flows from income -> center
let mut flows_in = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
@@ -71,6 +75,7 @@ pub fn compute_sankey_layout(
to_y_cursor += w;
}
// Flows from center -> expenses
let mut flows_out = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
+9
View File
@@ -142,14 +142,17 @@ impl BudgetsView {
nav_box.append(&next_btn);
nav_box.append(&cycle_btn);
// Summary
let summary_label = gtk::Label::new(None);
summary_label.add_css_class("dim-label");
summary_label.set_halign(gtk::Align::Center);
// Budget list
let budget_group = adw::PreferencesGroup::builder()
.title("BUDGETS")
.build();
// Add budget button
let add_btn = gtk::Button::with_label("Add Budget");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");
@@ -450,6 +453,7 @@ impl BudgetsView {
});
}
// Add budget button
{
let db_ref = db.clone();
let year_ref = current_year.clone();
@@ -818,6 +822,7 @@ impl BudgetsView {
year: i32,
month: u32,
) {
// Clear existing rows
let mut rows = Vec::new();
Self::collect_action_rows(group.upcast_ref(), &mut rows);
for row in &rows {
@@ -1129,6 +1134,7 @@ impl BudgetsView {
content.set_margin_start(16);
content.set_margin_end(16);
// Category selector
let cats = db
.list_categories(Some(TransactionType::Expense))
.unwrap_or_default();
@@ -1286,6 +1292,7 @@ impl BudgetsView {
toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar));
// Save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -1321,6 +1328,7 @@ impl BudgetsView {
});
}
// Delete
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -1376,6 +1384,7 @@ impl BudgetsView {
if cap <= 0.0 {
label.set_label("No monthly cap set");
bar.set_value(0.0);
// Remove old warning/error classes
bar.remove_css_class("warning");
bar.remove_css_class("error");
return;
+1
View File
@@ -66,6 +66,7 @@ pub fn calendar_heatmap(db: &Database, year: i32, month: u32) -> gtk::Box {
container.append(&grid);
// Legend
let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4);
legend.set_halign(gtk::Align::Center);
legend.set_margin_top(4);
+32
View File
@@ -1170,18 +1170,22 @@ impl ChartsView {
bx = bx.max(4.0);
by = by.max(4.0);
// Shadow
Self::rounded_rect(cr, bx + 2.0, by + 3.0, box_w, box_h, r);
cr.set_source_rgba(0.0, 0.0, 0.0, 0.18);
cr.fill().ok();
// Background
Self::rounded_rect(cr, bx, by, box_w, box_h, r);
cr.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
cr.fill_preserve().ok();
// Border
cr.set_source_rgba(tc.tooltip_border.0, tc.tooltip_border.1, tc.tooltip_border.2, tc.tooltip_border.3);
cr.set_line_width(1.0);
cr.stroke().ok();
// Text
cr.set_source_rgb(tc.tooltip_text.0, tc.tooltip_text.1, tc.tooltip_text.2);
for (i, line) in lines.iter().enumerate() {
cr.move_to(bx + pad_x, by + pad_y + (i as f64 + 1.0) * line_height - 4.0);
@@ -1280,6 +1284,7 @@ impl ChartsView {
let gap_angle = 1.5_f64.to_radians();
// Draw segments
let mut start = -PI / 2.0;
for (idx, seg) in data.iter().enumerate() {
let frac = seg.value / total;
@@ -1350,6 +1355,7 @@ impl ChartsView {
start2 += sweep2;
}
// Center text
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
cr.set_font_size(22.0);
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
@@ -1358,6 +1364,7 @@ impl ChartsView {
cr.move_to(cx - ext.width() / 2.0, cy + ext.height() / 2.0);
cr.show_text(&total_str).ok();
// Legend
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
cr.set_font_size(13.0);
@@ -1381,6 +1388,7 @@ impl ChartsView {
cr.show_text(&label).ok();
}
// Tooltip on hover
if hover_idx >= 0 && (hover_idx as usize) < data.len() && hover_progress > 0.1 {
let seg = &data[hover_idx as usize];
let pct = seg.value / total * 100.0;
@@ -1442,6 +1450,7 @@ impl ChartsView {
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
// Grid lines
cr.set_font_size(13.0);
cr.set_line_width(0.5);
for i in 0..=4 {
@@ -1511,6 +1520,7 @@ impl ChartsView {
(center_x - bar_w - gap / 2.0, center_x + gap / 2.0)
};
// Income bar
let bh_inc = (bar.income / y_max * chart_h * progress).max(0.0);
let bx_inc = bx_inc_offset;
let by_inc = margin_top + chart_h - bh_inc;
@@ -1525,6 +1535,7 @@ impl ChartsView {
cr.set_source_rgba(0.0, 0.0, 0.0, 0.10);
cr.fill().ok();
// Income bar with gradient
Self::rounded_top_rect(cr, bx_inc, by_inc, bar_w, bh_inc, corner_r);
if bh_inc > 1.0 {
let grad = cairo::LinearGradient::new(0.0, by_inc, 0.0, margin_top + chart_h);
@@ -1537,6 +1548,7 @@ impl ChartsView {
}
cr.fill().ok();
// Expense bar
let bh_exp = (bar.expense / y_max * chart_h * progress).max(0.0);
let bx_exp = bx_exp_offset;
let by_exp = margin_top + chart_h - bh_exp;
@@ -1551,6 +1563,7 @@ impl ChartsView {
cr.set_source_rgba(0.0, 0.0, 0.0, 0.10);
cr.fill().ok();
// Expense bar with gradient
Self::rounded_top_rect(cr, bx_exp, by_exp, bar_w, bh_exp, corner_r);
if bh_exp > 1.0 {
let grad = cairo::LinearGradient::new(0.0, by_exp, 0.0, margin_top + chart_h);
@@ -1563,6 +1576,7 @@ impl ChartsView {
}
cr.fill().ok();
// X-axis label
cr.set_font_size(14.0);
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
let ext = cr.text_extents(&bar.label).unwrap();
@@ -1570,6 +1584,7 @@ impl ChartsView {
cr.show_text(&bar.label).ok();
}
// Legend
let legend_y = h - PAD - 2.0;
cr.set_font_size(13.0);
let mut lx = margin_left;
@@ -1600,6 +1615,7 @@ impl ChartsView {
cr.show_text("Prev Year").ok();
}
// Tooltip
if let Some((idx, is_expense, is_cmp)) = hovered {
let src = if is_cmp { compare.unwrap_or(data) } else { data };
let bar = &src[idx];
@@ -1677,6 +1693,7 @@ impl ChartsView {
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
cr.set_font_size(13.0);
// Grid
cr.set_line_width(0.5);
for i in 0..=4 {
let frac = i as f64 / 4.0;
@@ -1695,6 +1712,7 @@ impl ChartsView {
cr.show_text(&label).ok();
}
// Zero line
if y_min < 0.0 && y_max > 0.0 {
let zy = to_y(0.0);
cr.set_source_rgba(tc.text.0, tc.text.1, tc.text.2, 0.35);
@@ -1706,6 +1724,7 @@ impl ChartsView {
cr.set_dash(&[], 0.0);
}
// X-axis labels
{
cr.set_font_size(13.0);
let step = if x_range > 20.0 { 5.0 } else if x_range > 10.0 { 2.0 } else { 1.0 };
@@ -1746,6 +1765,7 @@ impl ChartsView {
let accent = tc.income;
let zero_y = to_y(0.0).clamp(margin_top, margin_top + chart_h);
// Gradient fill
{
cr.new_path();
let first_x = to_x(data[0].day);
@@ -1768,6 +1788,7 @@ impl ChartsView {
cr.fill().ok();
}
// Line
{
// Glow/shadow under the line
cr.new_path();
@@ -1790,6 +1811,7 @@ impl ChartsView {
cr.stroke().ok();
}
// Data dots
let mut hovered_pt: Option<usize> = None;
let (mx, my) = mouse;
for (i, p) in data.iter().enumerate() {
@@ -1817,6 +1839,7 @@ impl ChartsView {
cr.restore().ok();
// Tooltip (drawn after clip restore)
if let Some(idx) = hovered_pt {
let p = &data[idx];
let l1 = format!("Day {}", p.day as i32);
@@ -1990,6 +2013,7 @@ impl ChartsView {
let n = data.len();
let step = if n > 1 { chart_w / (n - 1) as f64 } else { chart_w };
// Grid lines
cr.set_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
cr.set_line_width(0.5);
for i in 0..5 {
@@ -2007,6 +2031,7 @@ impl ChartsView {
cr.set_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
}
// Zero line
let zero_y = val_to_y(0.0);
if zero_y >= chart_top && zero_y <= chart_bottom {
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
@@ -2016,12 +2041,14 @@ impl ChartsView {
let _ = cr.stroke();
}
// Compute points
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, p)| {
let x = chart_left + i as f64 * step;
let y = val_to_y(p.value * progress);
(x, y)
}).collect();
// Fill area between smooth curve and zero
if points.len() >= 2 {
let zy = val_to_y(0.0).clamp(chart_top, chart_bottom);
let smooth = Self::monotone_subdivide(&points, 16);
@@ -2043,6 +2070,7 @@ impl ChartsView {
let _ = cr.close_path();
let _ = cr.fill();
// Draw smooth line stroke
cr.set_line_width(2.5);
if net_sum >= 0.0 {
cr.set_source_rgb(tc.income.0, tc.income.1, tc.income.2);
@@ -2055,6 +2083,7 @@ impl ChartsView {
}
let _ = cr.stroke();
// Draw points
for (i, &(x, y)) in points.iter().enumerate() {
let val = data[i].value;
if val >= 0.0 {
@@ -2067,6 +2096,7 @@ impl ChartsView {
}
}
// X-axis labels
cr.set_font_size(12.0);
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
for (i, p) in data.iter().enumerate() {
@@ -2075,6 +2105,7 @@ impl ChartsView {
let _ = cr.show_text(&p.label);
}
// Tooltip on hover
if mouse.0 >= chart_left && mouse.0 <= chart_right && mouse.1 >= chart_top && mouse.1 <= chart_bottom {
let idx = ((mouse.0 - chart_left) / step + 0.5) as usize;
if idx < data.len() {
@@ -2090,6 +2121,7 @@ impl ChartsView {
let tx = (px - tw / 2.0).clamp(chart_left, chart_right - tw);
let ty = py - th - 10.0;
// Background
Self::rounded_rect(cr, tx, ty, tw, th, 6.0);
cr.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
let _ = cr.fill_preserve();
+9
View File
@@ -22,6 +22,7 @@ impl CreditCardsView {
inner.set_margin_top(20);
inner.set_margin_bottom(20);
// Summary card
let summary_group = adw::PreferencesGroup::builder()
.title("SUMMARY")
.build();
@@ -60,15 +61,18 @@ impl CreditCardsView {
summary_group.add(&total_limit_row);
summary_group.add(&utilization_row);
// Cards list
let cards_group = adw::PreferencesGroup::builder()
.title("CARDS")
.build();
// Populate
Self::populate_cards(
&db, &cards_group, &toast_overlay,
&balance_label, &limit_label, &util_bar, &util_label,
);
// Add card button
let add_btn = gtk::Button::with_label("Add Card");
add_btn.add_css_class("pill");
add_btn.set_halign(gtk::Align::Center);
@@ -116,6 +120,7 @@ impl CreditCardsView {
util_bar: &gtk::LevelBar,
util_label: &gtk::Label,
) {
// Remove existing rows
while let Some(child) = group.first_child() {
if let Some(inner) = child.first_child() {
if let Some(listbox) = inner.downcast_ref::<gtk::ListBox>() {
@@ -237,6 +242,7 @@ impl CreditCardsView {
btn_row.set_child(Some(&btn_box));
expander.add_row(&btn_row);
// Wire payment
{
let card_id = card.id;
let card_name = card.name.clone();
@@ -255,6 +261,7 @@ impl CreditCardsView {
});
}
// Wire edit
{
let card_id = card.id;
let db_ref = db.clone();
@@ -272,6 +279,7 @@ impl CreditCardsView {
});
}
// Wire delete
{
let card_id = card.id;
let db_ref = db.clone();
@@ -313,6 +321,7 @@ impl CreditCardsView {
group.add(&expander);
}
// Update summary
balance_label.set_label(&format!("{:.2}", total_balance));
limit_label.set_label(&format!("{:.2}", total_limit));
let util = if total_limit > 0.0 { total_balance / total_limit } else { 0.0 };
+10
View File
@@ -47,6 +47,7 @@ pub fn show_edit_dialog(
.subtitle(type_label)
.build();
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.text(&format!("{:.2}", txn.amount))
@@ -54,6 +55,7 @@ pub fn show_edit_dialog(
amount_row.set_input_purpose(gtk::InputPurpose::Number);
crate::numpad::attach_numpad(&amount_row);
// Category selector
let cat_model = gtk::StringList::new(&[]);
let mut cat_ids: Vec<i64> = Vec::new();
let mut cat_selected: u32 = 0;
@@ -80,6 +82,7 @@ pub fn show_edit_dialog(
cat_row.set_list_factory(Some(&make_category_factory()));
let cat_ids = Rc::new(cat_ids);
// Date
let date_fmt = db.get_date_format_string();
let selected_date = Rc::new(Cell::new(txn.date));
let date_label = gtk::Label::new(Some(&txn.date.format(&date_fmt).to_string()));
@@ -131,16 +134,19 @@ pub fn show_edit_dialog(
popover_ref.popdown();
});
// Payee
let payee_row = adw::EntryRow::builder()
.title("Payee (optional)")
.text(txn.payee.as_deref().unwrap_or(""))
.build();
// Note
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.text(txn.note.as_deref().unwrap_or(""))
.build();
// Tags
let existing_tags = db.get_transaction_tags(txn_id)
.unwrap_or_default()
.iter()
@@ -240,6 +246,7 @@ pub fn show_edit_dialog(
form_group.add(&note_row);
form_group.add(&tags_row);
// Buttons
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
btn_box.set_halign(gtk::Align::Center);
btn_box.set_margin_top(8);
@@ -489,6 +496,7 @@ pub fn show_edit_dialog(
let on_changed = Rc::new(on_changed);
// Wire save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -556,6 +564,7 @@ pub fn show_edit_dialog(
match db_ref.update_transaction(&updated) {
Ok(()) => {
// Save tags
let mut tag_ids = Vec::new();
for name in &tag_names {
if let Ok(tid) = db_ref.get_or_create_tag(name) {
@@ -564,6 +573,7 @@ pub fn show_edit_dialog(
}
let _ = db_ref.set_transaction_tags(txn_clone.id, &tag_ids);
// Save splits
let _ = db_ref.delete_splits(txn_clone.id);
let entries = split_entries_ref.borrow();
if !entries.is_empty() {
+2
View File
@@ -24,6 +24,7 @@ impl ForecastView {
.ok().flatten()
.unwrap_or_else(|| "USD".to_string());
// Forecast summary card
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
summary_card.add_css_class("card");
summary_card.set_margin_start(4);
@@ -46,6 +47,7 @@ impl ForecastView {
summary_card.append(&summary_title);
summary_card.append(&summary_detail);
// Monthly forecast table
let forecast_group = adw::PreferencesGroup::builder()
.title("MONTHLY PROJECTION")
.build();
+6
View File
@@ -20,15 +20,18 @@ impl GoalsView {
inner.set_margin_top(20);
inner.set_margin_bottom(20);
// Active goals group
let active_group = adw::PreferencesGroup::builder()
.title("SAVINGS GOALS")
.build();
// Completed goals group
let completed_group = adw::PreferencesGroup::builder()
.title("COMPLETED")
.build();
completed_group.set_visible(false);
// Add goal button
let add_btn = gtk::Button::with_label("Add Goal");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");
@@ -71,6 +74,7 @@ impl GoalsView {
completed_group: &adw::PreferencesGroup,
toast_overlay: &adw::ToastOverlay,
) {
// Clear existing rows
Self::clear_group(active_group);
Self::clear_group(completed_group);
@@ -480,6 +484,7 @@ impl GoalsView {
toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar));
// Save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -524,6 +529,7 @@ impl GoalsView {
});
}
// Delete
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
+2
View File
@@ -808,6 +808,7 @@ impl HistoryView {
action_bar: Option<&gtk::ActionBar>,
action_count_label: Option<&gtk::Label>,
) {
// Clear existing children
while let Some(child) = list_container.first_child() {
list_container.remove(&child);
}
@@ -1199,6 +1200,7 @@ impl HistoryView {
updating: &Rc<Cell<bool>>,
no_results_page: &adw::StatusPage,
) {
// Clear existing chips
while let Some(child) = chips_box.first_child() {
chips_box.remove(&child);
}
+1
View File
@@ -381,6 +381,7 @@ impl InsightsView {
header.add_css_class("property");
list.append(&header);
// Category breakdown
for cat in &recap.categories {
let icon_name = cat.category_icon.as_deref().unwrap_or("folder-symbolic");
let row = adw::ActionRow::builder()
+8
View File
@@ -428,6 +428,7 @@ impl LogView {
form_group.add(&category_row);
// Currency
let currency_labels: Vec<String> = currencies
.iter()
.map(|(code, name)| format!("{} - {}", code, name))
@@ -449,6 +450,7 @@ impl LogView {
rate_label.set_margin_start(16);
rate_label.set_visible(false);
// Date picker
let date_fmt = db.get_date_format_string();
let today_date = chrono::Local::now().date_naive();
let selected_date = Rc::new(Cell::new(today_date));
@@ -535,21 +537,25 @@ impl LogView {
form_group.add(&date_row);
// Payee
let payee_row = adw::EntryRow::builder()
.title("Payee (optional)")
.build();
form_group.add(&payee_row);
// Note
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
form_group.add(&note_row);
// Tags
let tags_row = adw::EntryRow::builder()
.title("Tags (comma-separated)")
.build();
form_group.add(&tags_row);
// Split toggle
let split_switch = adw::SwitchRow::builder()
.title("Split across categories")
.build();
@@ -1196,6 +1202,7 @@ impl LogView {
}
pending_do.borrow_mut().clear();
// Save tags
if !tag_names.is_empty() {
let mut tag_ids = Vec::new();
for name in &tag_names {
@@ -1206,6 +1213,7 @@ impl LogView {
let _ = db_ref.set_transaction_tags(new_id, &tag_ids);
}
// Save splits
if !splits.is_empty() {
let _ = db_ref.insert_splits(new_id, &splits);
}
+10
View File
@@ -30,6 +30,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
content.set_margin_top(12);
content.set_margin_bottom(16);
// Type toggle
let is_income = Rc::new(Cell::new(false));
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
type_box.set_halign(gtk::Align::Center);
@@ -51,6 +52,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
});
}
// Amount entry
let amount_group = adw::PreferencesGroup::new();
let amount_row = adw::EntryRow::builder()
.title("Amount")
@@ -59,6 +61,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
amount_group.add(&amount_row);
content.append(&amount_group);
// Category
let cat_group = adw::PreferencesGroup::new();
let expense_cats = db
.list_categories(Some(TransactionType::Expense))
@@ -120,6 +123,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
});
}
// Note entry
let note_group = adw::PreferencesGroup::new();
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
@@ -127,6 +131,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
note_group.add(&note_row);
content.append(&note_group);
// Save button
let save_btn = gtk::Button::with_label("Save");
save_btn.add_css_class("suggested-action");
save_btn.add_css_class("pill");
@@ -247,6 +252,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
content.set_margin_top(12);
content.set_margin_bottom(16);
// Type toggle
let is_income = Rc::new(Cell::new(false));
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
type_box.set_halign(gtk::Align::Center);
@@ -268,6 +274,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
});
}
// Amount entry
let amount_group = adw::PreferencesGroup::new();
let amount_row = adw::EntryRow::builder()
.title("Amount")
@@ -276,6 +283,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
amount_group.add(&amount_row);
content.append(&amount_group);
// Category
let cat_group = adw::PreferencesGroup::new();
let expense_cats = db
.list_categories(Some(TransactionType::Expense))
@@ -337,6 +345,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
});
}
// Note entry
let note_group = adw::PreferencesGroup::new();
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
@@ -344,6 +353,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
note_group.add(&note_row);
content.append(&note_group);
// Save button
let save_btn = gtk::Button::with_label("Save");
save_btn.add_css_class("suggested-action");
save_btn.add_css_class("pill");
+17
View File
@@ -35,16 +35,19 @@ impl RecurringView {
inner.set_margin_top(20);
inner.set_margin_bottom(20);
// Active section
let active_group = adw::PreferencesGroup::builder()
.title("ACTIVE")
.build();
// Paused section
let paused_group = adw::PreferencesGroup::builder()
.title("PAUSED")
.build();
let on_change: ChangeCb = Rc::new(RefCell::new(None));
// Add button
let add_btn = gtk::Button::with_label("Add Recurring");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");
@@ -53,6 +56,7 @@ impl RecurringView {
Self::load_recurring(&db, &active_group, &paused_group, &toast_overlay, &on_change);
// Wire add button
{
let db_ref = db.clone();
let active_ref = active_group.clone();
@@ -284,6 +288,7 @@ impl RecurringView {
step1.set_margin_start(16);
step1.set_margin_end(16);
// Type toggle
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
type_box.add_css_class("linked");
type_box.set_halign(gtk::Align::Center);
@@ -301,6 +306,7 @@ impl RecurringView {
let form1 = adw::PreferencesGroup::new();
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.build();
@@ -308,6 +314,7 @@ impl RecurringView {
crate::numpad::attach_numpad(&amount_row);
form1.add(&amount_row);
// Category
let category_model = gtk::StringList::new(&[]);
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
let category_row = adw::ComboRow::builder()
@@ -319,6 +326,7 @@ impl RecurringView {
Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense);
form1.add(&category_row);
// Frequency
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
let freq_model = gtk::StringList::new(&freq_labels);
let freq_row = adw::ComboRow::builder()
@@ -377,6 +385,7 @@ impl RecurringView {
let form2 = adw::PreferencesGroup::new();
// Currency
let base_currency = db
.get_setting("base_currency")
.ok()
@@ -410,6 +419,7 @@ impl RecurringView {
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", "");
form2.add(&end_row);
// Note
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
@@ -534,6 +544,7 @@ impl RecurringView {
toolbar.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar));
// Wire save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -693,6 +704,7 @@ impl RecurringView {
let form = adw::PreferencesGroup::new();
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.text(&format!("{:.2}", rec.amount))
@@ -701,6 +713,7 @@ impl RecurringView {
crate::numpad::attach_numpad(&amount_row);
form.add(&amount_row);
// Frequency
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
let freq_model = gtk::StringList::new(&freq_labels);
let freq_idx = match rec.frequency {
@@ -727,6 +740,7 @@ impl RecurringView {
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", &end_str);
form.add(&end_row);
// Note
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.text(rec.note.as_deref().unwrap_or(""))
@@ -758,6 +772,7 @@ impl RecurringView {
});
}
// Buttons
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
btn_box.set_halign(gtk::Align::Center);
@@ -782,6 +797,7 @@ impl RecurringView {
toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar));
// Save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
@@ -1018,6 +1034,7 @@ impl RecurringView {
});
}
// Delete
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
+21
View File
@@ -1244,12 +1244,14 @@ impl SettingsView {
let form = adw::PreferencesGroup::new();
// Name row
let name_row = adw::EntryRow::builder()
.title("Name")
.text(&cat.name)
.build();
form.add(&name_row);
// Icon row
let selected_icon: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(cat.icon.clone()));
let initial_icon = icon_theme::resolve_category_icon(&cat.icon, &cat.color)
@@ -1285,6 +1287,7 @@ impl SettingsView {
}
form.add(&icon_row);
// Color row
let current_color = cat.color.as_deref()
.and_then(|hex| {
let hex = hex.trim_start_matches('#');
@@ -1398,6 +1401,7 @@ impl SettingsView {
let form = adw::PreferencesGroup::new();
// Name row
let name_row = adw::EntryRow::builder()
.title("Name")
.build();
@@ -2055,9 +2059,11 @@ impl SettingsView {
db.execute_raw(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))
.map_err(|e| format!("Backup failed: {}", e))?;
// Update last backup timestamp
let _ = db.set_setting("auto_backup_last", &now.format("%Y-%m-%d %H:%M").to_string());
let _ = db.set_setting("last_backup_date", &now.format("%Y-%m-%d").to_string());
// Prune old backups
let retention = db.get_setting("auto_backup_retention")
.ok().flatten()
.and_then(|v| v.parse::<usize>().ok())
@@ -2773,6 +2779,7 @@ impl SettingsView {
group: &adw::PreferencesGroup,
toast_overlay: &adw::ToastOverlay,
) {
// Remove existing rows
let mut rows_to_remove = Vec::new();
let mut child = group.first_child();
while let Some(widget) = child {
@@ -2853,6 +2860,7 @@ impl SettingsView {
let form = adw::PreferencesGroup::new();
// Type selector
let type_model = gtk::StringList::new(&["Expense", "Income"]);
let type_row = adw::ComboRow::builder()
.title("Type")
@@ -2868,11 +2876,13 @@ impl SettingsView {
.build();
form.add(&field_row);
// Pattern
let pattern_row = adw::EntryRow::builder()
.title("Contains text")
.build();
form.add(&pattern_row);
// Category
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
@@ -2985,6 +2995,7 @@ impl SettingsView {
group: &adw::PreferencesGroup,
toast_overlay: &adw::ToastOverlay,
) {
// Remove existing rows
let mut rows_to_remove = Vec::new();
let mut child = group.first_child();
while let Some(widget) = child {
@@ -3071,11 +3082,13 @@ impl SettingsView {
let form = adw::PreferencesGroup::new();
// Name
let name_row = adw::EntryRow::builder()
.title("Template name")
.build();
form.add(&name_row);
// Type
let type_model = gtk::StringList::new(&["Expense", "Income"]);
let type_row = adw::ComboRow::builder()
.title("Type")
@@ -3083,6 +3096,7 @@ impl SettingsView {
.build();
form.add(&type_row);
// Amount (optional)
let amount_row = adw::EntryRow::builder()
.title("Amount (optional)")
.build();
@@ -3090,6 +3104,7 @@ impl SettingsView {
crate::numpad::attach_numpad(&amount_row);
form.add(&amount_row);
// Category
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
@@ -3122,6 +3137,7 @@ impl SettingsView {
} else {
TransactionType::Income
};
// Clear existing
let n = cat_model.n_items();
if n > 0 {
cat_model.splice(0, n, &[] as &[&str]);
@@ -3141,11 +3157,13 @@ impl SettingsView {
});
}
// Payee (optional)
let payee_row = adw::EntryRow::builder()
.title("Payee (optional)")
.build();
form.add(&payee_row);
// Note (optional)
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
@@ -3322,11 +3340,13 @@ impl SettingsView {
let form = adw::PreferencesGroup::new();
// Name
let name_row = adw::EntryRow::builder()
.title("Name")
.build();
form.add(&name_row);
// Icon
let icon_row = adw::ActionRow::builder()
.title("Icon")
.subtitle("None")
@@ -3349,6 +3369,7 @@ impl SettingsView {
}
form.add(&icon_row);
// Color
let color_btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new()));
color_btn.set_valign(gtk::Align::Center);
let default_color = gdk::RGBA::parse("#95a5a6").unwrap_or(gdk::RGBA::BLACK);
+3
View File
@@ -42,6 +42,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
(0.42, 0.75, 0.45) // Green-ish for expenses going down
};
// Build points
let step = if data.len() > 1 { chart_w / (data.len() - 1) as f64 } else { chart_w };
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, &val)| {
let x = padding + i as f64 * step;
@@ -49,6 +50,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
(x, y)
}).collect();
// Fill area under curve
ctx.move_to(points[0].0, h - padding);
for &(x, y) in &points {
ctx.line_to(x, y);
@@ -58,6 +60,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
ctx.set_source_rgba(r, g, b, 0.15);
let _ = ctx.fill();
// Draw line
ctx.move_to(points[0].0, points[0].1);
for &(x, y) in points.iter().skip(1) {
ctx.line_to(x, y);
+22
View File
@@ -72,10 +72,12 @@ impl SubscriptionsView {
cost_card.append(&monthly_label);
cost_card.append(&yearly_label);
// Active subscriptions group
let active_group = adw::PreferencesGroup::builder()
.title("ACTIVE SUBSCRIPTIONS")
.build();
// Paused subscriptions group
let paused_group = adw::PreferencesGroup::builder()
.title("PAUSED SUBSCRIPTIONS")
.build();
@@ -88,6 +90,7 @@ impl SubscriptionsView {
&toast_overlay, &on_change,
);
// Add button
let add_btn = gtk::Button::with_label("Add Subscription");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");
@@ -262,6 +265,7 @@ impl SubscriptionsView {
equiv_label.add_css_class("dim-label");
suffix_box.append(&equiv_label);
// Pause/resume button
let pause_btn = if sub.active {
let btn = gtk::Button::from_icon_name("tabler-player-pause");
btn.set_tooltip_text(Some("Pause"));
@@ -294,6 +298,7 @@ impl SubscriptionsView {
}
suffix_box.append(&pause_btn);
// Edit button
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
edit_btn.add_css_class("flat");
edit_btn.set_tooltip_text(Some("Edit"));
@@ -317,6 +322,7 @@ impl SubscriptionsView {
}
suffix_box.append(&edit_btn);
// Delete button
let del_btn = gtk::Button::from_icon_name("tabler-trash");
del_btn.add_css_class("flat");
del_btn.set_tooltip_text(Some("Delete"));
@@ -380,11 +386,13 @@ impl SubscriptionsView {
let form1 = adw::PreferencesGroup::new();
// Name
let name_row = adw::EntryRow::builder()
.title("Name")
.build();
form1.add(&name_row);
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.build();
@@ -392,6 +400,7 @@ impl SubscriptionsView {
crate::numpad::attach_numpad(&amount_row);
form1.add(&amount_row);
// Category
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
if let Ok(cats) = db.list_subscription_categories() {
@@ -413,6 +422,7 @@ impl SubscriptionsView {
cat_row.set_list_factory(Some(&Self::make_category_factory()));
form1.add(&cat_row);
// Frequency
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
let freq_model = gtk::StringList::new(&freq_labels);
let freq_row = adw::ComboRow::builder()
@@ -444,6 +454,7 @@ impl SubscriptionsView {
let form2 = adw::PreferencesGroup::new();
// Currency
let base_cur = db.get_setting("base_currency")
.ok().flatten()
.unwrap_or_else(|| "USD".to_string());
@@ -466,15 +477,18 @@ impl SubscriptionsView {
.build();
form2.add(&currency_row);
// Start date
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &today_str);
form2.add(&start_row);
// Note (optional)
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
form2.add(&note_row);
// URL (optional)
let url_row = adw::EntryRow::builder()
.title("URL (optional)")
.build();
@@ -674,12 +688,14 @@ impl SubscriptionsView {
let form = adw::PreferencesGroup::new();
// Name
let name_row = adw::EntryRow::builder()
.title("Name")
.text(&sub.name)
.build();
form.add(&name_row);
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.text(&format!("{:.2}", sub.amount))
@@ -688,6 +704,7 @@ impl SubscriptionsView {
crate::numpad::attach_numpad(&amount_row);
form.add(&amount_row);
// Currency
let mut currencies = ExchangeRateService::supported_currencies();
let base_cur = db.get_setting("base_currency")
.ok().flatten()
@@ -711,6 +728,7 @@ impl SubscriptionsView {
.build();
form.add(&currency_row);
// Frequency
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
let freq_model = gtk::StringList::new(&freq_labels);
let selected_freq = match sub.frequency {
@@ -727,6 +745,7 @@ impl SubscriptionsView {
.build();
form.add(&freq_row);
// Category
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
let mut selected_cat = 0u32;
@@ -753,16 +772,19 @@ impl SubscriptionsView {
cat_row.set_list_factory(Some(&Self::make_category_factory()));
form.add(&cat_row);
// Start date
let start_str = sub.start_date.format("%Y-%m-%d").to_string();
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &start_str);
form.add(&start_row);
// Note (optional)
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.text(sub.note.as_deref().unwrap_or(""))
.build();
form.add(&note_row);
// URL (optional)
let url_row = adw::EntryRow::builder()
.title("URL (optional)")
.text(sub.url.as_deref().unwrap_or(""))
+12
View File
@@ -83,6 +83,7 @@ impl MainWindow {
let content_stack = gtk::Stack::new();
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
// Log view
let log_view = Rc::new(LogView::new(db.clone(), app));
let log_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
@@ -91,6 +92,7 @@ impl MainWindow {
log_view.toast_overlay.set_child(Some(&log_scroll));
content_stack.add_named(&log_view.toast_overlay, Some("log"));
// History view
let history_view = HistoryView::new(db.clone());
let history_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
@@ -98,18 +100,23 @@ impl MainWindow {
.build();
content_stack.add_named(&history_scroll, Some("history"));
// Charts view
let charts_view = ChartsView::new(db.clone());
content_stack.add_named(&charts_view.container, Some("charts"));
// Budgets view
let budgets_view = BudgetsView::new(db.clone());
content_stack.add_named(&budgets_view.container, Some("budgets"));
// Goals view
let goals_view = GoalsView::new(db.clone());
content_stack.add_named(&goals_view.container, Some("goals"));
// Recurring view
let recurring_view = Rc::new(RecurringView::new(db.clone()));
content_stack.add_named(&recurring_view.container, Some("recurring"));
// Subscriptions view
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
@@ -127,18 +134,23 @@ impl MainWindow {
});
}
// Wishlist view
let wishlist_view = WishlistView::new(db.clone());
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
// Forecast view
let forecast_view = ForecastView::new(db.clone());
content_stack.add_named(&forecast_view.container, Some("forecast"));
// Insights view
let insights_view = InsightsView::new(db.clone());
content_stack.add_named(&insights_view.container, Some("insights"));
// Credit Cards view
let credit_cards_view = CreditCardsView::new(db.clone());
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
// Settings view
let settings_view = SettingsView::new(db.clone(), app);
content_stack.add_named(&settings_view.container, Some("settings"));
+4
View File
@@ -22,6 +22,7 @@ impl WishlistView {
inner.set_margin_top(20);
inner.set_margin_bottom(20);
// Total card
let total_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
total_card.add_css_class("card");
total_card.set_margin_start(4);
@@ -48,16 +49,19 @@ impl WishlistView {
total_card.append(&total_title);
total_card.append(&total_label);
// Active items
let active_group = adw::PreferencesGroup::builder()
.title("WANTED")
.build();
// Purchased items
let purchased_group = adw::PreferencesGroup::builder()
.title("PURCHASED")
.build();
Self::load_items(&db, &active_group, &purchased_group, &total_label, &base_currency, &toast_overlay);
// Add button
let add_btn = gtk::Button::with_label("Add Item");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");