Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eec84fa1bc | |||
| 1849537c0c | |||
| bdf200211b | |||
| f46a86134a | |||
| a6626bfbe3 | |||
| c6677979fe | |||
| 3c5f96dc49 | |||
| 10a76e3003 | |||
| 773dae4684 | |||
| 524672a42e | |||
| e0a668600e | |||
| 0c4ae673a8 | |||
| ed5a5e231f | |||
| 2fff781a53 | |||
| 341e31ed3b | |||
| e53301421e | |||
| 987ab925ef | |||
| f12edd8ea1 | |||
| 6adf10c41b | |||
| d247c56cfa | |||
| 2741df45ad | |||
| b772870d98 | |||
| 29d86a5241 | |||
| 0a198db8c6 | |||
| e342272dbe | |||
| a3ffc531b9 | |||
| 854c3469b9 | |||
| 00de036de8 | |||
| bc4a3453f8 | |||
| 6daec1ea38 | |||
| a5f578844f | |||
| 61ced2d482 | |||
| 9865f8288f | |||
| 26d0be20c2 | |||
| 2ca1f87bf3 | |||
| 2b984c3f5d |
+65
@@ -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
|
||||||
@@ -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)
|
- [Installation](#-installation)
|
||||||
- [Building from source](#%EF%B8%8F-building-from-source)
|
- [Building from Source](#%EF%B8%8F-building-from-source)
|
||||||
- [Views and functionality](#-views-and-functionality)
|
- [Views and Functionality](#-views-and-functionality)
|
||||||
- [Import and export](#-import-and-export)
|
- [Import and Export](#-import-and-export)
|
||||||
- [Multi-currency support](#-multi-currency-support)
|
- [Multi-Currency Support](#-multi-currency-support)
|
||||||
- [Notifications](#-notifications)
|
- [Notifications](#-notifications)
|
||||||
- [System tray](#-system-tray)
|
- [System Tray](#-system-tray)
|
||||||
- [Keyboard and workflow](#%EF%B8%8F-keyboard-and-workflow)
|
- [Keyboard and Workflow](#%EF%B8%8F-keyboard-and-workflow)
|
||||||
- [Architecture](#-architecture)
|
- [Architecture](#-architecture)
|
||||||
- [Data storage](#-data-storage)
|
- [Data Storage](#-data-storage)
|
||||||
- [Packaging](#-packaging)
|
- [Packaging](#-packaging)
|
||||||
- [Contributing](#-contributing)
|
- [Contributing](#-contributing)
|
||||||
- [License](#-license)
|
- [License](#-license)
|
||||||
@@ -59,7 +59,7 @@ The goal is simple: give people a tool that is genuinely useful for managing the
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features at a glance
|
## ✨ Features at a Glance
|
||||||
|
|
||||||
| Area | What it does |
|
| Area | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -89,7 +89,7 @@ The goal is simple: give people a tool that is genuinely useful for managing the
|
|||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
### AppImage (recommended)
|
### AppImage (Recommended)
|
||||||
|
|
||||||
Download the latest AppImage from the releases page, make it executable, and run:
|
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.
|
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
|
### Dependencies
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ On Arch:
|
|||||||
sudo pacman -S gtk4 libadwaita sqlite pkg-config
|
sudo pacman -S gtk4 libadwaita sqlite pkg-config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build and run
|
### Build and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.lashman.live/lashman/outlay.git
|
git clone https://git.lashman.live/lashman/outlay.git
|
||||||
@@ -145,7 +145,7 @@ cargo build --release
|
|||||||
./target/release/outlay-gtk
|
./target/release/outlay-gtk
|
||||||
```
|
```
|
||||||
|
|
||||||
### Demo data
|
### Demo Data
|
||||||
|
|
||||||
To populate the app with two years of realistic sample data for testing:
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
Outlay believes your data should flow freely. No lock-in, no proprietary formats, no friction for leaving.
|
||||||
|
|
||||||
### Export formats
|
### Export Formats
|
||||||
|
|
||||||
| Format | Description |
|
| 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. |
|
| **QIF** | Quicken Interchange Format. Legacy compatibility with older financial software. |
|
||||||
| **PDF** | Formatted monthly financial report with summary, category breakdown, top expenses, and budget comparison. |
|
| **PDF** | Formatted monthly financial report with summary, category breakdown, top expenses, and budget comparison. |
|
||||||
|
|
||||||
### Import formats
|
### Import Formats
|
||||||
|
|
||||||
| Format | Description |
|
| 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. |
|
| **QIF** | Import from Quicken or other apps that export QIF. |
|
||||||
| **PDF** | Extract transactions from PDF bank statements using text parsing. Multi-page support. |
|
| **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:
|
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.
|
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.
|
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.
|
The interface follows standard GTK4 and GNOME conventions.
|
||||||
|
|
||||||
@@ -585,7 +585,7 @@ outlay/
|
|||||||
plans/ # Design documentation
|
plans/ # Design documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key technical decisions
|
### Key Technical Decisions
|
||||||
|
|
||||||
- **Raw GTK4 bindings** instead of a framework like Relm4. More verbose, but no hidden abstractions.
|
- **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.
|
- **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:
|
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.
|
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:
|
The AppImage sets:
|
||||||
- `GDK_BACKEND=wayland,x11` - Wayland-first with X11 fallback
|
- `GDK_BACKEND=wayland,x11` - Wayland-first with X11 fallback
|
||||||
@@ -704,6 +704,7 @@ See [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) for
|
|||||||
<sub>
|
<sub>
|
||||||
Built with care for people who want to understand where their money goes.<br>
|
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>
|
No tracking. No ads. No subscriptions. No data collection. No corporate interests.<br>
|
||||||
Just software that belongs to the commons.
|
Just software that belongs to the commons.<br>
|
||||||
|
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>
|
</sub>
|
||||||
</p>
|
</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
@@ -67,9 +67,11 @@ pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, Ba
|
|||||||
rusqlite::params![temp_db_path.to_str().unwrap()],
|
rusqlite::params![temp_db_path.to_str().unwrap()],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Read the vacuumed database
|
||||||
let db_bytes = std::fs::read(&temp_db_path)?;
|
let db_bytes = std::fs::read(&temp_db_path)?;
|
||||||
std::fs::remove_file(&temp_db_path)?;
|
std::fs::remove_file(&temp_db_path)?;
|
||||||
|
|
||||||
|
// Gather metadata
|
||||||
let txn_count: i64 = db.conn.query_row(
|
let txn_count: i64 = db.conn.query_row(
|
||||||
"SELECT COUNT(*) FROM transactions",
|
"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)?;
|
let meta_json = serde_json::to_string_pretty(&meta)?;
|
||||||
|
|
||||||
|
// Create ZIP archive
|
||||||
let file = std::fs::File::create(output_path)?;
|
let file = std::fs::File::create(output_path)?;
|
||||||
let mut zip = zip::ZipWriter::new(file);
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
let options = SimpleFileOptions::default()
|
let options = SimpleFileOptions::default()
|
||||||
@@ -138,6 +141,7 @@ pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta,
|
|||||||
meta
|
meta
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract database
|
||||||
let mut db_file = archive.by_name("outlay.db").map_err(|_| {
|
let mut db_file = archive.by_name("outlay.db").map_err(|_| {
|
||||||
BackupError::InvalidBackup("outlay.db not found in backup".to_string())
|
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();
|
let mut db_bytes = Vec::new();
|
||||||
db_file.read_to_end(&mut db_bytes)?;
|
db_file.read_to_end(&mut db_bytes)?;
|
||||||
|
|
||||||
|
// Write the restored database
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.get_schema_version()? < 7 {
|
if self.get_schema_version()? < 7 {
|
||||||
|
// Add is_bill and reminder_days to recurring_transactions
|
||||||
let has_is_bill: bool = self.conn
|
let has_is_bill: bool = self.conn
|
||||||
.prepare("SELECT is_bill FROM recurring_transactions LIMIT 0")
|
.prepare("SELECT is_bill FROM recurring_transactions LIMIT 0")
|
||||||
.is_ok();
|
.is_ok();
|
||||||
@@ -997,12 +998,14 @@ impl Database {
|
|||||||
pub fn get_safe_to_spend(&self, month: &str) -> SqlResult<(f64, f64, i32)> {
|
pub fn get_safe_to_spend(&self, month: &str) -> SqlResult<(f64, f64, i32)> {
|
||||||
use chrono::{Datelike, Local, NaiveDate};
|
use chrono::{Datelike, Local, NaiveDate};
|
||||||
|
|
||||||
|
// Total budget for this month
|
||||||
let total_budget: f64 = self.conn.query_row(
|
let total_budget: f64 = self.conn.query_row(
|
||||||
"SELECT COALESCE(SUM(amount), 0.0) FROM budgets WHERE month = ?1",
|
"SELECT COALESCE(SUM(amount), 0.0) FROM budgets WHERE month = ?1",
|
||||||
params![month],
|
params![month],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Total spent this month
|
||||||
let total_spent: f64 = self.conn.query_row(
|
let total_spent: f64 = self.conn.query_row(
|
||||||
"SELECT COALESCE(SUM(amount * exchange_rate), 0.0) FROM transactions
|
"SELECT COALESCE(SUM(amount * exchange_rate), 0.0) FROM transactions
|
||||||
WHERE date LIKE ?1 AND type = 'expense'",
|
WHERE date LIKE ?1 AND type = 'expense'",
|
||||||
@@ -1010,10 +1013,12 @@ impl Database {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Compute days left in the month
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
let year: i32 = month[..4].parse().unwrap_or(today.year());
|
let year: i32 = month[..4].parse().unwrap_or(today.year());
|
||||||
let mon: u32 = month[5..7].parse().unwrap_or(today.month());
|
let mon: u32 = month[5..7].parse().unwrap_or(today.month());
|
||||||
|
|
||||||
|
// Last day of month
|
||||||
let last_day = if mon == 12 {
|
let last_day = if mon == 12 {
|
||||||
NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||||
} else {
|
} else {
|
||||||
@@ -1113,6 +1118,7 @@ impl Database {
|
|||||||
"UPDATE recurring_transactions SET active = ?1, resume_date = NULL WHERE id = ?2",
|
"UPDATE recurring_transactions SET active = ?1, resume_date = NULL WHERE id = ?2",
|
||||||
params![active, id],
|
params![active, id],
|
||||||
)?;
|
)?;
|
||||||
|
// Sync linked subscription
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE subscriptions SET active = ?1 WHERE recurring_id = ?2",
|
"UPDATE subscriptions SET active = ?1 WHERE recurring_id = ?2",
|
||||||
params![active, id],
|
params![active, id],
|
||||||
@@ -1125,6 +1131,7 @@ impl Database {
|
|||||||
"UPDATE recurring_transactions SET active = 0, resume_date = ?1 WHERE id = ?2",
|
"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],
|
params![resume_date.map(|d| d.format("%Y-%m-%d").to_string()), id],
|
||||||
)?;
|
)?;
|
||||||
|
// Sync linked subscription
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE subscriptions SET active = 0 WHERE recurring_id = ?1",
|
"UPDATE subscriptions SET active = 0 WHERE recurring_id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
@@ -1715,6 +1722,7 @@ impl Database {
|
|||||||
"UPDATE transactions SET recurring_id = NULL WHERE recurring_id = ?1",
|
"UPDATE transactions SET recurring_id = NULL WHERE recurring_id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
)?;
|
)?;
|
||||||
|
// Delete linked subscriptions
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"DELETE FROM subscriptions WHERE recurring_id = ?1",
|
"DELETE FROM subscriptions WHERE recurring_id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
@@ -1733,6 +1741,7 @@ impl Database {
|
|||||||
(SELECT id FROM recurring_transactions WHERE subscription_id = ?1)",
|
(SELECT id FROM recurring_transactions WHERE subscription_id = ?1)",
|
||||||
params![id],
|
params![id],
|
||||||
)?;
|
)?;
|
||||||
|
// Delete linked recurring transactions
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"DELETE FROM recurring_transactions WHERE subscription_id = ?1",
|
"DELETE FROM recurring_transactions WHERE subscription_id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
@@ -1750,8 +1759,10 @@ impl Database {
|
|||||||
sub_cat_id: i64,
|
sub_cat_id: i64,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> SqlResult<i64> {
|
) -> SqlResult<i64> {
|
||||||
|
// Insert the recurring transaction first
|
||||||
let rec_id = self.insert_recurring(rec)?;
|
let rec_id = self.insert_recurring(rec)?;
|
||||||
|
|
||||||
|
// Create a matching subscription linked to it
|
||||||
let sub = NewSubscription {
|
let sub = NewSubscription {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
amount: rec.amount,
|
amount: rec.amount,
|
||||||
@@ -1765,6 +1776,7 @@ impl Database {
|
|||||||
};
|
};
|
||||||
let sub_id = self.insert_subscription(&sub)?;
|
let sub_id = self.insert_subscription(&sub)?;
|
||||||
|
|
||||||
|
// Link back: recurring -> subscription
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE recurring_transactions SET subscription_id = ?1 WHERE id = ?2",
|
"UPDATE recurring_transactions SET subscription_id = ?1 WHERE id = ?2",
|
||||||
params![sub_id, rec_id],
|
params![sub_id, rec_id],
|
||||||
@@ -1778,8 +1790,10 @@ impl Database {
|
|||||||
sub: &NewSubscription,
|
sub: &NewSubscription,
|
||||||
expense_cat_id: i64,
|
expense_cat_id: i64,
|
||||||
) -> SqlResult<i64> {
|
) -> SqlResult<i64> {
|
||||||
|
// Insert the subscription first
|
||||||
let sub_id = self.insert_subscription(sub)?;
|
let sub_id = self.insert_subscription(sub)?;
|
||||||
|
|
||||||
|
// Create a matching recurring transaction linked to it
|
||||||
let rec = NewRecurringTransaction {
|
let rec = NewRecurringTransaction {
|
||||||
amount: sub.amount,
|
amount: sub.amount,
|
||||||
transaction_type: TransactionType::Expense,
|
transaction_type: TransactionType::Expense,
|
||||||
@@ -1795,6 +1809,7 @@ impl Database {
|
|||||||
};
|
};
|
||||||
let rec_id = self.insert_recurring(&rec)?;
|
let rec_id = self.insert_recurring(&rec)?;
|
||||||
|
|
||||||
|
// Link back: subscription -> recurring
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE subscriptions SET recurring_id = ?1 WHERE id = ?2",
|
"UPDATE subscriptions SET recurring_id = ?1 WHERE id = ?2",
|
||||||
params![rec_id, sub_id],
|
params![rec_id, sub_id],
|
||||||
@@ -3511,6 +3526,7 @@ mod tests {
|
|||||||
|
|
||||||
db.set_budget(cat_id, "2026-03", 500.0, false).unwrap();
|
db.set_budget(cat_id, "2026-03", 500.0, false).unwrap();
|
||||||
|
|
||||||
|
// Add some expenses
|
||||||
let txn = NewTransaction {
|
let txn = NewTransaction {
|
||||||
amount: 200.0,
|
amount: 200.0,
|
||||||
transaction_type: TransactionType::Expense,
|
transaction_type: TransactionType::Expense,
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ impl<'a> ExchangeRateService<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch fresh rates
|
||||||
let rates = match self.fetch_from_primary(&base_lower).await {
|
let rates = match self.fetch_from_primary(&base_lower).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => self.fetch_from_fallback(&base_lower).await?,
|
Err(_) => self.fetch_from_fallback(&base_lower).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache all fetched rates
|
||||||
self.db.cache_rates(&base_lower, &rates)?;
|
self.db.cache_rates(&base_lower, &rates)?;
|
||||||
|
|
||||||
rates
|
rates
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub fn export_ofx<W: Write>(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_else(|| "USD".to_string());
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
|
|
||||||
|
// OFX SGML headers
|
||||||
writeln!(writer, "OFXHEADER:100")?;
|
writeln!(writer, "OFXHEADER:100")?;
|
||||||
writeln!(writer, "DATA:OFXSGML")?;
|
writeln!(writer, "DATA:OFXSGML")?;
|
||||||
writeln!(writer, "VERSION:160")?;
|
writeln!(writer, "VERSION:160")?;
|
||||||
@@ -78,6 +79,7 @@ pub fn export_ofx<W: Write>(
|
|||||||
writeln!(writer, "NEWFILEUID:NONE")?;
|
writeln!(writer, "NEWFILEUID:NONE")?;
|
||||||
writeln!(writer)?;
|
writeln!(writer)?;
|
||||||
|
|
||||||
|
// OFX body
|
||||||
writeln!(writer, "<OFX>")?;
|
writeln!(writer, "<OFX>")?;
|
||||||
writeln!(writer, "<SIGNONMSGSRSV1>")?;
|
writeln!(writer, "<SIGNONMSGSRSV1>")?;
|
||||||
writeln!(writer, "<SONRS>")?;
|
writeln!(writer, "<SONRS>")?;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub fn generate_monthly_report(
|
|||||||
decorator.set_margins(15);
|
decorator.set_margins(15);
|
||||||
doc.set_page_decorator(decorator);
|
doc.set_page_decorator(decorator);
|
||||||
|
|
||||||
|
// Title
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"Outlay - {} {} Report",
|
"Outlay - {} {} Report",
|
||||||
month_name(month),
|
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::Paragraph::new(&title).styled(style::Style::new().bold().with_font_size(18)));
|
||||||
doc.push(elements::Break::new(1.5));
|
doc.push(elements::Break::new(1.5));
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
let month_str = format!("{:04}-{:02}", year, month);
|
let month_str = format!("{:04}-{:02}", year, month);
|
||||||
let transactions = db.list_transactions_by_month(year, month)?;
|
let transactions = db.list_transactions_by_month(year, month)?;
|
||||||
|
|
||||||
let mut total_income = 0.0_f64;
|
let mut total_income = 0.0_f64;
|
||||||
let mut total_expenses = 0.0_f64;
|
let mut total_expenses = 0.0_f64;
|
||||||
|
|
||||||
|
// Category breakdown
|
||||||
let mut category_totals: std::collections::HashMap<i64, (String, f64)> =
|
let mut category_totals: std::collections::HashMap<i64, (String, f64)> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
@@ -115,6 +118,7 @@ pub fn generate_monthly_report(
|
|||||||
|
|
||||||
let net = total_income - total_expenses;
|
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::Paragraph::new("Summary").styled(style::Style::new().bold().with_font_size(14)));
|
||||||
doc.push(elements::Break::new(0.5));
|
doc.push(elements::Break::new(0.5));
|
||||||
|
|
||||||
@@ -132,6 +136,7 @@ pub fn generate_monthly_report(
|
|||||||
)));
|
)));
|
||||||
doc.push(elements::Break::new(1.0));
|
doc.push(elements::Break::new(1.0));
|
||||||
|
|
||||||
|
// Category breakdown table
|
||||||
if !category_totals.is_empty() {
|
if !category_totals.is_empty() {
|
||||||
doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled(
|
doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled(
|
||||||
style::Style::new().bold().with_font_size(14),
|
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]);
|
let mut table = elements::TableLayout::new(vec![3, 2, 1]);
|
||||||
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
|
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
|
||||||
|
|
||||||
|
// Header
|
||||||
table
|
table
|
||||||
.row()
|
.row()
|
||||||
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
|
.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));
|
doc.push(elements::Break::new(1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top 5 expenses
|
||||||
let mut expense_txns: Vec<_> = transactions
|
let mut expense_txns: Vec<_> = transactions
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| t.transaction_type == TransactionType::Expense)
|
.filter(|t| t.transaction_type == TransactionType::Expense)
|
||||||
@@ -220,6 +227,7 @@ pub fn generate_monthly_report(
|
|||||||
doc.push(elements::Break::new(1.0));
|
doc.push(elements::Break::new(1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Budget vs actual
|
||||||
let budgets = db.list_budgets_for_month(&month_str)?;
|
let budgets = db.list_budgets_for_month(&month_str)?;
|
||||||
if !budgets.is_empty() {
|
if !budgets.is_empty() {
|
||||||
doc.push(elements::Paragraph::new("Budget vs Actual").styled(
|
doc.push(elements::Paragraph::new("Budget vs Actual").styled(
|
||||||
@@ -261,6 +269,7 @@ pub fn generate_monthly_report(
|
|||||||
doc.push(table);
|
doc.push(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
doc.push(elements::Break::new(1.5));
|
doc.push(elements::Break::new(1.5));
|
||||||
doc.push(
|
doc.push(
|
||||||
elements::Paragraph::new(format!("Generated by Outlay on {}", chrono::Local::now().format("%Y-%m-%d %H:%M")))
|
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 cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
|
||||||
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
|
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
|
||||||
|
|
||||||
|
// Add sample transactions
|
||||||
let txns = vec![
|
let txns = vec![
|
||||||
NewTransaction {
|
NewTransaction {
|
||||||
amount: 45.0,
|
amount: 45.0,
|
||||||
@@ -332,6 +342,7 @@ mod tests {
|
|||||||
db.insert_transaction(txn).unwrap();
|
db.insert_transaction(txn).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set a budget
|
||||||
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
|
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
|
||||||
|
|
||||||
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
|
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
|
||||||
@@ -341,6 +352,7 @@ mod tests {
|
|||||||
let metadata = std::fs::metadata(&tmp).unwrap();
|
let metadata = std::fs::metadata(&tmp).unwrap();
|
||||||
assert!(metadata.len() > 100); // PDF should be non-trivial size
|
assert!(metadata.len() > 100); // PDF should be non-trivial size
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
std::fs::remove_file(&tmp).ok();
|
std::fs::remove_file(&tmp).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
|
||||||
let query_lower = query.to_lowercase();
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
if let Some(cat) = categories
|
if let Some(cat) = categories
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.to_lowercase() == query_lower)
|
.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);
|
return Some(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix match
|
||||||
if let Some(cat) = categories
|
if let Some(cat) = categories
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.to_lowercase().starts_with(&query_lower))
|
.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);
|
return Some(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contains match
|
||||||
if let Some(cat) = categories
|
if let Some(cat) = categories
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.to_lowercase().contains(&query_lower))
|
.find(|c| c.name.to_lowercase().contains(&query_lower))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub fn compute_sankey_layout(
|
|||||||
|
|
||||||
let padding = 4.0;
|
let padding = 4.0;
|
||||||
|
|
||||||
|
// Layout left (income) nodes
|
||||||
let mut left_nodes = Vec::new();
|
let mut left_nodes = Vec::new();
|
||||||
let mut y = 0.0;
|
let mut y = 0.0;
|
||||||
let income_count = income_sources.len().max(1);
|
let income_count = income_sources.len().max(1);
|
||||||
@@ -34,6 +35,7 @@ pub fn compute_sankey_layout(
|
|||||||
y += h + padding;
|
y += h + padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Layout right (expense) nodes
|
||||||
let mut right_nodes = Vec::new();
|
let mut right_nodes = Vec::new();
|
||||||
y = 0.0;
|
y = 0.0;
|
||||||
let expense_count = expense_categories.len().max(1);
|
let expense_count = expense_categories.len().max(1);
|
||||||
@@ -51,9 +53,11 @@ pub fn compute_sankey_layout(
|
|||||||
y += h + padding;
|
y += h + padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Center node (net/available)
|
||||||
let center_height = (total_income / max_side) * available_left;
|
let center_height = (total_income / max_side) * available_left;
|
||||||
let center_y = 0.0;
|
let center_y = 0.0;
|
||||||
|
|
||||||
|
// Flows from income -> center
|
||||||
let mut flows_in = Vec::new();
|
let mut flows_in = Vec::new();
|
||||||
let mut from_y_cursor = 0.0;
|
let mut from_y_cursor = 0.0;
|
||||||
let mut to_y_cursor = 0.0;
|
let mut to_y_cursor = 0.0;
|
||||||
@@ -71,6 +75,7 @@ pub fn compute_sankey_layout(
|
|||||||
to_y_cursor += w;
|
to_y_cursor += w;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flows from center -> expenses
|
||||||
let mut flows_out = Vec::new();
|
let mut flows_out = Vec::new();
|
||||||
let mut from_y_cursor = 0.0;
|
let mut from_y_cursor = 0.0;
|
||||||
let mut to_y_cursor = 0.0;
|
let mut to_y_cursor = 0.0;
|
||||||
|
|||||||
@@ -142,14 +142,17 @@ impl BudgetsView {
|
|||||||
nav_box.append(&next_btn);
|
nav_box.append(&next_btn);
|
||||||
nav_box.append(&cycle_btn);
|
nav_box.append(&cycle_btn);
|
||||||
|
|
||||||
|
// Summary
|
||||||
let summary_label = gtk::Label::new(None);
|
let summary_label = gtk::Label::new(None);
|
||||||
summary_label.add_css_class("dim-label");
|
summary_label.add_css_class("dim-label");
|
||||||
summary_label.set_halign(gtk::Align::Center);
|
summary_label.set_halign(gtk::Align::Center);
|
||||||
|
|
||||||
|
// Budget list
|
||||||
let budget_group = adw::PreferencesGroup::builder()
|
let budget_group = adw::PreferencesGroup::builder()
|
||||||
.title("BUDGETS")
|
.title("BUDGETS")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Add budget button
|
||||||
let add_btn = gtk::Button::with_label("Add Budget");
|
let add_btn = gtk::Button::with_label("Add Budget");
|
||||||
add_btn.add_css_class("suggested-action");
|
add_btn.add_css_class("suggested-action");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
@@ -450,6 +453,7 @@ impl BudgetsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add budget button
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let year_ref = current_year.clone();
|
let year_ref = current_year.clone();
|
||||||
@@ -818,6 +822,7 @@ impl BudgetsView {
|
|||||||
year: i32,
|
year: i32,
|
||||||
month: u32,
|
month: u32,
|
||||||
) {
|
) {
|
||||||
|
// Clear existing rows
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
Self::collect_action_rows(group.upcast_ref(), &mut rows);
|
Self::collect_action_rows(group.upcast_ref(), &mut rows);
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
@@ -1129,6 +1134,7 @@ impl BudgetsView {
|
|||||||
content.set_margin_start(16);
|
content.set_margin_start(16);
|
||||||
content.set_margin_end(16);
|
content.set_margin_end(16);
|
||||||
|
|
||||||
|
// Category selector
|
||||||
let cats = db
|
let cats = db
|
||||||
.list_categories(Some(TransactionType::Expense))
|
.list_categories(Some(TransactionType::Expense))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -1286,6 +1292,7 @@ impl BudgetsView {
|
|||||||
toolbar.set_content(Some(&content));
|
toolbar.set_content(Some(&content));
|
||||||
dialog.set_child(Some(&toolbar));
|
dialog.set_child(Some(&toolbar));
|
||||||
|
|
||||||
|
// Save
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -1321,6 +1328,7 @@ impl BudgetsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -1376,6 +1384,7 @@ impl BudgetsView {
|
|||||||
if cap <= 0.0 {
|
if cap <= 0.0 {
|
||||||
label.set_label("No monthly cap set");
|
label.set_label("No monthly cap set");
|
||||||
bar.set_value(0.0);
|
bar.set_value(0.0);
|
||||||
|
// Remove old warning/error classes
|
||||||
bar.remove_css_class("warning");
|
bar.remove_css_class("warning");
|
||||||
bar.remove_css_class("error");
|
bar.remove_css_class("error");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub fn calendar_heatmap(db: &Database, year: i32, month: u32) -> gtk::Box {
|
|||||||
|
|
||||||
container.append(&grid);
|
container.append(&grid);
|
||||||
|
|
||||||
|
// Legend
|
||||||
let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||||
legend.set_halign(gtk::Align::Center);
|
legend.set_halign(gtk::Align::Center);
|
||||||
legend.set_margin_top(4);
|
legend.set_margin_top(4);
|
||||||
|
|||||||
@@ -1170,18 +1170,22 @@ impl ChartsView {
|
|||||||
bx = bx.max(4.0);
|
bx = bx.max(4.0);
|
||||||
by = by.max(4.0);
|
by = by.max(4.0);
|
||||||
|
|
||||||
|
// Shadow
|
||||||
Self::rounded_rect(cr, bx + 2.0, by + 3.0, box_w, box_h, r);
|
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.set_source_rgba(0.0, 0.0, 0.0, 0.18);
|
||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
|
|
||||||
|
// Background
|
||||||
Self::rounded_rect(cr, bx, by, box_w, box_h, r);
|
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.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
|
||||||
cr.fill_preserve().ok();
|
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_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.set_line_width(1.0);
|
||||||
cr.stroke().ok();
|
cr.stroke().ok();
|
||||||
|
|
||||||
|
// Text
|
||||||
cr.set_source_rgb(tc.tooltip_text.0, tc.tooltip_text.1, tc.tooltip_text.2);
|
cr.set_source_rgb(tc.tooltip_text.0, tc.tooltip_text.1, tc.tooltip_text.2);
|
||||||
for (i, line) in lines.iter().enumerate() {
|
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);
|
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();
|
let gap_angle = 1.5_f64.to_radians();
|
||||||
|
|
||||||
|
// Draw segments
|
||||||
let mut start = -PI / 2.0;
|
let mut start = -PI / 2.0;
|
||||||
for (idx, seg) in data.iter().enumerate() {
|
for (idx, seg) in data.iter().enumerate() {
|
||||||
let frac = seg.value / total;
|
let frac = seg.value / total;
|
||||||
@@ -1350,6 +1355,7 @@ impl ChartsView {
|
|||||||
start2 += sweep2;
|
start2 += sweep2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Center text
|
||||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
|
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
|
||||||
cr.set_font_size(22.0);
|
cr.set_font_size(22.0);
|
||||||
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
|
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.move_to(cx - ext.width() / 2.0, cy + ext.height() / 2.0);
|
||||||
cr.show_text(&total_str).ok();
|
cr.show_text(&total_str).ok();
|
||||||
|
|
||||||
|
// Legend
|
||||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||||
cr.set_font_size(13.0);
|
cr.set_font_size(13.0);
|
||||||
|
|
||||||
@@ -1381,6 +1388,7 @@ impl ChartsView {
|
|||||||
cr.show_text(&label).ok();
|
cr.show_text(&label).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip on hover
|
||||||
if hover_idx >= 0 && (hover_idx as usize) < data.len() && hover_progress > 0.1 {
|
if hover_idx >= 0 && (hover_idx as usize) < data.len() && hover_progress > 0.1 {
|
||||||
let seg = &data[hover_idx as usize];
|
let seg = &data[hover_idx as usize];
|
||||||
let pct = seg.value / total * 100.0;
|
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);
|
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
cr.set_font_size(13.0);
|
cr.set_font_size(13.0);
|
||||||
cr.set_line_width(0.5);
|
cr.set_line_width(0.5);
|
||||||
for i in 0..=4 {
|
for i in 0..=4 {
|
||||||
@@ -1511,6 +1520,7 @@ impl ChartsView {
|
|||||||
(center_x - bar_w - gap / 2.0, center_x + gap / 2.0)
|
(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 bh_inc = (bar.income / y_max * chart_h * progress).max(0.0);
|
||||||
let bx_inc = bx_inc_offset;
|
let bx_inc = bx_inc_offset;
|
||||||
let by_inc = margin_top + chart_h - bh_inc;
|
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.set_source_rgba(0.0, 0.0, 0.0, 0.10);
|
||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
|
|
||||||
|
// Income bar with gradient
|
||||||
Self::rounded_top_rect(cr, bx_inc, by_inc, bar_w, bh_inc, corner_r);
|
Self::rounded_top_rect(cr, bx_inc, by_inc, bar_w, bh_inc, corner_r);
|
||||||
if bh_inc > 1.0 {
|
if bh_inc > 1.0 {
|
||||||
let grad = cairo::LinearGradient::new(0.0, by_inc, 0.0, margin_top + chart_h);
|
let grad = cairo::LinearGradient::new(0.0, by_inc, 0.0, margin_top + chart_h);
|
||||||
@@ -1537,6 +1548,7 @@ impl ChartsView {
|
|||||||
}
|
}
|
||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
|
|
||||||
|
// Expense bar
|
||||||
let bh_exp = (bar.expense / y_max * chart_h * progress).max(0.0);
|
let bh_exp = (bar.expense / y_max * chart_h * progress).max(0.0);
|
||||||
let bx_exp = bx_exp_offset;
|
let bx_exp = bx_exp_offset;
|
||||||
let by_exp = margin_top + chart_h - bh_exp;
|
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.set_source_rgba(0.0, 0.0, 0.0, 0.10);
|
||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
|
|
||||||
|
// Expense bar with gradient
|
||||||
Self::rounded_top_rect(cr, bx_exp, by_exp, bar_w, bh_exp, corner_r);
|
Self::rounded_top_rect(cr, bx_exp, by_exp, bar_w, bh_exp, corner_r);
|
||||||
if bh_exp > 1.0 {
|
if bh_exp > 1.0 {
|
||||||
let grad = cairo::LinearGradient::new(0.0, by_exp, 0.0, margin_top + chart_h);
|
let grad = cairo::LinearGradient::new(0.0, by_exp, 0.0, margin_top + chart_h);
|
||||||
@@ -1563,6 +1576,7 @@ impl ChartsView {
|
|||||||
}
|
}
|
||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
|
|
||||||
|
// X-axis label
|
||||||
cr.set_font_size(14.0);
|
cr.set_font_size(14.0);
|
||||||
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
|
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
|
||||||
let ext = cr.text_extents(&bar.label).unwrap();
|
let ext = cr.text_extents(&bar.label).unwrap();
|
||||||
@@ -1570,6 +1584,7 @@ impl ChartsView {
|
|||||||
cr.show_text(&bar.label).ok();
|
cr.show_text(&bar.label).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
let legend_y = h - PAD - 2.0;
|
let legend_y = h - PAD - 2.0;
|
||||||
cr.set_font_size(13.0);
|
cr.set_font_size(13.0);
|
||||||
let mut lx = margin_left;
|
let mut lx = margin_left;
|
||||||
@@ -1600,6 +1615,7 @@ impl ChartsView {
|
|||||||
cr.show_text("Prev Year").ok();
|
cr.show_text("Prev Year").ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
if let Some((idx, is_expense, is_cmp)) = hovered {
|
if let Some((idx, is_expense, is_cmp)) = hovered {
|
||||||
let src = if is_cmp { compare.unwrap_or(data) } else { data };
|
let src = if is_cmp { compare.unwrap_or(data) } else { data };
|
||||||
let bar = &src[idx];
|
let bar = &src[idx];
|
||||||
@@ -1677,6 +1693,7 @@ impl ChartsView {
|
|||||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||||
cr.set_font_size(13.0);
|
cr.set_font_size(13.0);
|
||||||
|
|
||||||
|
// Grid
|
||||||
cr.set_line_width(0.5);
|
cr.set_line_width(0.5);
|
||||||
for i in 0..=4 {
|
for i in 0..=4 {
|
||||||
let frac = i as f64 / 4.0;
|
let frac = i as f64 / 4.0;
|
||||||
@@ -1695,6 +1712,7 @@ impl ChartsView {
|
|||||||
cr.show_text(&label).ok();
|
cr.show_text(&label).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero line
|
||||||
if y_min < 0.0 && y_max > 0.0 {
|
if y_min < 0.0 && y_max > 0.0 {
|
||||||
let zy = to_y(0.0);
|
let zy = to_y(0.0);
|
||||||
cr.set_source_rgba(tc.text.0, tc.text.1, tc.text.2, 0.35);
|
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);
|
cr.set_dash(&[], 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-axis labels
|
||||||
{
|
{
|
||||||
cr.set_font_size(13.0);
|
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 };
|
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 accent = tc.income;
|
||||||
let zero_y = to_y(0.0).clamp(margin_top, margin_top + chart_h);
|
let zero_y = to_y(0.0).clamp(margin_top, margin_top + chart_h);
|
||||||
|
|
||||||
|
// Gradient fill
|
||||||
{
|
{
|
||||||
cr.new_path();
|
cr.new_path();
|
||||||
let first_x = to_x(data[0].day);
|
let first_x = to_x(data[0].day);
|
||||||
@@ -1768,6 +1788,7 @@ impl ChartsView {
|
|||||||
cr.fill().ok();
|
cr.fill().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Line
|
||||||
{
|
{
|
||||||
// Glow/shadow under the line
|
// Glow/shadow under the line
|
||||||
cr.new_path();
|
cr.new_path();
|
||||||
@@ -1790,6 +1811,7 @@ impl ChartsView {
|
|||||||
cr.stroke().ok();
|
cr.stroke().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Data dots
|
||||||
let mut hovered_pt: Option<usize> = None;
|
let mut hovered_pt: Option<usize> = None;
|
||||||
let (mx, my) = mouse;
|
let (mx, my) = mouse;
|
||||||
for (i, p) in data.iter().enumerate() {
|
for (i, p) in data.iter().enumerate() {
|
||||||
@@ -1817,6 +1839,7 @@ impl ChartsView {
|
|||||||
|
|
||||||
cr.restore().ok();
|
cr.restore().ok();
|
||||||
|
|
||||||
|
// Tooltip (drawn after clip restore)
|
||||||
if let Some(idx) = hovered_pt {
|
if let Some(idx) = hovered_pt {
|
||||||
let p = &data[idx];
|
let p = &data[idx];
|
||||||
let l1 = format!("Day {}", p.day as i32);
|
let l1 = format!("Day {}", p.day as i32);
|
||||||
@@ -1990,6 +2013,7 @@ impl ChartsView {
|
|||||||
let n = data.len();
|
let n = data.len();
|
||||||
let step = if n > 1 { chart_w / (n - 1) as f64 } else { chart_w };
|
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_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
|
||||||
cr.set_line_width(0.5);
|
cr.set_line_width(0.5);
|
||||||
for i in 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);
|
cr.set_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero line
|
||||||
let zero_y = val_to_y(0.0);
|
let zero_y = val_to_y(0.0);
|
||||||
if zero_y >= chart_top && zero_y <= chart_bottom {
|
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);
|
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();
|
let _ = cr.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute points
|
||||||
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, p)| {
|
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, p)| {
|
||||||
let x = chart_left + i as f64 * step;
|
let x = chart_left + i as f64 * step;
|
||||||
let y = val_to_y(p.value * progress);
|
let y = val_to_y(p.value * progress);
|
||||||
(x, y)
|
(x, y)
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
|
// Fill area between smooth curve and zero
|
||||||
if points.len() >= 2 {
|
if points.len() >= 2 {
|
||||||
let zy = val_to_y(0.0).clamp(chart_top, chart_bottom);
|
let zy = val_to_y(0.0).clamp(chart_top, chart_bottom);
|
||||||
let smooth = Self::monotone_subdivide(&points, 16);
|
let smooth = Self::monotone_subdivide(&points, 16);
|
||||||
@@ -2043,6 +2070,7 @@ impl ChartsView {
|
|||||||
let _ = cr.close_path();
|
let _ = cr.close_path();
|
||||||
let _ = cr.fill();
|
let _ = cr.fill();
|
||||||
|
|
||||||
|
// Draw smooth line stroke
|
||||||
cr.set_line_width(2.5);
|
cr.set_line_width(2.5);
|
||||||
if net_sum >= 0.0 {
|
if net_sum >= 0.0 {
|
||||||
cr.set_source_rgb(tc.income.0, tc.income.1, tc.income.2);
|
cr.set_source_rgb(tc.income.0, tc.income.1, tc.income.2);
|
||||||
@@ -2055,6 +2083,7 @@ impl ChartsView {
|
|||||||
}
|
}
|
||||||
let _ = cr.stroke();
|
let _ = cr.stroke();
|
||||||
|
|
||||||
|
// Draw points
|
||||||
for (i, &(x, y)) in points.iter().enumerate() {
|
for (i, &(x, y)) in points.iter().enumerate() {
|
||||||
let val = data[i].value;
|
let val = data[i].value;
|
||||||
if val >= 0.0 {
|
if val >= 0.0 {
|
||||||
@@ -2067,6 +2096,7 @@ impl ChartsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-axis labels
|
||||||
cr.set_font_size(12.0);
|
cr.set_font_size(12.0);
|
||||||
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
|
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
|
||||||
for (i, p) in data.iter().enumerate() {
|
for (i, p) in data.iter().enumerate() {
|
||||||
@@ -2075,6 +2105,7 @@ impl ChartsView {
|
|||||||
let _ = cr.show_text(&p.label);
|
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 {
|
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;
|
let idx = ((mouse.0 - chart_left) / step + 0.5) as usize;
|
||||||
if idx < data.len() {
|
if idx < data.len() {
|
||||||
@@ -2090,6 +2121,7 @@ impl ChartsView {
|
|||||||
let tx = (px - tw / 2.0).clamp(chart_left, chart_right - tw);
|
let tx = (px - tw / 2.0).clamp(chart_left, chart_right - tw);
|
||||||
let ty = py - th - 10.0;
|
let ty = py - th - 10.0;
|
||||||
|
|
||||||
|
// Background
|
||||||
Self::rounded_rect(cr, tx, ty, tw, th, 6.0);
|
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);
|
cr.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
|
||||||
let _ = cr.fill_preserve();
|
let _ = cr.fill_preserve();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ impl CreditCardsView {
|
|||||||
inner.set_margin_top(20);
|
inner.set_margin_top(20);
|
||||||
inner.set_margin_bottom(20);
|
inner.set_margin_bottom(20);
|
||||||
|
|
||||||
|
// Summary card
|
||||||
let summary_group = adw::PreferencesGroup::builder()
|
let summary_group = adw::PreferencesGroup::builder()
|
||||||
.title("SUMMARY")
|
.title("SUMMARY")
|
||||||
.build();
|
.build();
|
||||||
@@ -60,15 +61,18 @@ impl CreditCardsView {
|
|||||||
summary_group.add(&total_limit_row);
|
summary_group.add(&total_limit_row);
|
||||||
summary_group.add(&utilization_row);
|
summary_group.add(&utilization_row);
|
||||||
|
|
||||||
|
// Cards list
|
||||||
let cards_group = adw::PreferencesGroup::builder()
|
let cards_group = adw::PreferencesGroup::builder()
|
||||||
.title("CARDS")
|
.title("CARDS")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Populate
|
||||||
Self::populate_cards(
|
Self::populate_cards(
|
||||||
&db, &cards_group, &toast_overlay,
|
&db, &cards_group, &toast_overlay,
|
||||||
&balance_label, &limit_label, &util_bar, &util_label,
|
&balance_label, &limit_label, &util_bar, &util_label,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add card button
|
||||||
let add_btn = gtk::Button::with_label("Add Card");
|
let add_btn = gtk::Button::with_label("Add Card");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
add_btn.set_halign(gtk::Align::Center);
|
add_btn.set_halign(gtk::Align::Center);
|
||||||
@@ -116,6 +120,7 @@ impl CreditCardsView {
|
|||||||
util_bar: >k::LevelBar,
|
util_bar: >k::LevelBar,
|
||||||
util_label: >k::Label,
|
util_label: >k::Label,
|
||||||
) {
|
) {
|
||||||
|
// Remove existing rows
|
||||||
while let Some(child) = group.first_child() {
|
while let Some(child) = group.first_child() {
|
||||||
if let Some(inner) = child.first_child() {
|
if let Some(inner) = child.first_child() {
|
||||||
if let Some(listbox) = inner.downcast_ref::<gtk::ListBox>() {
|
if let Some(listbox) = inner.downcast_ref::<gtk::ListBox>() {
|
||||||
@@ -237,6 +242,7 @@ impl CreditCardsView {
|
|||||||
btn_row.set_child(Some(&btn_box));
|
btn_row.set_child(Some(&btn_box));
|
||||||
expander.add_row(&btn_row);
|
expander.add_row(&btn_row);
|
||||||
|
|
||||||
|
// Wire payment
|
||||||
{
|
{
|
||||||
let card_id = card.id;
|
let card_id = card.id;
|
||||||
let card_name = card.name.clone();
|
let card_name = card.name.clone();
|
||||||
@@ -255,6 +261,7 @@ impl CreditCardsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire edit
|
||||||
{
|
{
|
||||||
let card_id = card.id;
|
let card_id = card.id;
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
@@ -272,6 +279,7 @@ impl CreditCardsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire delete
|
||||||
{
|
{
|
||||||
let card_id = card.id;
|
let card_id = card.id;
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
@@ -313,6 +321,7 @@ impl CreditCardsView {
|
|||||||
group.add(&expander);
|
group.add(&expander);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update summary
|
||||||
balance_label.set_label(&format!("{:.2}", total_balance));
|
balance_label.set_label(&format!("{:.2}", total_balance));
|
||||||
limit_label.set_label(&format!("{:.2}", total_limit));
|
limit_label.set_label(&format!("{:.2}", total_limit));
|
||||||
let util = if total_limit > 0.0 { total_balance / total_limit } else { 0.0 };
|
let util = if total_limit > 0.0 { total_balance / total_limit } else { 0.0 };
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub fn show_edit_dialog(
|
|||||||
.subtitle(type_label)
|
.subtitle(type_label)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Amount
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
.text(&format!("{:.2}", txn.amount))
|
.text(&format!("{:.2}", txn.amount))
|
||||||
@@ -54,6 +55,7 @@ pub fn show_edit_dialog(
|
|||||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
|
|
||||||
|
// Category selector
|
||||||
let cat_model = gtk::StringList::new(&[]);
|
let cat_model = gtk::StringList::new(&[]);
|
||||||
let mut cat_ids: Vec<i64> = Vec::new();
|
let mut cat_ids: Vec<i64> = Vec::new();
|
||||||
let mut cat_selected: u32 = 0;
|
let mut cat_selected: u32 = 0;
|
||||||
@@ -80,6 +82,7 @@ pub fn show_edit_dialog(
|
|||||||
cat_row.set_list_factory(Some(&make_category_factory()));
|
cat_row.set_list_factory(Some(&make_category_factory()));
|
||||||
let cat_ids = Rc::new(cat_ids);
|
let cat_ids = Rc::new(cat_ids);
|
||||||
|
|
||||||
|
// Date
|
||||||
let date_fmt = db.get_date_format_string();
|
let date_fmt = db.get_date_format_string();
|
||||||
let selected_date = Rc::new(Cell::new(txn.date));
|
let selected_date = Rc::new(Cell::new(txn.date));
|
||||||
let date_label = gtk::Label::new(Some(&txn.date.format(&date_fmt).to_string()));
|
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();
|
popover_ref.popdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Payee
|
||||||
let payee_row = adw::EntryRow::builder()
|
let payee_row = adw::EntryRow::builder()
|
||||||
.title("Payee (optional)")
|
.title("Payee (optional)")
|
||||||
.text(txn.payee.as_deref().unwrap_or(""))
|
.text(txn.payee.as_deref().unwrap_or(""))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Note
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.text(txn.note.as_deref().unwrap_or(""))
|
.text(txn.note.as_deref().unwrap_or(""))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Tags
|
||||||
let existing_tags = db.get_transaction_tags(txn_id)
|
let existing_tags = db.get_transaction_tags(txn_id)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -240,6 +246,7 @@ pub fn show_edit_dialog(
|
|||||||
form_group.add(¬e_row);
|
form_group.add(¬e_row);
|
||||||
form_group.add(&tags_row);
|
form_group.add(&tags_row);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||||
btn_box.set_halign(gtk::Align::Center);
|
btn_box.set_halign(gtk::Align::Center);
|
||||||
btn_box.set_margin_top(8);
|
btn_box.set_margin_top(8);
|
||||||
@@ -489,6 +496,7 @@ pub fn show_edit_dialog(
|
|||||||
|
|
||||||
let on_changed = Rc::new(on_changed);
|
let on_changed = Rc::new(on_changed);
|
||||||
|
|
||||||
|
// Wire save
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -556,6 +564,7 @@ pub fn show_edit_dialog(
|
|||||||
|
|
||||||
match db_ref.update_transaction(&updated) {
|
match db_ref.update_transaction(&updated) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
// Save tags
|
||||||
let mut tag_ids = Vec::new();
|
let mut tag_ids = Vec::new();
|
||||||
for name in &tag_names {
|
for name in &tag_names {
|
||||||
if let Ok(tid) = db_ref.get_or_create_tag(name) {
|
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);
|
let _ = db_ref.set_transaction_tags(txn_clone.id, &tag_ids);
|
||||||
|
|
||||||
|
// Save splits
|
||||||
let _ = db_ref.delete_splits(txn_clone.id);
|
let _ = db_ref.delete_splits(txn_clone.id);
|
||||||
let entries = split_entries_ref.borrow();
|
let entries = split_entries_ref.borrow();
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ impl ForecastView {
|
|||||||
.ok().flatten()
|
.ok().flatten()
|
||||||
.unwrap_or_else(|| "USD".to_string());
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
|
|
||||||
|
// Forecast summary card
|
||||||
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||||
summary_card.add_css_class("card");
|
summary_card.add_css_class("card");
|
||||||
summary_card.set_margin_start(4);
|
summary_card.set_margin_start(4);
|
||||||
@@ -46,6 +47,7 @@ impl ForecastView {
|
|||||||
summary_card.append(&summary_title);
|
summary_card.append(&summary_title);
|
||||||
summary_card.append(&summary_detail);
|
summary_card.append(&summary_detail);
|
||||||
|
|
||||||
|
// Monthly forecast table
|
||||||
let forecast_group = adw::PreferencesGroup::builder()
|
let forecast_group = adw::PreferencesGroup::builder()
|
||||||
.title("MONTHLY PROJECTION")
|
.title("MONTHLY PROJECTION")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ impl GoalsView {
|
|||||||
inner.set_margin_top(20);
|
inner.set_margin_top(20);
|
||||||
inner.set_margin_bottom(20);
|
inner.set_margin_bottom(20);
|
||||||
|
|
||||||
|
// Active goals group
|
||||||
let active_group = adw::PreferencesGroup::builder()
|
let active_group = adw::PreferencesGroup::builder()
|
||||||
.title("SAVINGS GOALS")
|
.title("SAVINGS GOALS")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Completed goals group
|
||||||
let completed_group = adw::PreferencesGroup::builder()
|
let completed_group = adw::PreferencesGroup::builder()
|
||||||
.title("COMPLETED")
|
.title("COMPLETED")
|
||||||
.build();
|
.build();
|
||||||
completed_group.set_visible(false);
|
completed_group.set_visible(false);
|
||||||
|
|
||||||
|
// Add goal button
|
||||||
let add_btn = gtk::Button::with_label("Add Goal");
|
let add_btn = gtk::Button::with_label("Add Goal");
|
||||||
add_btn.add_css_class("suggested-action");
|
add_btn.add_css_class("suggested-action");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
@@ -71,6 +74,7 @@ impl GoalsView {
|
|||||||
completed_group: &adw::PreferencesGroup,
|
completed_group: &adw::PreferencesGroup,
|
||||||
toast_overlay: &adw::ToastOverlay,
|
toast_overlay: &adw::ToastOverlay,
|
||||||
) {
|
) {
|
||||||
|
// Clear existing rows
|
||||||
Self::clear_group(active_group);
|
Self::clear_group(active_group);
|
||||||
Self::clear_group(completed_group);
|
Self::clear_group(completed_group);
|
||||||
|
|
||||||
@@ -480,6 +484,7 @@ impl GoalsView {
|
|||||||
toolbar.set_content(Some(&content));
|
toolbar.set_content(Some(&content));
|
||||||
dialog.set_child(Some(&toolbar));
|
dialog.set_child(Some(&toolbar));
|
||||||
|
|
||||||
|
// Save
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -524,6 +529,7 @@ impl GoalsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ impl HistoryView {
|
|||||||
action_bar: Option<>k::ActionBar>,
|
action_bar: Option<>k::ActionBar>,
|
||||||
action_count_label: Option<>k::Label>,
|
action_count_label: Option<>k::Label>,
|
||||||
) {
|
) {
|
||||||
|
// Clear existing children
|
||||||
while let Some(child) = list_container.first_child() {
|
while let Some(child) = list_container.first_child() {
|
||||||
list_container.remove(&child);
|
list_container.remove(&child);
|
||||||
}
|
}
|
||||||
@@ -1199,6 +1200,7 @@ impl HistoryView {
|
|||||||
updating: &Rc<Cell<bool>>,
|
updating: &Rc<Cell<bool>>,
|
||||||
no_results_page: &adw::StatusPage,
|
no_results_page: &adw::StatusPage,
|
||||||
) {
|
) {
|
||||||
|
// Clear existing chips
|
||||||
while let Some(child) = chips_box.first_child() {
|
while let Some(child) = chips_box.first_child() {
|
||||||
chips_box.remove(&child);
|
chips_box.remove(&child);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ impl InsightsView {
|
|||||||
header.add_css_class("property");
|
header.add_css_class("property");
|
||||||
list.append(&header);
|
list.append(&header);
|
||||||
|
|
||||||
|
// Category breakdown
|
||||||
for cat in &recap.categories {
|
for cat in &recap.categories {
|
||||||
let icon_name = cat.category_icon.as_deref().unwrap_or("folder-symbolic");
|
let icon_name = cat.category_icon.as_deref().unwrap_or("folder-symbolic");
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
|
|||||||
@@ -428,6 +428,7 @@ impl LogView {
|
|||||||
|
|
||||||
form_group.add(&category_row);
|
form_group.add(&category_row);
|
||||||
|
|
||||||
|
// Currency
|
||||||
let currency_labels: Vec<String> = currencies
|
let currency_labels: Vec<String> = currencies
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(code, name)| format!("{} - {}", code, name))
|
.map(|(code, name)| format!("{} - {}", code, name))
|
||||||
@@ -449,6 +450,7 @@ impl LogView {
|
|||||||
rate_label.set_margin_start(16);
|
rate_label.set_margin_start(16);
|
||||||
rate_label.set_visible(false);
|
rate_label.set_visible(false);
|
||||||
|
|
||||||
|
// Date picker
|
||||||
let date_fmt = db.get_date_format_string();
|
let date_fmt = db.get_date_format_string();
|
||||||
let today_date = chrono::Local::now().date_naive();
|
let today_date = chrono::Local::now().date_naive();
|
||||||
let selected_date = Rc::new(Cell::new(today_date));
|
let selected_date = Rc::new(Cell::new(today_date));
|
||||||
@@ -535,21 +537,25 @@ impl LogView {
|
|||||||
|
|
||||||
form_group.add(&date_row);
|
form_group.add(&date_row);
|
||||||
|
|
||||||
|
// Payee
|
||||||
let payee_row = adw::EntryRow::builder()
|
let payee_row = adw::EntryRow::builder()
|
||||||
.title("Payee (optional)")
|
.title("Payee (optional)")
|
||||||
.build();
|
.build();
|
||||||
form_group.add(&payee_row);
|
form_group.add(&payee_row);
|
||||||
|
|
||||||
|
// Note
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.build();
|
.build();
|
||||||
form_group.add(¬e_row);
|
form_group.add(¬e_row);
|
||||||
|
|
||||||
|
// Tags
|
||||||
let tags_row = adw::EntryRow::builder()
|
let tags_row = adw::EntryRow::builder()
|
||||||
.title("Tags (comma-separated)")
|
.title("Tags (comma-separated)")
|
||||||
.build();
|
.build();
|
||||||
form_group.add(&tags_row);
|
form_group.add(&tags_row);
|
||||||
|
|
||||||
|
// Split toggle
|
||||||
let split_switch = adw::SwitchRow::builder()
|
let split_switch = adw::SwitchRow::builder()
|
||||||
.title("Split across categories")
|
.title("Split across categories")
|
||||||
.build();
|
.build();
|
||||||
@@ -1196,6 +1202,7 @@ impl LogView {
|
|||||||
}
|
}
|
||||||
pending_do.borrow_mut().clear();
|
pending_do.borrow_mut().clear();
|
||||||
|
|
||||||
|
// Save tags
|
||||||
if !tag_names.is_empty() {
|
if !tag_names.is_empty() {
|
||||||
let mut tag_ids = Vec::new();
|
let mut tag_ids = Vec::new();
|
||||||
for name in &tag_names {
|
for name in &tag_names {
|
||||||
@@ -1206,6 +1213,7 @@ impl LogView {
|
|||||||
let _ = db_ref.set_transaction_tags(new_id, &tag_ids);
|
let _ = db_ref.set_transaction_tags(new_id, &tag_ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save splits
|
||||||
if !splits.is_empty() {
|
if !splits.is_empty() {
|
||||||
let _ = db_ref.insert_splits(new_id, &splits);
|
let _ = db_ref.insert_splits(new_id, &splits);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_top(12);
|
||||||
content.set_margin_bottom(16);
|
content.set_margin_bottom(16);
|
||||||
|
|
||||||
|
// Type toggle
|
||||||
let is_income = Rc::new(Cell::new(false));
|
let is_income = Rc::new(Cell::new(false));
|
||||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
type_box.set_halign(gtk::Align::Center);
|
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_group = adw::PreferencesGroup::new();
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.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);
|
amount_group.add(&amount_row);
|
||||||
content.append(&amount_group);
|
content.append(&amount_group);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_group = adw::PreferencesGroup::new();
|
let cat_group = adw::PreferencesGroup::new();
|
||||||
let expense_cats = db
|
let expense_cats = db
|
||||||
.list_categories(Some(TransactionType::Expense))
|
.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_group = adw::PreferencesGroup::new();
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.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(¬e_row);
|
note_group.add(¬e_row);
|
||||||
content.append(¬e_group);
|
content.append(¬e_group);
|
||||||
|
|
||||||
|
// Save button
|
||||||
let save_btn = gtk::Button::with_label("Save");
|
let save_btn = gtk::Button::with_label("Save");
|
||||||
save_btn.add_css_class("suggested-action");
|
save_btn.add_css_class("suggested-action");
|
||||||
save_btn.add_css_class("pill");
|
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_top(12);
|
||||||
content.set_margin_bottom(16);
|
content.set_margin_bottom(16);
|
||||||
|
|
||||||
|
// Type toggle
|
||||||
let is_income = Rc::new(Cell::new(false));
|
let is_income = Rc::new(Cell::new(false));
|
||||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
type_box.set_halign(gtk::Align::Center);
|
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_group = adw::PreferencesGroup::new();
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
@@ -276,6 +283,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
|||||||
amount_group.add(&amount_row);
|
amount_group.add(&amount_row);
|
||||||
content.append(&amount_group);
|
content.append(&amount_group);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_group = adw::PreferencesGroup::new();
|
let cat_group = adw::PreferencesGroup::new();
|
||||||
let expense_cats = db
|
let expense_cats = db
|
||||||
.list_categories(Some(TransactionType::Expense))
|
.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_group = adw::PreferencesGroup::new();
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
@@ -344,6 +353,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
|||||||
note_group.add(¬e_row);
|
note_group.add(¬e_row);
|
||||||
content.append(¬e_group);
|
content.append(¬e_group);
|
||||||
|
|
||||||
|
// Save button
|
||||||
let save_btn = gtk::Button::with_label("Save");
|
let save_btn = gtk::Button::with_label("Save");
|
||||||
save_btn.add_css_class("suggested-action");
|
save_btn.add_css_class("suggested-action");
|
||||||
save_btn.add_css_class("pill");
|
save_btn.add_css_class("pill");
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ impl RecurringView {
|
|||||||
inner.set_margin_top(20);
|
inner.set_margin_top(20);
|
||||||
inner.set_margin_bottom(20);
|
inner.set_margin_bottom(20);
|
||||||
|
|
||||||
|
// Active section
|
||||||
let active_group = adw::PreferencesGroup::builder()
|
let active_group = adw::PreferencesGroup::builder()
|
||||||
.title("ACTIVE")
|
.title("ACTIVE")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Paused section
|
||||||
let paused_group = adw::PreferencesGroup::builder()
|
let paused_group = adw::PreferencesGroup::builder()
|
||||||
.title("PAUSED")
|
.title("PAUSED")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let on_change: ChangeCb = Rc::new(RefCell::new(None));
|
let on_change: ChangeCb = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// Add button
|
||||||
let add_btn = gtk::Button::with_label("Add Recurring");
|
let add_btn = gtk::Button::with_label("Add Recurring");
|
||||||
add_btn.add_css_class("suggested-action");
|
add_btn.add_css_class("suggested-action");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
@@ -53,6 +56,7 @@ impl RecurringView {
|
|||||||
|
|
||||||
Self::load_recurring(&db, &active_group, &paused_group, &toast_overlay, &on_change);
|
Self::load_recurring(&db, &active_group, &paused_group, &toast_overlay, &on_change);
|
||||||
|
|
||||||
|
// Wire add button
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let active_ref = active_group.clone();
|
let active_ref = active_group.clone();
|
||||||
@@ -284,6 +288,7 @@ impl RecurringView {
|
|||||||
step1.set_margin_start(16);
|
step1.set_margin_start(16);
|
||||||
step1.set_margin_end(16);
|
step1.set_margin_end(16);
|
||||||
|
|
||||||
|
// Type toggle
|
||||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
type_box.add_css_class("linked");
|
type_box.add_css_class("linked");
|
||||||
type_box.set_halign(gtk::Align::Center);
|
type_box.set_halign(gtk::Align::Center);
|
||||||
@@ -301,6 +306,7 @@ impl RecurringView {
|
|||||||
|
|
||||||
let form1 = adw::PreferencesGroup::new();
|
let form1 = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Amount
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
.build();
|
.build();
|
||||||
@@ -308,6 +314,7 @@ impl RecurringView {
|
|||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
form1.add(&amount_row);
|
form1.add(&amount_row);
|
||||||
|
|
||||||
|
// Category
|
||||||
let category_model = gtk::StringList::new(&[]);
|
let category_model = gtk::StringList::new(&[]);
|
||||||
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
let category_row = adw::ComboRow::builder()
|
let category_row = adw::ComboRow::builder()
|
||||||
@@ -319,6 +326,7 @@ impl RecurringView {
|
|||||||
Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense);
|
Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense);
|
||||||
form1.add(&category_row);
|
form1.add(&category_row);
|
||||||
|
|
||||||
|
// Frequency
|
||||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||||
let freq_model = gtk::StringList::new(&freq_labels);
|
let freq_model = gtk::StringList::new(&freq_labels);
|
||||||
let freq_row = adw::ComboRow::builder()
|
let freq_row = adw::ComboRow::builder()
|
||||||
@@ -377,6 +385,7 @@ impl RecurringView {
|
|||||||
|
|
||||||
let form2 = adw::PreferencesGroup::new();
|
let form2 = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Currency
|
||||||
let base_currency = db
|
let base_currency = db
|
||||||
.get_setting("base_currency")
|
.get_setting("base_currency")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -410,6 +419,7 @@ impl RecurringView {
|
|||||||
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", "");
|
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", "");
|
||||||
form2.add(&end_row);
|
form2.add(&end_row);
|
||||||
|
|
||||||
|
// Note
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.build();
|
.build();
|
||||||
@@ -534,6 +544,7 @@ impl RecurringView {
|
|||||||
toolbar.set_content(Some(&scroll));
|
toolbar.set_content(Some(&scroll));
|
||||||
dialog.set_child(Some(&toolbar));
|
dialog.set_child(Some(&toolbar));
|
||||||
|
|
||||||
|
// Wire save
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -693,6 +704,7 @@ impl RecurringView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Amount
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
.text(&format!("{:.2}", rec.amount))
|
.text(&format!("{:.2}", rec.amount))
|
||||||
@@ -701,6 +713,7 @@ impl RecurringView {
|
|||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
form.add(&amount_row);
|
form.add(&amount_row);
|
||||||
|
|
||||||
|
// Frequency
|
||||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||||
let freq_model = gtk::StringList::new(&freq_labels);
|
let freq_model = gtk::StringList::new(&freq_labels);
|
||||||
let freq_idx = match rec.frequency {
|
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);
|
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", &end_str);
|
||||||
form.add(&end_row);
|
form.add(&end_row);
|
||||||
|
|
||||||
|
// Note
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.text(rec.note.as_deref().unwrap_or(""))
|
.text(rec.note.as_deref().unwrap_or(""))
|
||||||
@@ -758,6 +772,7 @@ impl RecurringView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||||
btn_box.set_halign(gtk::Align::Center);
|
btn_box.set_halign(gtk::Align::Center);
|
||||||
|
|
||||||
@@ -782,6 +797,7 @@ impl RecurringView {
|
|||||||
toolbar.set_content(Some(&content));
|
toolbar.set_content(Some(&content));
|
||||||
dialog.set_child(Some(&toolbar));
|
dialog.set_child(Some(&toolbar));
|
||||||
|
|
||||||
|
// Save
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
@@ -1018,6 +1034,7 @@ impl RecurringView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
|
|||||||
@@ -1244,12 +1244,14 @@ impl SettingsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name row
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Name")
|
.title("Name")
|
||||||
.text(&cat.name)
|
.text(&cat.name)
|
||||||
.build();
|
.build();
|
||||||
form.add(&name_row);
|
form.add(&name_row);
|
||||||
|
|
||||||
|
// Icon row
|
||||||
let selected_icon: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(cat.icon.clone()));
|
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)
|
let initial_icon = icon_theme::resolve_category_icon(&cat.icon, &cat.color)
|
||||||
@@ -1285,6 +1287,7 @@ impl SettingsView {
|
|||||||
}
|
}
|
||||||
form.add(&icon_row);
|
form.add(&icon_row);
|
||||||
|
|
||||||
|
// Color row
|
||||||
let current_color = cat.color.as_deref()
|
let current_color = cat.color.as_deref()
|
||||||
.and_then(|hex| {
|
.and_then(|hex| {
|
||||||
let hex = hex.trim_start_matches('#');
|
let hex = hex.trim_start_matches('#');
|
||||||
@@ -1398,6 +1401,7 @@ impl SettingsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name row
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Name")
|
.title("Name")
|
||||||
.build();
|
.build();
|
||||||
@@ -2055,9 +2059,11 @@ impl SettingsView {
|
|||||||
db.execute_raw(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))
|
db.execute_raw(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))
|
||||||
.map_err(|e| format!("Backup failed: {}", e))?;
|
.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("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());
|
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")
|
let retention = db.get_setting("auto_backup_retention")
|
||||||
.ok().flatten()
|
.ok().flatten()
|
||||||
.and_then(|v| v.parse::<usize>().ok())
|
.and_then(|v| v.parse::<usize>().ok())
|
||||||
@@ -2773,6 +2779,7 @@ impl SettingsView {
|
|||||||
group: &adw::PreferencesGroup,
|
group: &adw::PreferencesGroup,
|
||||||
toast_overlay: &adw::ToastOverlay,
|
toast_overlay: &adw::ToastOverlay,
|
||||||
) {
|
) {
|
||||||
|
// Remove existing rows
|
||||||
let mut rows_to_remove = Vec::new();
|
let mut rows_to_remove = Vec::new();
|
||||||
let mut child = group.first_child();
|
let mut child = group.first_child();
|
||||||
while let Some(widget) = child {
|
while let Some(widget) = child {
|
||||||
@@ -2853,6 +2860,7 @@ impl SettingsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Type selector
|
||||||
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
||||||
let type_row = adw::ComboRow::builder()
|
let type_row = adw::ComboRow::builder()
|
||||||
.title("Type")
|
.title("Type")
|
||||||
@@ -2868,11 +2876,13 @@ impl SettingsView {
|
|||||||
.build();
|
.build();
|
||||||
form.add(&field_row);
|
form.add(&field_row);
|
||||||
|
|
||||||
|
// Pattern
|
||||||
let pattern_row = adw::EntryRow::builder()
|
let pattern_row = adw::EntryRow::builder()
|
||||||
.title("Contains text")
|
.title("Contains text")
|
||||||
.build();
|
.build();
|
||||||
form.add(&pattern_row);
|
form.add(&pattern_row);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_model = gtk::StringList::new(&[]);
|
let cat_model = gtk::StringList::new(&[]);
|
||||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
||||||
@@ -2985,6 +2995,7 @@ impl SettingsView {
|
|||||||
group: &adw::PreferencesGroup,
|
group: &adw::PreferencesGroup,
|
||||||
toast_overlay: &adw::ToastOverlay,
|
toast_overlay: &adw::ToastOverlay,
|
||||||
) {
|
) {
|
||||||
|
// Remove existing rows
|
||||||
let mut rows_to_remove = Vec::new();
|
let mut rows_to_remove = Vec::new();
|
||||||
let mut child = group.first_child();
|
let mut child = group.first_child();
|
||||||
while let Some(widget) = child {
|
while let Some(widget) = child {
|
||||||
@@ -3071,11 +3082,13 @@ impl SettingsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Template name")
|
.title("Template name")
|
||||||
.build();
|
.build();
|
||||||
form.add(&name_row);
|
form.add(&name_row);
|
||||||
|
|
||||||
|
// Type
|
||||||
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
||||||
let type_row = adw::ComboRow::builder()
|
let type_row = adw::ComboRow::builder()
|
||||||
.title("Type")
|
.title("Type")
|
||||||
@@ -3083,6 +3096,7 @@ impl SettingsView {
|
|||||||
.build();
|
.build();
|
||||||
form.add(&type_row);
|
form.add(&type_row);
|
||||||
|
|
||||||
|
// Amount (optional)
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount (optional)")
|
.title("Amount (optional)")
|
||||||
.build();
|
.build();
|
||||||
@@ -3090,6 +3104,7 @@ impl SettingsView {
|
|||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
form.add(&amount_row);
|
form.add(&amount_row);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_model = gtk::StringList::new(&[]);
|
let cat_model = gtk::StringList::new(&[]);
|
||||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
||||||
@@ -3122,6 +3137,7 @@ impl SettingsView {
|
|||||||
} else {
|
} else {
|
||||||
TransactionType::Income
|
TransactionType::Income
|
||||||
};
|
};
|
||||||
|
// Clear existing
|
||||||
let n = cat_model.n_items();
|
let n = cat_model.n_items();
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
cat_model.splice(0, n, &[] as &[&str]);
|
cat_model.splice(0, n, &[] as &[&str]);
|
||||||
@@ -3141,11 +3157,13 @@ impl SettingsView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payee (optional)
|
||||||
let payee_row = adw::EntryRow::builder()
|
let payee_row = adw::EntryRow::builder()
|
||||||
.title("Payee (optional)")
|
.title("Payee (optional)")
|
||||||
.build();
|
.build();
|
||||||
form.add(&payee_row);
|
form.add(&payee_row);
|
||||||
|
|
||||||
|
// Note (optional)
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.build();
|
.build();
|
||||||
@@ -3322,11 +3340,13 @@ impl SettingsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Name")
|
.title("Name")
|
||||||
.build();
|
.build();
|
||||||
form.add(&name_row);
|
form.add(&name_row);
|
||||||
|
|
||||||
|
// Icon
|
||||||
let icon_row = adw::ActionRow::builder()
|
let icon_row = adw::ActionRow::builder()
|
||||||
.title("Icon")
|
.title("Icon")
|
||||||
.subtitle("None")
|
.subtitle("None")
|
||||||
@@ -3349,6 +3369,7 @@ impl SettingsView {
|
|||||||
}
|
}
|
||||||
form.add(&icon_row);
|
form.add(&icon_row);
|
||||||
|
|
||||||
|
// Color
|
||||||
let color_btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new()));
|
let color_btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new()));
|
||||||
color_btn.set_valign(gtk::Align::Center);
|
color_btn.set_valign(gtk::Align::Center);
|
||||||
let default_color = gdk::RGBA::parse("#95a5a6").unwrap_or(gdk::RGBA::BLACK);
|
let default_color = gdk::RGBA::parse("#95a5a6").unwrap_or(gdk::RGBA::BLACK);
|
||||||
|
|||||||
@@ -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
|
(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 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 points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, &val)| {
|
||||||
let x = padding + i as f64 * step;
|
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)
|
(x, y)
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
|
// Fill area under curve
|
||||||
ctx.move_to(points[0].0, h - padding);
|
ctx.move_to(points[0].0, h - padding);
|
||||||
for &(x, y) in &points {
|
for &(x, y) in &points {
|
||||||
ctx.line_to(x, y);
|
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);
|
ctx.set_source_rgba(r, g, b, 0.15);
|
||||||
let _ = ctx.fill();
|
let _ = ctx.fill();
|
||||||
|
|
||||||
|
// Draw line
|
||||||
ctx.move_to(points[0].0, points[0].1);
|
ctx.move_to(points[0].0, points[0].1);
|
||||||
for &(x, y) in points.iter().skip(1) {
|
for &(x, y) in points.iter().skip(1) {
|
||||||
ctx.line_to(x, y);
|
ctx.line_to(x, y);
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ impl SubscriptionsView {
|
|||||||
cost_card.append(&monthly_label);
|
cost_card.append(&monthly_label);
|
||||||
cost_card.append(&yearly_label);
|
cost_card.append(&yearly_label);
|
||||||
|
|
||||||
|
// Active subscriptions group
|
||||||
let active_group = adw::PreferencesGroup::builder()
|
let active_group = adw::PreferencesGroup::builder()
|
||||||
.title("ACTIVE SUBSCRIPTIONS")
|
.title("ACTIVE SUBSCRIPTIONS")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Paused subscriptions group
|
||||||
let paused_group = adw::PreferencesGroup::builder()
|
let paused_group = adw::PreferencesGroup::builder()
|
||||||
.title("PAUSED SUBSCRIPTIONS")
|
.title("PAUSED SUBSCRIPTIONS")
|
||||||
.build();
|
.build();
|
||||||
@@ -88,6 +90,7 @@ impl SubscriptionsView {
|
|||||||
&toast_overlay, &on_change,
|
&toast_overlay, &on_change,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add button
|
||||||
let add_btn = gtk::Button::with_label("Add Subscription");
|
let add_btn = gtk::Button::with_label("Add Subscription");
|
||||||
add_btn.add_css_class("suggested-action");
|
add_btn.add_css_class("suggested-action");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
@@ -262,6 +265,7 @@ impl SubscriptionsView {
|
|||||||
equiv_label.add_css_class("dim-label");
|
equiv_label.add_css_class("dim-label");
|
||||||
suffix_box.append(&equiv_label);
|
suffix_box.append(&equiv_label);
|
||||||
|
|
||||||
|
// Pause/resume button
|
||||||
let pause_btn = if sub.active {
|
let pause_btn = if sub.active {
|
||||||
let btn = gtk::Button::from_icon_name("tabler-player-pause");
|
let btn = gtk::Button::from_icon_name("tabler-player-pause");
|
||||||
btn.set_tooltip_text(Some("Pause"));
|
btn.set_tooltip_text(Some("Pause"));
|
||||||
@@ -294,6 +298,7 @@ impl SubscriptionsView {
|
|||||||
}
|
}
|
||||||
suffix_box.append(&pause_btn);
|
suffix_box.append(&pause_btn);
|
||||||
|
|
||||||
|
// Edit button
|
||||||
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
|
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
|
||||||
edit_btn.add_css_class("flat");
|
edit_btn.add_css_class("flat");
|
||||||
edit_btn.set_tooltip_text(Some("Edit"));
|
edit_btn.set_tooltip_text(Some("Edit"));
|
||||||
@@ -317,6 +322,7 @@ impl SubscriptionsView {
|
|||||||
}
|
}
|
||||||
suffix_box.append(&edit_btn);
|
suffix_box.append(&edit_btn);
|
||||||
|
|
||||||
|
// Delete button
|
||||||
let del_btn = gtk::Button::from_icon_name("tabler-trash");
|
let del_btn = gtk::Button::from_icon_name("tabler-trash");
|
||||||
del_btn.add_css_class("flat");
|
del_btn.add_css_class("flat");
|
||||||
del_btn.set_tooltip_text(Some("Delete"));
|
del_btn.set_tooltip_text(Some("Delete"));
|
||||||
@@ -380,11 +386,13 @@ impl SubscriptionsView {
|
|||||||
|
|
||||||
let form1 = adw::PreferencesGroup::new();
|
let form1 = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Name")
|
.title("Name")
|
||||||
.build();
|
.build();
|
||||||
form1.add(&name_row);
|
form1.add(&name_row);
|
||||||
|
|
||||||
|
// Amount
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
.build();
|
.build();
|
||||||
@@ -392,6 +400,7 @@ impl SubscriptionsView {
|
|||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
form1.add(&amount_row);
|
form1.add(&amount_row);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_model = gtk::StringList::new(&[]);
|
let cat_model = gtk::StringList::new(&[]);
|
||||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
if let Ok(cats) = db.list_subscription_categories() {
|
if let Ok(cats) = db.list_subscription_categories() {
|
||||||
@@ -413,6 +422,7 @@ impl SubscriptionsView {
|
|||||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||||
form1.add(&cat_row);
|
form1.add(&cat_row);
|
||||||
|
|
||||||
|
// Frequency
|
||||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||||
let freq_model = gtk::StringList::new(&freq_labels);
|
let freq_model = gtk::StringList::new(&freq_labels);
|
||||||
let freq_row = adw::ComboRow::builder()
|
let freq_row = adw::ComboRow::builder()
|
||||||
@@ -444,6 +454,7 @@ impl SubscriptionsView {
|
|||||||
|
|
||||||
let form2 = adw::PreferencesGroup::new();
|
let form2 = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Currency
|
||||||
let base_cur = db.get_setting("base_currency")
|
let base_cur = db.get_setting("base_currency")
|
||||||
.ok().flatten()
|
.ok().flatten()
|
||||||
.unwrap_or_else(|| "USD".to_string());
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
@@ -466,15 +477,18 @@ impl SubscriptionsView {
|
|||||||
.build();
|
.build();
|
||||||
form2.add(¤cy_row);
|
form2.add(¤cy_row);
|
||||||
|
|
||||||
|
// Start date
|
||||||
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
|
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);
|
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &today_str);
|
||||||
form2.add(&start_row);
|
form2.add(&start_row);
|
||||||
|
|
||||||
|
// Note (optional)
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.build();
|
.build();
|
||||||
form2.add(¬e_row);
|
form2.add(¬e_row);
|
||||||
|
|
||||||
|
// URL (optional)
|
||||||
let url_row = adw::EntryRow::builder()
|
let url_row = adw::EntryRow::builder()
|
||||||
.title("URL (optional)")
|
.title("URL (optional)")
|
||||||
.build();
|
.build();
|
||||||
@@ -674,12 +688,14 @@ impl SubscriptionsView {
|
|||||||
|
|
||||||
let form = adw::PreferencesGroup::new();
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
// Name
|
||||||
let name_row = adw::EntryRow::builder()
|
let name_row = adw::EntryRow::builder()
|
||||||
.title("Name")
|
.title("Name")
|
||||||
.text(&sub.name)
|
.text(&sub.name)
|
||||||
.build();
|
.build();
|
||||||
form.add(&name_row);
|
form.add(&name_row);
|
||||||
|
|
||||||
|
// Amount
|
||||||
let amount_row = adw::EntryRow::builder()
|
let amount_row = adw::EntryRow::builder()
|
||||||
.title("Amount")
|
.title("Amount")
|
||||||
.text(&format!("{:.2}", sub.amount))
|
.text(&format!("{:.2}", sub.amount))
|
||||||
@@ -688,6 +704,7 @@ impl SubscriptionsView {
|
|||||||
crate::numpad::attach_numpad(&amount_row);
|
crate::numpad::attach_numpad(&amount_row);
|
||||||
form.add(&amount_row);
|
form.add(&amount_row);
|
||||||
|
|
||||||
|
// Currency
|
||||||
let mut currencies = ExchangeRateService::supported_currencies();
|
let mut currencies = ExchangeRateService::supported_currencies();
|
||||||
let base_cur = db.get_setting("base_currency")
|
let base_cur = db.get_setting("base_currency")
|
||||||
.ok().flatten()
|
.ok().flatten()
|
||||||
@@ -711,6 +728,7 @@ impl SubscriptionsView {
|
|||||||
.build();
|
.build();
|
||||||
form.add(¤cy_row);
|
form.add(¤cy_row);
|
||||||
|
|
||||||
|
// Frequency
|
||||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||||
let freq_model = gtk::StringList::new(&freq_labels);
|
let freq_model = gtk::StringList::new(&freq_labels);
|
||||||
let selected_freq = match sub.frequency {
|
let selected_freq = match sub.frequency {
|
||||||
@@ -727,6 +745,7 @@ impl SubscriptionsView {
|
|||||||
.build();
|
.build();
|
||||||
form.add(&freq_row);
|
form.add(&freq_row);
|
||||||
|
|
||||||
|
// Category
|
||||||
let cat_model = gtk::StringList::new(&[]);
|
let cat_model = gtk::StringList::new(&[]);
|
||||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
let mut selected_cat = 0u32;
|
let mut selected_cat = 0u32;
|
||||||
@@ -753,16 +772,19 @@ impl SubscriptionsView {
|
|||||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||||
form.add(&cat_row);
|
form.add(&cat_row);
|
||||||
|
|
||||||
|
// Start date
|
||||||
let start_str = sub.start_date.format("%Y-%m-%d").to_string();
|
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);
|
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &start_str);
|
||||||
form.add(&start_row);
|
form.add(&start_row);
|
||||||
|
|
||||||
|
// Note (optional)
|
||||||
let note_row = adw::EntryRow::builder()
|
let note_row = adw::EntryRow::builder()
|
||||||
.title("Note (optional)")
|
.title("Note (optional)")
|
||||||
.text(sub.note.as_deref().unwrap_or(""))
|
.text(sub.note.as_deref().unwrap_or(""))
|
||||||
.build();
|
.build();
|
||||||
form.add(¬e_row);
|
form.add(¬e_row);
|
||||||
|
|
||||||
|
// URL (optional)
|
||||||
let url_row = adw::EntryRow::builder()
|
let url_row = adw::EntryRow::builder()
|
||||||
.title("URL (optional)")
|
.title("URL (optional)")
|
||||||
.text(sub.url.as_deref().unwrap_or(""))
|
.text(sub.url.as_deref().unwrap_or(""))
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ impl MainWindow {
|
|||||||
let content_stack = gtk::Stack::new();
|
let content_stack = gtk::Stack::new();
|
||||||
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
||||||
|
|
||||||
|
// Log view
|
||||||
let log_view = Rc::new(LogView::new(db.clone(), app));
|
let log_view = Rc::new(LogView::new(db.clone(), app));
|
||||||
let log_scroll = gtk::ScrolledWindow::builder()
|
let log_scroll = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
@@ -91,6 +92,7 @@ impl MainWindow {
|
|||||||
log_view.toast_overlay.set_child(Some(&log_scroll));
|
log_view.toast_overlay.set_child(Some(&log_scroll));
|
||||||
content_stack.add_named(&log_view.toast_overlay, Some("log"));
|
content_stack.add_named(&log_view.toast_overlay, Some("log"));
|
||||||
|
|
||||||
|
// History view
|
||||||
let history_view = HistoryView::new(db.clone());
|
let history_view = HistoryView::new(db.clone());
|
||||||
let history_scroll = gtk::ScrolledWindow::builder()
|
let history_scroll = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
@@ -98,18 +100,23 @@ impl MainWindow {
|
|||||||
.build();
|
.build();
|
||||||
content_stack.add_named(&history_scroll, Some("history"));
|
content_stack.add_named(&history_scroll, Some("history"));
|
||||||
|
|
||||||
|
// Charts view
|
||||||
let charts_view = ChartsView::new(db.clone());
|
let charts_view = ChartsView::new(db.clone());
|
||||||
content_stack.add_named(&charts_view.container, Some("charts"));
|
content_stack.add_named(&charts_view.container, Some("charts"));
|
||||||
|
|
||||||
|
// Budgets view
|
||||||
let budgets_view = BudgetsView::new(db.clone());
|
let budgets_view = BudgetsView::new(db.clone());
|
||||||
content_stack.add_named(&budgets_view.container, Some("budgets"));
|
content_stack.add_named(&budgets_view.container, Some("budgets"));
|
||||||
|
|
||||||
|
// Goals view
|
||||||
let goals_view = GoalsView::new(db.clone());
|
let goals_view = GoalsView::new(db.clone());
|
||||||
content_stack.add_named(&goals_view.container, Some("goals"));
|
content_stack.add_named(&goals_view.container, Some("goals"));
|
||||||
|
|
||||||
|
// Recurring view
|
||||||
let recurring_view = Rc::new(RecurringView::new(db.clone()));
|
let recurring_view = Rc::new(RecurringView::new(db.clone()));
|
||||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||||
|
|
||||||
|
// Subscriptions view
|
||||||
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
|
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
|
||||||
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
|
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
|
||||||
|
|
||||||
@@ -127,18 +134,23 @@ impl MainWindow {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wishlist view
|
||||||
let wishlist_view = WishlistView::new(db.clone());
|
let wishlist_view = WishlistView::new(db.clone());
|
||||||
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
|
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
|
||||||
|
|
||||||
|
// Forecast view
|
||||||
let forecast_view = ForecastView::new(db.clone());
|
let forecast_view = ForecastView::new(db.clone());
|
||||||
content_stack.add_named(&forecast_view.container, Some("forecast"));
|
content_stack.add_named(&forecast_view.container, Some("forecast"));
|
||||||
|
|
||||||
|
// Insights view
|
||||||
let insights_view = InsightsView::new(db.clone());
|
let insights_view = InsightsView::new(db.clone());
|
||||||
content_stack.add_named(&insights_view.container, Some("insights"));
|
content_stack.add_named(&insights_view.container, Some("insights"));
|
||||||
|
|
||||||
|
// Credit Cards view
|
||||||
let credit_cards_view = CreditCardsView::new(db.clone());
|
let credit_cards_view = CreditCardsView::new(db.clone());
|
||||||
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
|
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
|
||||||
|
|
||||||
|
// Settings view
|
||||||
let settings_view = SettingsView::new(db.clone(), app);
|
let settings_view = SettingsView::new(db.clone(), app);
|
||||||
content_stack.add_named(&settings_view.container, Some("settings"));
|
content_stack.add_named(&settings_view.container, Some("settings"));
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ impl WishlistView {
|
|||||||
inner.set_margin_top(20);
|
inner.set_margin_top(20);
|
||||||
inner.set_margin_bottom(20);
|
inner.set_margin_bottom(20);
|
||||||
|
|
||||||
|
// Total card
|
||||||
let total_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
let total_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||||
total_card.add_css_class("card");
|
total_card.add_css_class("card");
|
||||||
total_card.set_margin_start(4);
|
total_card.set_margin_start(4);
|
||||||
@@ -48,16 +49,19 @@ impl WishlistView {
|
|||||||
total_card.append(&total_title);
|
total_card.append(&total_title);
|
||||||
total_card.append(&total_label);
|
total_card.append(&total_label);
|
||||||
|
|
||||||
|
// Active items
|
||||||
let active_group = adw::PreferencesGroup::builder()
|
let active_group = adw::PreferencesGroup::builder()
|
||||||
.title("WANTED")
|
.title("WANTED")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Purchased items
|
||||||
let purchased_group = adw::PreferencesGroup::builder()
|
let purchased_group = adw::PreferencesGroup::builder()
|
||||||
.title("PURCHASED")
|
.title("PURCHASED")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Self::load_items(&db, &active_group, &purchased_group, &total_label, &base_currency, &toast_overlay);
|
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");
|
let add_btn = gtk::Button::with_label("Add Item");
|
||||||
add_btn.add_css_class("suggested-action");
|
add_btn.add_css_class("suggested-action");
|
||||||
add_btn.add_css_class("pill");
|
add_btn.add_css_class("pill");
|
||||||
|
|||||||
Reference in New Issue
Block a user