Compare commits
29 Commits
main
..
10a76e3003
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
*~
|
||||
|
||||
# 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
|
||||
@@ -1,58 +0,0 @@
|
||||
PREFIX ?= /usr/local
|
||||
DESTDIR ?=
|
||||
BINDIR = $(PREFIX)/bin
|
||||
DATADIR = $(PREFIX)/share
|
||||
|
||||
ICON_DATA = outlay-gtk/data/icons
|
||||
DESKTOP_FILE = outlay-gtk/data/com.outlay.app.desktop
|
||||
METAINFO_FILE = outlay-gtk/data/com.outlay.app.metainfo.xml
|
||||
SCHEMA_FILE = outlay-gtk/data/com.outlay.app.gschema.xml
|
||||
|
||||
.PHONY: build install uninstall
|
||||
|
||||
build:
|
||||
cargo build --release -p outlay-gtk
|
||||
|
||||
install: build
|
||||
install -Dm755 target/release/outlay-gtk $(DESTDIR)$(BINDIR)/outlay-gtk
|
||||
|
||||
# Desktop file
|
||||
install -Dm644 $(DESKTOP_FILE) $(DESTDIR)$(DATADIR)/applications/com.outlay.app.desktop
|
||||
|
||||
# AppStream metainfo
|
||||
install -Dm644 $(METAINFO_FILE) $(DESTDIR)$(DATADIR)/metainfo/com.outlay.app.metainfo.xml
|
||||
|
||||
# GSettings schema
|
||||
install -Dm644 $(SCHEMA_FILE) $(DESTDIR)$(DATADIR)/glib-2.0/schemas/com.outlay.app.gschema.xml
|
||||
|
||||
# App icons (raster sizes)
|
||||
for size_dir in $(ICON_DATA)/hicolor/*/apps; do \
|
||||
size=$$(basename "$$(dirname "$$size_dir")"); \
|
||||
for icon in "$$size_dir"/com.outlay.app.*; do \
|
||||
[ -f "$$icon" ] || continue; \
|
||||
install -Dm644 "$$icon" $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$$(basename "$$icon"); \
|
||||
done; \
|
||||
done
|
||||
|
||||
# Scalable action icons (outlay custom + tabler)
|
||||
for svg in $(ICON_DATA)/hicolor/scalable/actions/outlay-*.svg \
|
||||
$(ICON_DATA)/hicolor/scalable/actions/tabler-*.svg; do \
|
||||
[ -f "$$svg" ] || continue; \
|
||||
install -Dm644 "$$svg" $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/actions/$$(basename "$$svg"); \
|
||||
done
|
||||
|
||||
# Bundled fonts
|
||||
for ttf in outlay-gtk/data/fonts/*.ttf; do \
|
||||
[ -f "$$ttf" ] || continue; \
|
||||
install -Dm644 "$$ttf" $(DESTDIR)$(DATADIR)/fonts/outlay/$$(basename "$$ttf"); \
|
||||
done
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/outlay-gtk
|
||||
rm -f $(DESTDIR)$(DATADIR)/applications/com.outlay.app.desktop
|
||||
rm -f $(DESTDIR)$(DATADIR)/metainfo/com.outlay.app.metainfo.xml
|
||||
rm -f $(DESTDIR)$(DATADIR)/glib-2.0/schemas/com.outlay.app.gschema.xml
|
||||
rm -rf $(DESTDIR)$(DATADIR)/icons/hicolor/*/apps/com.outlay.app.*
|
||||
rm -rf $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/actions/outlay-*.svg
|
||||
rm -rf $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/actions/tabler-*.svg
|
||||
rm -rf $(DESTDIR)$(DATADIR)/fonts/outlay
|
||||
@@ -1,710 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="outlay-gtk/data/icons/com.outlay.Outlay-256.png?v=2" alt="Outlay" width="128" height="128">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Outlay</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Personal finance for people, not corporations.</strong><br>
|
||||
Track expenses, plan budgets, and take direct control of your money.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/License-CC0_1.0-brightgreen?style=flat-square" alt="License: CC0"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Language-Rust-orange?style=flat-square&logo=rust" alt="Rust"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Toolkit-GTK4-4a86cf?style=flat-square&logo=gnome" alt="GTK4"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/UI-libadwaita_1.8-4a86cf?style=flat-square&logo=gnome" alt="libadwaita"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Database-SQLite-003B57?style=flat-square&logo=sqlite" alt="SQLite"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Platform-Linux-FCC624?style=flat-square&logo=linux&logoColor=black" alt="Linux"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Display-Wayland-yellow?style=flat-square" alt="Wayland"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Packaging-AppImage-blue?style=flat-square" alt="AppImage"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Tracking-Zero-success?style=flat-square" alt="No Tracking"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Ads-None-success?style=flat-square" alt="No Ads"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Outlay is a desktop finance app built with Rust and GTK4. It runs entirely on your machine, stores everything locally in SQLite, and never phones home. No accounts, no cloud sync, no subscriptions, no data harvesting. Your financial data stays yours - no middlemen, no landlords between you and your own information.
|
||||
|
||||
It exists because tracking your spending shouldn't require handing your most intimate data to some company's servers. Every feature is available to everyone equally - no premium tiers, no artificial scarcity, no paywalls.
|
||||
|
||||
Released into the public domain under CC0. Take it, use it, change it, share it. It belongs to the commons.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Features at a glance](#-features-at-a-glance)
|
||||
- [Installation](#-installation)
|
||||
- [Building from source](#%EF%B8%8F-building-from-source)
|
||||
- [Views and functionality](#-views-and-functionality)
|
||||
- [Import and export](#-import-and-export)
|
||||
- [Multi-currency support](#-multi-currency-support)
|
||||
- [Notifications](#-notifications)
|
||||
- [System tray](#-system-tray)
|
||||
- [Keyboard and workflow](#%EF%B8%8F-keyboard-and-workflow)
|
||||
- [Architecture](#-architecture)
|
||||
- [Data storage](#-data-storage)
|
||||
- [Packaging](#-packaging)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
Outlay is a native Linux application for personal income and expense tracking. It is built from scratch in Rust using GTK4 and libadwaita 1.8 for a modern GNOME-native experience. Everything runs locally - the database is a single SQLite file on your disk, exchange rates are cached after fetching, and there is no telemetry or network activity beyond currency conversion lookups.
|
||||
|
||||
The goal is simple: give people a tool that is genuinely useful for managing their own money, without any of the usual strings attached. No venture capital, no growth metrics, no engagement loops, no investor exit strategy. Just a straightforward app that does what it says.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features at a glance
|
||||
|
||||
| Area | What it does |
|
||||
|---|---|
|
||||
| **Transaction logging** | Quick expense/income entry with categories, notes, payees, tags, and inline math |
|
||||
| **History browsing** | Day-grouped transaction list with search, category filters, batch operations |
|
||||
| **Charts** | Donut charts, bar charts, and smooth trend lines with monthly navigation |
|
||||
| **Budgets** | Per-category monthly budgets with progress bars, rollover, pace tracking, sparklines |
|
||||
| **Recurring** | Automated transaction generation for daily/weekly/biweekly/monthly/yearly patterns |
|
||||
| **Subscriptions** | Lifecycle tracking with monthly/yearly cost summaries, linked to recurring |
|
||||
| **Savings goals** | Target amounts with deadlines, progress tracking, color-coded visualization |
|
||||
| **Credit cards** | Balance and utilization tracking, statement cycle management |
|
||||
| **Wishlist** | Want-to-buy items with priority, pricing, and purchase status |
|
||||
| **Forecast** | Cash flow projections based on recurring patterns and historical averages |
|
||||
| **Insights** | Spending anomaly detection, monthly recaps, year-over-year comparisons |
|
||||
| **Achievements** | 16+ unlockable badges for financial milestones and healthy habits |
|
||||
| **Streaks** | No-spend and on-budget streak tracking with personal bests |
|
||||
| **Calendar** | Spending heatmap with per-day drill-down |
|
||||
| **Multi-currency** | 40+ currencies with automatic exchange rate fetching and caching |
|
||||
| **Import/Export** | CSV, JSON, OFX, QIF, PDF - bring your data in, take your data out |
|
||||
| **Backup** | Full-state `.outlay` archive files for portability and safekeeping |
|
||||
| **System tray** | Minimize to tray, quick-add popup, persistent background operation |
|
||||
| **Notifications** | Budget alerts, bill reminders, weekly digests, achievement unlocks |
|
||||
| **OCR** | Extract amounts from receipt images via Tesseract |
|
||||
| **NLP** | Natural language parsing for quick transaction entry |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### AppImage (recommended)
|
||||
|
||||
Download the latest AppImage from the releases page, make it executable, and run:
|
||||
|
||||
```bash
|
||||
chmod +x Outlay-*.AppImage
|
||||
./Outlay-*.AppImage
|
||||
```
|
||||
|
||||
No installation required. It carries everything it needs. Works on any modern Linux distribution.
|
||||
|
||||
### From source
|
||||
|
||||
See [Building from source](#-building-from-source) below.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Building from source
|
||||
|
||||
### Dependencies
|
||||
|
||||
You will need:
|
||||
|
||||
- Rust toolchain (stable, 2024 edition)
|
||||
- GTK4 development libraries (4.10+)
|
||||
- libadwaita development libraries (1.8+)
|
||||
- SQLite3 development headers
|
||||
- pkg-config
|
||||
|
||||
On Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install libgtk-4-dev libadwaita-1-dev libsqlite3-dev pkg-config
|
||||
```
|
||||
|
||||
On Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install gtk4-devel libadwaita-devel sqlite-devel pkg-config
|
||||
```
|
||||
|
||||
On Arch:
|
||||
|
||||
```bash
|
||||
sudo pacman -S gtk4 libadwaita sqlite pkg-config
|
||||
```
|
||||
|
||||
### Build and run
|
||||
|
||||
```bash
|
||||
git clone https://git.lashman.live/lashman/outlay.git
|
||||
cd outlay
|
||||
cargo build --release
|
||||
./target/release/outlay-gtk
|
||||
```
|
||||
|
||||
### Demo data
|
||||
|
||||
To populate the app with two years of realistic sample data for testing:
|
||||
|
||||
```bash
|
||||
cargo run -p outlay-core --bin seed-demo
|
||||
```
|
||||
|
||||
This wipes the existing database and creates a fresh one filled with transactions, budgets, recurring items, subscriptions, and goals across various categories.
|
||||
|
||||
---
|
||||
|
||||
## 🖥 Views and functionality
|
||||
|
||||
### 📝 Log view
|
||||
|
||||
The default view. A quick-entry form for logging transactions.
|
||||
|
||||
- Toggle between expense and income modes
|
||||
- Amount field with auto-focus and inline arithmetic (type `12.50+3` and it evaluates to `15.50`)
|
||||
- Currency selector with 40+ options
|
||||
- Category dropdown filtered by transaction type
|
||||
- Date picker defaulting to today
|
||||
- Optional note and payee fields
|
||||
- Toast confirmation on save
|
||||
|
||||
The form is designed for speed. Open the app, type a number, pick a category, save. Three seconds and you are done. No friction, no unnecessary steps - because tracking expenses should be easier than not tracking them.
|
||||
|
||||
### 📜 History
|
||||
|
||||
Browse all your transactions grouped by day.
|
||||
|
||||
- Month navigation with previous/next controls
|
||||
- Full-text search across notes, payees, and categories
|
||||
- Category filter chips for quick narrowing
|
||||
- Tag-based filtering
|
||||
- Day headers with date labels ("Today", "Yesterday", "Feb 27")
|
||||
- Per-transaction display: category icon, name, amount (color-coded green/red), note
|
||||
- Daily subtotals showing income, expenses, and net
|
||||
- Selection mode for batch delete operations
|
||||
- Inline edit and delete per transaction
|
||||
- Spending anomaly banners when unusual patterns are detected
|
||||
|
||||
Every transaction you have ever logged is here, organized chronologically. Nothing hidden, nothing summarized away. Your complete financial record, accessible and transparent.
|
||||
|
||||
### 📊 Charts
|
||||
|
||||
Visual breakdowns of where your money goes and comes from.
|
||||
|
||||
- **Donut chart**: Category breakdown for the selected month, color-coded, with interactive segments
|
||||
- **Bar chart**: Side-by-side monthly income vs expense comparison over time
|
||||
- **Net trend line**: Smooth 12-month running balance curve using monotone Hermite interpolation (no jagged lines, no Bezier overshoot)
|
||||
- Month and range selector for all chart types
|
||||
- Theme-aware rendering (adapts to light and dark modes)
|
||||
- Touch-friendly interactions
|
||||
|
||||
The charts use Cairo for rendering directly onto GTK DrawingAreas. The smoothing algorithm is Fritsch-Carlson monotone cubic Hermite interpolation, which guarantees the curve passes through every data point without overshooting between them.
|
||||
|
||||
### 💰 Budgets
|
||||
|
||||
Set spending limits per category and track progress through the month.
|
||||
|
||||
- Per-category budget amounts with visual progress bars
|
||||
- Color coding: green (on track), yellow (75%+), red (over budget)
|
||||
- Monthly overview card showing total budgeted vs total spent
|
||||
- Sparkline mini-charts showing 4-month spending trend per category
|
||||
- Budget rollover support (carry unused amounts to next month)
|
||||
- Daily pace indicator: your actual daily spend vs what you can afford per day
|
||||
- "Safe to spend" calculation
|
||||
- Copy budgets from previous month for quick setup
|
||||
- Three budget cycle modes:
|
||||
- **Calendar**: Standard month (1st to last day)
|
||||
- **Payday**: Custom start date (e.g. the 15th)
|
||||
- **Rolling**: 30-day sliding window
|
||||
- Budget threshold notifications at 75%, 90%, and 100%
|
||||
|
||||
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
|
||||
|
||||
Automate regular income and expenses.
|
||||
|
||||
- Define transactions that repeat: daily, weekly, biweekly, monthly, or yearly
|
||||
- Set start and optional end dates
|
||||
- Mark items as bills with configurable reminder days
|
||||
- Pause and resume with optional resume date
|
||||
- Active and paused sections displayed separately
|
||||
- Linked to subscriptions for bidirectional sync (pause one, the other pauses too)
|
||||
- Auto-generation on app launch catches up on any missed occurrences
|
||||
- Toast notification: "Added N recurring transaction(s)"
|
||||
|
||||
When you open Outlay after being away for a week, it quietly generates all the recurring transactions that would have occurred. No manual intervention needed. The system respects end dates, pause states, and frequency rules.
|
||||
|
||||
### 🔔 Subscriptions
|
||||
|
||||
Track your subscription services separately from raw recurring transactions.
|
||||
|
||||
- Active and paused subscription lists
|
||||
- Monthly cost summary across all active subscriptions
|
||||
- Yearly cost projection
|
||||
- Per-subscription details: name, amount, frequency, currency, next due date
|
||||
- Add, edit, delete, and pause/resume controls
|
||||
- Bidirectional link with recurring transactions:
|
||||
- Pausing a subscription pauses its linked recurring transaction
|
||||
- Deleting either cascades properly (no orphaned records, no constraint errors)
|
||||
- Status changes propagate instantly between views
|
||||
|
||||
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
|
||||
|
||||
Set targets and track your progress toward them.
|
||||
|
||||
- Create goals with a name, target amount, currency, and optional deadline
|
||||
- Track current savings against the target with progress bars
|
||||
- Color and icon customization per goal
|
||||
- Active and completed goal sections
|
||||
- Mark goals as complete when reached
|
||||
- Achievement unlocks tied to goal completion milestones
|
||||
|
||||
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
|
||||
|
||||
Keep tabs on credit card balances and utilization.
|
||||
|
||||
- Track multiple cards with individual limits and balances
|
||||
- Statement close day and payment due day per card
|
||||
- Utilization percentage calculation and display
|
||||
- Color-coded card entries
|
||||
- Active/inactive toggle
|
||||
- Total balance and total limit summary cards
|
||||
|
||||
This is a straightforward tracking tool - enter your balances manually to maintain awareness of where you stand. No bank API integration, no sharing credentials with third parties.
|
||||
|
||||
### 🛒 Wishlist
|
||||
|
||||
A place for things you want but have not bought yet.
|
||||
|
||||
- Item name, estimated cost, optional category and URL
|
||||
- Priority levels for ordering
|
||||
- Purchase status toggle
|
||||
- Total wishlist value calculation
|
||||
|
||||
The wishlist is a cooling-off buffer. Writing something down instead of buying it immediately is one of the simplest ways to reduce impulse spending. If you still want it in a week, it will be here waiting. No algorithmic nudges pushing you to buy.
|
||||
|
||||
### 🔮 Forecast
|
||||
|
||||
Project your future cash flow based on existing patterns.
|
||||
|
||||
- Projections based on recurring transaction schedules
|
||||
- 3-month average spending calculations
|
||||
- Income projections from recurring income sources
|
||||
- Estimated balance for upcoming months (3, 6, and 12 month horizons)
|
||||
- Monthly net forecast: expected income minus expected expenses
|
||||
- Visual comparison of projected income vs expenses
|
||||
|
||||
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
|
||||
|
||||
Analytics and light gamification to encourage healthy financial habits.
|
||||
|
||||
**Streaks:**
|
||||
- No-spend streak: consecutive days without logging an expense
|
||||
- Budget streak: consecutive months finishing under budget
|
||||
- Current vs personal best tracking
|
||||
|
||||
**Achievements (16+ badges):**
|
||||
- First Transaction
|
||||
- 7-Day and 30-Day No-Spend streaks
|
||||
- 1, 3, 6, and 12 Months Under Budget
|
||||
- First Goal Completed, 5 Goals Completed
|
||||
- 100, 500, and 1000 Transactions logged
|
||||
- First Recurring Transaction set up
|
||||
- Big Saver (50%+ savings rate in a month)
|
||||
- Category Master (10+ categories used)
|
||||
- Streak Champion (14+ day no-spend streak)
|
||||
- Year Under Budget
|
||||
|
||||
**Monthly Recap:**
|
||||
- Category-by-category spending breakdown with percentages
|
||||
- Total income, expenses, and net for the month
|
||||
- Transaction count
|
||||
- Month-over-month and year-over-year change indicators
|
||||
- Year and month navigation
|
||||
|
||||
**Anomaly Detection:**
|
||||
- Automatic flagging of unusual spending patterns
|
||||
- Category-specific insights ("You spent 200% of your average in Groceries this month")
|
||||
- Interactive links to filtered history for investigation
|
||||
|
||||
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
|
||||
|
||||
A visual grid showing daily spending intensity.
|
||||
|
||||
- Month calendar layout with day-of-week headers
|
||||
- Each cell colored by spending amount (darker green = higher spending)
|
||||
- Click any day to see its transactions
|
||||
- Daily totals calculated automatically
|
||||
|
||||
A quick way to spot patterns - maybe you spend more on weekends, or there is a consistent spike mid-month. The heatmap makes these rhythms visible at a glance.
|
||||
|
||||
### ⚙️ Settings
|
||||
|
||||
Configure Outlay to work the way you need.
|
||||
|
||||
- **Theme**: System, Light, or Dark mode
|
||||
- **Base currency**: Set your home currency for conversions and reports
|
||||
- **Categories**: Add, edit, reorder, and manage expense/income categories with icons and colors
|
||||
- **Notifications**: Toggle recurring transaction alerts, budget warnings, and bill reminders
|
||||
- **Backup**: Create and restore full-state `.outlay` backup archives
|
||||
- **Export**: Generate PDF reports, CSV spreadsheets, and JSON data dumps
|
||||
- **Import**: Bring in data from CSV, JSON, OFX, QIF, and PDF bank statements
|
||||
|
||||
---
|
||||
|
||||
## 📤 Import and export
|
||||
|
||||
Outlay believes your data should flow freely. No lock-in, no proprietary formats, no friction for leaving.
|
||||
|
||||
### Export formats
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| **CSV** | Standard comma-separated values. Opens in any spreadsheet. Date, category, amount, currency, note, type. |
|
||||
| **JSON** | Complete structured export of the entire database. Includes schema version and all tables. Ideal for programmatic access or full migration. |
|
||||
| **OFX** | Open Financial Exchange. Bank-compatible format for importing into other financial tools. |
|
||||
| **QIF** | Quicken Interchange Format. Legacy compatibility with older financial software. |
|
||||
| **PDF** | Formatted monthly financial report with summary, category breakdown, top expenses, and budget comparison. |
|
||||
|
||||
### Import formats
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| **CSV** | Configurable column mapping. Auto-detects categories by name, creates missing ones as needed. |
|
||||
| **JSON** | Restore from a previous JSON export. Merge into existing data or replace entirely. Schema validation included. |
|
||||
| **OFX** | Parse bank statements in OFX format. Multi-currency support. |
|
||||
| **QIF** | Import from Quicken or other apps that export QIF. |
|
||||
| **PDF** | Extract transactions from PDF bank statements using text parsing. Multi-page support. |
|
||||
|
||||
### Full backup
|
||||
|
||||
The `.outlay` backup format is a ZIP archive containing:
|
||||
|
||||
- `outlay.db` - Complete SQLite database
|
||||
- `settings.json` - All application settings
|
||||
- `meta.json` - App version, export date, schema version
|
||||
|
||||
Create a backup, copy it to another machine, restore it, and you have an exact replica of your entire financial history. Your data is portable because it should be - you should never feel trapped by the tools you use.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Multi-currency support
|
||||
|
||||
Outlay supports 40+ currencies with automatic exchange rate conversion.
|
||||
|
||||
<details>
|
||||
<summary>Supported currencies</summary>
|
||||
|
||||
| Code | Currency |
|
||||
|------|----------|
|
||||
| USD | US Dollar |
|
||||
| EUR | Euro |
|
||||
| GBP | British Pound |
|
||||
| JPY | Japanese Yen |
|
||||
| CAD | Canadian Dollar |
|
||||
| AUD | Australian Dollar |
|
||||
| CHF | Swiss Franc |
|
||||
| CNY | Chinese Yuan |
|
||||
| INR | Indian Rupee |
|
||||
| BRL | Brazilian Real |
|
||||
| MXN | Mexican Peso |
|
||||
| ARS | Argentine Peso |
|
||||
| CLP | Chilean Peso |
|
||||
| COP | Colombian Peso |
|
||||
| PEN | Peruvian Sol |
|
||||
| UYU | Uruguayan Peso |
|
||||
| SGD | Singapore Dollar |
|
||||
| HKD | Hong Kong Dollar |
|
||||
| KRW | South Korean Won |
|
||||
| THB | Thai Baht |
|
||||
| TWD | New Taiwan Dollar |
|
||||
| PHP | Philippine Peso |
|
||||
| MYR | Malaysian Ringgit |
|
||||
| IDR | Indonesian Rupiah |
|
||||
| VND | Vietnamese Dong |
|
||||
| PKR | Pakistani Rupee |
|
||||
| BDT | Bangladeshi Taka |
|
||||
| SEK | Swedish Krona |
|
||||
| NOK | Norwegian Krone |
|
||||
| DKK | Danish Krone |
|
||||
| PLN | Polish Zloty |
|
||||
| CZK | Czech Koruna |
|
||||
| HUF | Hungarian Forint |
|
||||
| RON | Romanian Leu |
|
||||
| BGN | Bulgarian Lev |
|
||||
| HRK | Croatian Kuna |
|
||||
| RUB | Russian Ruble |
|
||||
| ZAR | South African Rand |
|
||||
| EGP | Egyptian Pound |
|
||||
| NGN | Nigerian Naira |
|
||||
| KES | Kenyan Shilling |
|
||||
| TRY | Turkish Lira |
|
||||
| ILS | Israeli Shekel |
|
||||
| SAR | Saudi Riyal |
|
||||
| AED | UAE Dirham |
|
||||
| QAR | Qatari Riyal |
|
||||
|
||||
</details>
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Set your base currency in Settings
|
||||
- When logging a transaction in a different currency, the exchange rate is fetched automatically
|
||||
- Primary rate source: [fawazahmed0/exchange-api](https://github.com/fawazahmed0/exchange-api) (free, open, no API key required, no rate limits)
|
||||
- Fallback source: [Frankfurter](https://www.frankfurter.app/) (also free and open)
|
||||
- Rates are cached in the database for 24 hours
|
||||
- Every transaction stores the exchange rate at the time of entry, preserving historical accuracy
|
||||
- Reports and charts convert everything to your base currency
|
||||
|
||||
The exchange rate sources are free and open services run by volunteers. No corporate API keys, no billing tiers, no rate limiting designed to push you toward a paid plan.
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Notifications
|
||||
|
||||
Outlay uses native GNOME desktop notifications to keep you informed without being intrusive.
|
||||
|
||||
- **Budget alerts**: Warnings at 75%, 90%, and 100%+ of your budget threshold per category
|
||||
- **Bill reminders**: Configurable advance notice (e.g. 3 days before due) for bills marked in recurring transactions
|
||||
- **Recurring generation**: Confirmation when recurring transactions are auto-generated on launch
|
||||
- **Weekly digest**: Summary of spending if 7+ days since last digest
|
||||
- **Backup reminder**: Gentle nudge if 30+ days since last backup and you have 50+ transactions
|
||||
- **Achievement unlocks**: Toast notification when you earn a new badge
|
||||
- **Spending anomalies**: Alert on startup if unusual spending patterns are detected
|
||||
|
||||
Notifications are de-duplicated (you will not get the same budget warning twice) and can be toggled per type in Settings. They exist to help you stay aware, not to create anxiety or generate "engagement."
|
||||
|
||||
---
|
||||
|
||||
## 🗔 System tray
|
||||
|
||||
Outlay integrates with the freedesktop.org system tray specification via DBus.
|
||||
|
||||
- Minimize to tray instead of quitting
|
||||
- Tray menu options:
|
||||
- **Show Outlay** - Restore the main window
|
||||
- **Quick Add** - Popup for rapid transaction entry without opening the full app
|
||||
- **Log Expense** - Open directly to expense entry
|
||||
- **Log Income** - Open directly to income entry
|
||||
- **Quit** - Actually close the application
|
||||
|
||||
The Quick Add popup is designed for those moments when you just bought something and want to log it in five seconds without switching context.
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Keyboard and workflow
|
||||
|
||||
The interface follows standard GTK4 and GNOME conventions.
|
||||
|
||||
- Amount field auto-focuses on the Log view for immediate typing
|
||||
- Inline arithmetic in the amount field: type `24.99+tax` or `100/3` and the result is calculated
|
||||
- Expression evaluator supports `+`, `-`, `*`, `/`, and parentheses
|
||||
- Standard keyboard navigation throughout (Tab, Enter, Escape)
|
||||
- Adaptive layout: sidebar collapses to a stack on narrow windows
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
Outlay is structured as a Cargo workspace with two crates:
|
||||
|
||||
```
|
||||
outlay/
|
||||
outlay-core/ # Library crate - all business logic
|
||||
src/
|
||||
lib.rs # Module declarations
|
||||
models.rs # Data structures and enums
|
||||
db.rs # SQLite operations, migrations, queries
|
||||
exchange.rs # Currency conversion service
|
||||
recurring.rs # Recurring transaction generator
|
||||
notifications.rs # Alert and reminder system
|
||||
backup.rs # Full-state backup/restore
|
||||
nlp.rs # Natural language transaction parsing
|
||||
ocr.rs # Receipt image scanning
|
||||
expr.rs # Arithmetic expression evaluator
|
||||
sankey.rs # Sankey diagram layout engine
|
||||
seed.rs # Demo data generator
|
||||
export_csv.rs # CSV export
|
||||
export_json.rs # JSON export
|
||||
export_ofx.rs # OFX export
|
||||
export_qif.rs # QIF export
|
||||
export_pdf.rs # PDF report generation
|
||||
import_csv.rs # CSV import
|
||||
import_json.rs # JSON import
|
||||
import_ofx.rs # OFX import
|
||||
import_qif.rs # QIF import
|
||||
import_pdf.rs # PDF statement import
|
||||
bin/
|
||||
seed_demo.rs # Demo data seeder binary
|
||||
outlay-gtk/ # Binary crate - GTK4 user interface
|
||||
src/
|
||||
main.rs # Application entry, initialization, notifications
|
||||
window.rs # Main window scaffold, navigation, view wiring
|
||||
log_view.rs # Transaction entry form
|
||||
history_view.rs # Transaction browsing and filtering
|
||||
charts_view.rs # Financial charts and visualization
|
||||
budgets_view.rs # Budget management and progress
|
||||
recurring_view.rs # Recurring transaction management
|
||||
subscriptions_view.rs # Subscription tracking
|
||||
goals_view.rs # Savings goal management
|
||||
credit_cards_view.rs # Credit card tracking
|
||||
wishlist_view.rs # Wishlist management
|
||||
forecast_view.rs # Cash flow projections
|
||||
insights_view.rs # Analytics, achievements, streaks
|
||||
calendar_view.rs # Spending heatmap
|
||||
settings_view.rs # Configuration interface
|
||||
quick_add.rs # Tray popup for rapid entry
|
||||
edit_dialog.rs # Transaction editing dialog
|
||||
date_picker.rs # Calendar date selector
|
||||
category_combo.rs # Filterable category dropdown
|
||||
numpad.rs # On-screen calculator keypad
|
||||
month_nav.rs # Month navigation controls
|
||||
sparkline.rs # Mini trend line widget
|
||||
icon_theme.rs # Tabler icon integration
|
||||
tray.rs # System tray integration
|
||||
style.css # Application stylesheet
|
||||
data/
|
||||
fonts/ # Bundled typefaces
|
||||
icons/ # App icon (all sizes) + action icons
|
||||
build.rs # Build-time resource compilation
|
||||
scripts/
|
||||
build-appimage.sh # AppImage packaging script
|
||||
docs/
|
||||
plans/ # Design documentation
|
||||
```
|
||||
|
||||
### Key technical decisions
|
||||
|
||||
- **Raw GTK4 bindings** instead of a framework like Relm4. More verbose, but no hidden abstractions.
|
||||
- **SQLite with WAL mode** for reliable concurrent reads during UI updates.
|
||||
- **Foreign keys enforced** at the database level. Cascade deletes are explicit in application code to maintain control over what gets removed.
|
||||
- **Schema migrations** built into the database module. The app upgrades the database automatically on launch.
|
||||
- **Exchange rates cached locally** to minimize network requests. Only fetched when needed, only re-fetched after 24 hours.
|
||||
- **All chart rendering via Cairo** on GTK DrawingAreas. No web views, no JavaScript, no Electron.
|
||||
- **Monotone Hermite interpolation** for chart curves. Guarantees smooth lines that pass through every data point without overshooting.
|
||||
|
||||
### Dependencies
|
||||
|
||||
The core dependencies, all freely licensed:
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `gtk4` | GUI toolkit |
|
||||
| `libadwaita` | GNOME UI patterns |
|
||||
| `rusqlite` | SQLite bindings (bundled) |
|
||||
| `cairo-rs` | 2D rendering for charts |
|
||||
| `reqwest` | HTTP client for exchange rates |
|
||||
| `tokio` | Async runtime |
|
||||
| `serde` / `serde_json` | Serialization |
|
||||
| `chrono` | Date and time handling |
|
||||
| `genpdf` | PDF report generation |
|
||||
| `pdf-extract` | PDF statement parsing |
|
||||
| `csv` | CSV reading/writing |
|
||||
| `zip` | Backup archive creation |
|
||||
| `ksni` | System tray (freedesktop DBus) |
|
||||
| `rand` | Demo data generation |
|
||||
| `thiserror` | Error type definitions |
|
||||
|
||||
---
|
||||
|
||||
## 💾 Data storage
|
||||
|
||||
All data lives in a single SQLite file:
|
||||
|
||||
```
|
||||
~/.local/share/outlay/outlay.db
|
||||
```
|
||||
|
||||
The database contains 19 tables covering transactions, categories, budgets, recurring items, subscriptions, goals, credit cards, wishlist items, tags, splits, templates, rules, streaks, achievements, attachments, exchange rates, notifications, and settings.
|
||||
|
||||
**There is no cloud component.** Your financial history never leaves your machine unless you explicitly export or back it up. This is by design - financial data is deeply personal, and keeping it local is the most straightforward way to ensure it stays under your control.
|
||||
|
||||
Schema migrations happen automatically. When a new version adds tables or columns, the app detects the current schema version and applies the necessary changes on launch.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Packaging
|
||||
|
||||
### AppImage
|
||||
|
||||
The recommended distribution format. Built on Ubuntu 22.04 for broad glibc compatibility.
|
||||
|
||||
The AppImage bundles:
|
||||
- GTK4 and libadwaita libraries
|
||||
- GLib schemas and Adwaita theme
|
||||
- Bundled fonts (JetBrains Mono for the UI, Liberation Sans for PDF reports)
|
||||
- All icon assets
|
||||
- Optional: Tesseract OCR engine and language data
|
||||
|
||||
Build it yourself:
|
||||
|
||||
```bash
|
||||
./scripts/build-appimage.sh
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The AppImage sets:
|
||||
- `GDK_BACKEND=wayland,x11` - Wayland-first with X11 fallback
|
||||
- Font configuration for bundled typefaces
|
||||
- GSettings schema paths for desktop integration
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Outlay is public domain software. There are no contributor license agreements, no copyright assignment forms, and no corporate ownership to navigate. If you want to help, help. If you want to fork it and take it in a different direction, do that. The code belongs to everyone equally.
|
||||
|
||||
Some ways to contribute:
|
||||
|
||||
- **Report bugs**: Open an issue describing what happened and how to reproduce it
|
||||
- **Suggest features**: Ideas are welcome, especially ones that help people manage money without being patronizing about it
|
||||
- **Submit patches**: Fork, branch, make your changes, open a pull request
|
||||
- **Translate**: The UI strings could use localization for non-English speakers
|
||||
- **Test**: Run it on different distributions, window managers, and screen sizes
|
||||
- **Share**: Tell someone who might find it useful
|
||||
|
||||
There is no formal governance structure. Good ideas get merged. Decisions are made by the people doing the work.
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
<p align="center">
|
||||
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
|
||||
<img src="https://img.shields.io/badge/License-CC0_1.0_Universal-brightgreen?style=for-the-badge" alt="CC0">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Outlay is released under the **CC0 1.0 Universal** public domain dedication.
|
||||
|
||||
To the extent possible under law, the author has waived all copyright and related or neighboring rights to this work. You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
|
||||
|
||||
This is not "open source with conditions." This is public domain. It belongs to no one and everyone simultaneously. Use it however you see fit.
|
||||
|
||||
See [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) for the full legal text.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
Built with care for people who want to understand where their money goes.<br>
|
||||
No tracking. No ads. No subscriptions. No data collection. No corporate interests.<br>
|
||||
Just software that belongs to the commons.<br>
|
||||
<i>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</i>
|
||||
</sub>
|
||||
</p>
|
||||
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 39 KiB |
@@ -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
|
||||
@@ -67,9 +67,11 @@ pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, Ba
|
||||
rusqlite::params![temp_db_path.to_str().unwrap()],
|
||||
)?;
|
||||
|
||||
// Read the vacuumed database
|
||||
let db_bytes = std::fs::read(&temp_db_path)?;
|
||||
std::fs::remove_file(&temp_db_path)?;
|
||||
|
||||
// Gather metadata
|
||||
let txn_count: i64 = db.conn.query_row(
|
||||
"SELECT COUNT(*) FROM transactions",
|
||||
[],
|
||||
@@ -92,6 +94,7 @@ pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, Ba
|
||||
|
||||
let meta_json = serde_json::to_string_pretty(&meta)?;
|
||||
|
||||
// Create ZIP archive
|
||||
let file = std::fs::File::create(output_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
@@ -138,6 +141,7 @@ pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta,
|
||||
meta
|
||||
};
|
||||
|
||||
// Extract database
|
||||
let mut db_file = archive.by_name("outlay.db").map_err(|_| {
|
||||
BackupError::InvalidBackup("outlay.db not found in backup".to_string())
|
||||
})?;
|
||||
@@ -145,6 +149,7 @@ pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta,
|
||||
let mut db_bytes = Vec::new();
|
||||
db_file.read_to_end(&mut db_bytes)?;
|
||||
|
||||
// Write the restored database
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ impl Database {
|
||||
}
|
||||
|
||||
if self.get_schema_version()? < 7 {
|
||||
// Add is_bill and reminder_days to recurring_transactions
|
||||
let has_is_bill: bool = self.conn
|
||||
.prepare("SELECT is_bill FROM recurring_transactions LIMIT 0")
|
||||
.is_ok();
|
||||
@@ -997,12 +998,14 @@ impl Database {
|
||||
pub fn get_safe_to_spend(&self, month: &str) -> SqlResult<(f64, f64, i32)> {
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
|
||||
// Total budget for this month
|
||||
let total_budget: f64 = self.conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount), 0.0) FROM budgets WHERE month = ?1",
|
||||
params![month],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Total spent this month
|
||||
let total_spent: f64 = self.conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount * exchange_rate), 0.0) FROM transactions
|
||||
WHERE date LIKE ?1 AND type = 'expense'",
|
||||
@@ -1010,10 +1013,12 @@ impl Database {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Compute days left in the month
|
||||
let today = Local::now().date_naive();
|
||||
let year: i32 = month[..4].parse().unwrap_or(today.year());
|
||||
let mon: u32 = month[5..7].parse().unwrap_or(today.month());
|
||||
|
||||
// Last day of month
|
||||
let last_day = if mon == 12 {
|
||||
NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
@@ -1113,6 +1118,7 @@ impl Database {
|
||||
"UPDATE recurring_transactions SET active = ?1, resume_date = NULL WHERE id = ?2",
|
||||
params![active, id],
|
||||
)?;
|
||||
// Sync linked subscription
|
||||
self.conn.execute(
|
||||
"UPDATE subscriptions SET active = ?1 WHERE recurring_id = ?2",
|
||||
params![active, id],
|
||||
@@ -1125,6 +1131,7 @@ impl Database {
|
||||
"UPDATE recurring_transactions SET active = 0, resume_date = ?1 WHERE id = ?2",
|
||||
params![resume_date.map(|d| d.format("%Y-%m-%d").to_string()), id],
|
||||
)?;
|
||||
// Sync linked subscription
|
||||
self.conn.execute(
|
||||
"UPDATE subscriptions SET active = 0 WHERE recurring_id = ?1",
|
||||
params![id],
|
||||
@@ -1715,6 +1722,7 @@ impl Database {
|
||||
"UPDATE transactions SET recurring_id = NULL WHERE recurring_id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
// Delete linked subscriptions
|
||||
self.conn.execute(
|
||||
"DELETE FROM subscriptions WHERE recurring_id = ?1",
|
||||
params![id],
|
||||
@@ -1733,6 +1741,7 @@ impl Database {
|
||||
(SELECT id FROM recurring_transactions WHERE subscription_id = ?1)",
|
||||
params![id],
|
||||
)?;
|
||||
// Delete linked recurring transactions
|
||||
self.conn.execute(
|
||||
"DELETE FROM recurring_transactions WHERE subscription_id = ?1",
|
||||
params![id],
|
||||
@@ -1750,8 +1759,10 @@ impl Database {
|
||||
sub_cat_id: i64,
|
||||
name: &str,
|
||||
) -> SqlResult<i64> {
|
||||
// Insert the recurring transaction first
|
||||
let rec_id = self.insert_recurring(rec)?;
|
||||
|
||||
// Create a matching subscription linked to it
|
||||
let sub = NewSubscription {
|
||||
name: name.to_string(),
|
||||
amount: rec.amount,
|
||||
@@ -1765,6 +1776,7 @@ impl Database {
|
||||
};
|
||||
let sub_id = self.insert_subscription(&sub)?;
|
||||
|
||||
// Link back: recurring -> subscription
|
||||
self.conn.execute(
|
||||
"UPDATE recurring_transactions SET subscription_id = ?1 WHERE id = ?2",
|
||||
params![sub_id, rec_id],
|
||||
@@ -1778,8 +1790,10 @@ impl Database {
|
||||
sub: &NewSubscription,
|
||||
expense_cat_id: i64,
|
||||
) -> SqlResult<i64> {
|
||||
// Insert the subscription first
|
||||
let sub_id = self.insert_subscription(sub)?;
|
||||
|
||||
// Create a matching recurring transaction linked to it
|
||||
let rec = NewRecurringTransaction {
|
||||
amount: sub.amount,
|
||||
transaction_type: TransactionType::Expense,
|
||||
@@ -1795,6 +1809,7 @@ impl Database {
|
||||
};
|
||||
let rec_id = self.insert_recurring(&rec)?;
|
||||
|
||||
// Link back: subscription -> recurring
|
||||
self.conn.execute(
|
||||
"UPDATE subscriptions SET recurring_id = ?1 WHERE id = ?2",
|
||||
params![rec_id, sub_id],
|
||||
@@ -3511,6 +3526,7 @@ mod tests {
|
||||
|
||||
db.set_budget(cat_id, "2026-03", 500.0, false).unwrap();
|
||||
|
||||
// Add some expenses
|
||||
let txn = NewTransaction {
|
||||
amount: 200.0,
|
||||
transaction_type: TransactionType::Expense,
|
||||
|
||||
@@ -40,11 +40,13 @@ impl<'a> ExchangeRateService<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh rates
|
||||
let rates = match self.fetch_from_primary(&base_lower).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => self.fetch_from_fallback(&base_lower).await?,
|
||||
};
|
||||
|
||||
// Cache all fetched rates
|
||||
self.db.cache_rates(&base_lower, &rates)?;
|
||||
|
||||
rates
|
||||
|
||||
@@ -67,6 +67,7 @@ pub fn export_ofx<W: Write>(
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
// OFX SGML headers
|
||||
writeln!(writer, "OFXHEADER:100")?;
|
||||
writeln!(writer, "DATA:OFXSGML")?;
|
||||
writeln!(writer, "VERSION:160")?;
|
||||
@@ -78,6 +79,7 @@ pub fn export_ofx<W: Write>(
|
||||
writeln!(writer, "NEWFILEUID:NONE")?;
|
||||
writeln!(writer)?;
|
||||
|
||||
// OFX body
|
||||
writeln!(writer, "<OFX>")?;
|
||||
writeln!(writer, "<SIGNONMSGSRSV1>")?;
|
||||
writeln!(writer, "<SONRS>")?;
|
||||
|
||||
@@ -77,6 +77,7 @@ pub fn generate_monthly_report(
|
||||
decorator.set_margins(15);
|
||||
doc.set_page_decorator(decorator);
|
||||
|
||||
// Title
|
||||
let title = format!(
|
||||
"Outlay - {} {} Report",
|
||||
month_name(month),
|
||||
@@ -85,12 +86,14 @@ pub fn generate_monthly_report(
|
||||
doc.push(elements::Paragraph::new(&title).styled(style::Style::new().bold().with_font_size(18)));
|
||||
doc.push(elements::Break::new(1.5));
|
||||
|
||||
// Fetch data
|
||||
let month_str = format!("{:04}-{:02}", year, month);
|
||||
let transactions = db.list_transactions_by_month(year, month)?;
|
||||
|
||||
let mut total_income = 0.0_f64;
|
||||
let mut total_expenses = 0.0_f64;
|
||||
|
||||
// Category breakdown
|
||||
let mut category_totals: std::collections::HashMap<i64, (String, f64)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
@@ -115,6 +118,7 @@ pub fn generate_monthly_report(
|
||||
|
||||
let net = total_income - total_expenses;
|
||||
|
||||
// Summary section
|
||||
doc.push(elements::Paragraph::new("Summary").styled(style::Style::new().bold().with_font_size(14)));
|
||||
doc.push(elements::Break::new(0.5));
|
||||
|
||||
@@ -132,6 +136,7 @@ pub fn generate_monthly_report(
|
||||
)));
|
||||
doc.push(elements::Break::new(1.0));
|
||||
|
||||
// Category breakdown table
|
||||
if !category_totals.is_empty() {
|
||||
doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled(
|
||||
style::Style::new().bold().with_font_size(14),
|
||||
@@ -141,6 +146,7 @@ pub fn generate_monthly_report(
|
||||
let mut table = elements::TableLayout::new(vec![3, 2, 1]);
|
||||
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
|
||||
|
||||
// Header
|
||||
table
|
||||
.row()
|
||||
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
|
||||
@@ -171,6 +177,7 @@ pub fn generate_monthly_report(
|
||||
doc.push(elements::Break::new(1.0));
|
||||
}
|
||||
|
||||
// Top 5 expenses
|
||||
let mut expense_txns: Vec<_> = transactions
|
||||
.iter()
|
||||
.filter(|t| t.transaction_type == TransactionType::Expense)
|
||||
@@ -220,6 +227,7 @@ pub fn generate_monthly_report(
|
||||
doc.push(elements::Break::new(1.0));
|
||||
}
|
||||
|
||||
// Budget vs actual
|
||||
let budgets = db.list_budgets_for_month(&month_str)?;
|
||||
if !budgets.is_empty() {
|
||||
doc.push(elements::Paragraph::new("Budget vs Actual").styled(
|
||||
@@ -261,6 +269,7 @@ pub fn generate_monthly_report(
|
||||
doc.push(table);
|
||||
}
|
||||
|
||||
// Footer
|
||||
doc.push(elements::Break::new(1.5));
|
||||
doc.push(
|
||||
elements::Paragraph::new(format!("Generated by Outlay on {}", chrono::Local::now().format("%Y-%m-%d %H:%M")))
|
||||
@@ -292,6 +301,7 @@ mod tests {
|
||||
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
|
||||
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
|
||||
|
||||
// Add sample transactions
|
||||
let txns = vec![
|
||||
NewTransaction {
|
||||
amount: 45.0,
|
||||
@@ -332,6 +342,7 @@ mod tests {
|
||||
db.insert_transaction(txn).unwrap();
|
||||
}
|
||||
|
||||
// Set a budget
|
||||
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
|
||||
|
||||
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
|
||||
@@ -341,6 +352,7 @@ mod tests {
|
||||
let metadata = std::fs::metadata(&tmp).unwrap();
|
||||
assert!(metadata.len() > 100); // PDF should be non-trivial size
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ pub fn parse_transaction(input: &str, categories: &[Category]) -> Option<ParsedT
|
||||
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// Exact match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase() == query_lower)
|
||||
@@ -112,6 +113,7 @@ fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&
|
||||
return Some(cat);
|
||||
}
|
||||
|
||||
// Prefix match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase().starts_with(&query_lower))
|
||||
@@ -119,6 +121,7 @@ fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&
|
||||
return Some(cat);
|
||||
}
|
||||
|
||||
// Contains match
|
||||
if let Some(cat) = categories
|
||||
.iter()
|
||||
.find(|c| c.name.to_lowercase().contains(&query_lower))
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn compute_sankey_layout(
|
||||
|
||||
let padding = 4.0;
|
||||
|
||||
// Layout left (income) nodes
|
||||
let mut left_nodes = Vec::new();
|
||||
let mut y = 0.0;
|
||||
let income_count = income_sources.len().max(1);
|
||||
@@ -34,6 +35,7 @@ pub fn compute_sankey_layout(
|
||||
y += h + padding;
|
||||
}
|
||||
|
||||
// Layout right (expense) nodes
|
||||
let mut right_nodes = Vec::new();
|
||||
y = 0.0;
|
||||
let expense_count = expense_categories.len().max(1);
|
||||
@@ -51,9 +53,11 @@ pub fn compute_sankey_layout(
|
||||
y += h + padding;
|
||||
}
|
||||
|
||||
// Center node (net/available)
|
||||
let center_height = (total_income / max_side) * available_left;
|
||||
let center_y = 0.0;
|
||||
|
||||
// Flows from income -> center
|
||||
let mut flows_in = Vec::new();
|
||||
let mut from_y_cursor = 0.0;
|
||||
let mut to_y_cursor = 0.0;
|
||||
@@ -71,6 +75,7 @@ pub fn compute_sankey_layout(
|
||||
to_y_cursor += w;
|
||||
}
|
||||
|
||||
// Flows from center -> expenses
|
||||
let mut flows_out = Vec::new();
|
||||
let mut from_y_cursor = 0.0;
|
||||
let mut to_y_cursor = 0.0;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Outlay
|
||||
Comment=Personal income and expense tracker with budgets, charts, and multi-currency support
|
||||
Exec=outlay-gtk
|
||||
Icon=com.outlay.app
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Finance;GTK;
|
||||
Keywords=expense;budget;money;finance;income;tracker;currency;receipt;
|
||||
StartupNotify=true
|
||||
SingleMainWindow=true
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist gettext-domain="outlay">
|
||||
<schema id="com.outlay.app" path="/com/outlay/app/">
|
||||
<key name="window-width" type="i">
|
||||
<default>900</default>
|
||||
<summary>Window width</summary>
|
||||
<description>The width of the main application window.</description>
|
||||
</key>
|
||||
<key name="window-height" type="i">
|
||||
<default>600</default>
|
||||
<summary>Window height</summary>
|
||||
<description>The height of the main application window.</description>
|
||||
</key>
|
||||
<key name="window-maximized" type="b">
|
||||
<default>false</default>
|
||||
<summary>Window maximized</summary>
|
||||
<description>Whether the main application window is maximized.</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
@@ -1,196 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.outlay.app</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>CC0-1.0</project_license>
|
||||
|
||||
<name>Outlay</name>
|
||||
<summary>Personal income and expense tracker with budgets, charts, and multi-currency support</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Outlay is a native GTK4/libadwaita personal finance application for
|
||||
tracking income and expenses, managing budgets, and visualizing spending
|
||||
patterns. It stores everything locally in SQLite with no cloud dependency.
|
||||
</p>
|
||||
<p>Key features:</p>
|
||||
<ul>
|
||||
<li>Log expenses and income with category tagging and notes</li>
|
||||
<li>Multi-currency support with live exchange rate conversion</li>
|
||||
<li>Category-based budgets with threshold alerts and sparkline trends</li>
|
||||
<li>Recurring transactions with flexible scheduling (daily, weekly, monthly, yearly)</li>
|
||||
<li>Subscription tracking with pause, resume, and cost overview</li>
|
||||
<li>Interactive charts: donut breakdown, monthly bars, and smooth net trend lines</li>
|
||||
<li>Calendar heatmap view of daily spending</li>
|
||||
<li>Financial forecasting based on historical patterns</li>
|
||||
<li>Savings goals with progress tracking</li>
|
||||
<li>Credit card balance and payment tracking</li>
|
||||
<li>Wishlist with priority ranking and affordability estimates</li>
|
||||
<li>Spending anomaly detection and monthly insights</li>
|
||||
<li>Natural language transaction input</li>
|
||||
<li>Receipt OCR for automatic amount extraction</li>
|
||||
<li>Export to CSV, JSON, PDF, OFX, and QIF formats</li>
|
||||
<li>Import from CSV, JSON, OFX, QIF, and PDF bank statements</li>
|
||||
<li>Automatic and manual backup with restore</li>
|
||||
<li>System tray with quick-add actions</li>
|
||||
<li>Bill reminders and budget threshold notifications</li>
|
||||
<li>Achievement system for building financial habits</li>
|
||||
<li>Split transactions across multiple categories</li>
|
||||
<li>Transaction templates for frequent entries</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<icon type="stock">com.outlay.app</icon>
|
||||
|
||||
<launchable type="desktop-id">com.outlay.app.desktop</launchable>
|
||||
|
||||
<developer id="com.outlay">
|
||||
<name>Outlay Contributors</name>
|
||||
</developer>
|
||||
|
||||
<url type="homepage">https://git.lashman.live/lashman/outlay</url>
|
||||
<url type="bugtracker">https://git.lashman.live/lashman/outlay/issues</url>
|
||||
<url type="vcs-browser">https://git.lashman.live/lashman/outlay</url>
|
||||
<url type="donation">https://ko-fi.com/lashman</url>
|
||||
<url type="contact">https://git.lashman.live/lashman/outlay/issues</url>
|
||||
<url type="contribute">https://git.lashman.live/lashman/outlay</url>
|
||||
|
||||
<update_contact>lashman@robotbrush.com</update_contact>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Log view with numpad calculator for quick expense entry</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/01.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Transaction history with search, category filters, and daily totals</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/02.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Charts with donut category breakdown and monthly income vs expenses</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/03.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Budget tracking with sparkline trends, spending pace, and insights</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/04.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Savings goals with progress bars and monthly contribution targets</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/05.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Cash flow forecast with projected monthly balances</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/06.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recurring transactions with next due dates and amounts</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/07.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Subscription management with monthly and yearly cost overview</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/08.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Wishlist with wanted items, prices, and purchase tracking</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/09.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Credit card tracking with balances, utilization, and payment details</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/10.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Insights with streaks, achievements, and monthly spending recap</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/11.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Settings for currency, appearance, and budget notifications</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/12.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Category management, auto-categorization rules, and transaction templates</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/13.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Export and import in CSV, JSON, QIF, OFX, and PDF formats</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/14.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Backup and restore with automatic scheduled backups</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/15.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Quick Add popup for fast expense logging from the system tray</caption>
|
||||
<image type="source" width="461" height="650">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/16.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#62a0ea</color>
|
||||
<color type="primary" scheme_preference="dark">#1a5fb4</color>
|
||||
</branding>
|
||||
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Finance</category>
|
||||
<category>GTK</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>Expense</keyword>
|
||||
<keyword>Income</keyword>
|
||||
<keyword>Budget</keyword>
|
||||
<keyword>Finance</keyword>
|
||||
<keyword>Money</keyword>
|
||||
<keyword>Currency</keyword>
|
||||
<keyword>Receipt</keyword>
|
||||
<keyword>Tracker</keyword>
|
||||
<keyword>Chart</keyword>
|
||||
<keyword>Subscription</keyword>
|
||||
</keywords>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-info">mild</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<requires>
|
||||
<display_length compare="ge">360</display_length>
|
||||
</requires>
|
||||
|
||||
<recommends>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</recommends>
|
||||
|
||||
<supports>
|
||||
<internet>first-run</internet>
|
||||
</supports>
|
||||
|
||||
<provides>
|
||||
<binary>outlay-gtk</binary>
|
||||
</provides>
|
||||
|
||||
<translation type="gettext">com.outlay.app</translation>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-03-03" type="stable">
|
||||
<description>
|
||||
<p>Initial release of Outlay with core features:</p>
|
||||
<ul>
|
||||
<li>Expense and income logging with multi-currency support</li>
|
||||
<li>Category-based budgets with alerts and sparkline trends</li>
|
||||
<li>Recurring transactions and subscription management</li>
|
||||
<li>Interactive donut, bar, and net trend charts</li>
|
||||
<li>Calendar heatmap, forecast, goals, and wishlist views</li>
|
||||
<li>Credit card tracking with payment schedules</li>
|
||||
<li>Natural language input and receipt OCR</li>
|
||||
<li>Export to CSV, JSON, PDF, OFX, and QIF</li>
|
||||
<li>Import from CSV, JSON, OFX, QIF, and PDF statements</li>
|
||||
<li>Spending anomaly detection and monthly insights</li>
|
||||
<li>System tray integration with quick-add actions</li>
|
||||
<li>Automatic backup scheduling and restore</li>
|
||||
<li>Achievement system for financial habit building</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { background: transparent; width: 100%; height: 100%; }
|
||||
body {
|
||||
background-image:
|
||||
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.icon-wrapper {
|
||||
padding: 50px;
|
||||
filter: drop-shadow(0 8px 14px rgba(26, 95, 180, 0.45));
|
||||
}
|
||||
.icon-container {
|
||||
width: 512px;
|
||||
height: 512px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* GNOME-style rounded square - ~22% radius */
|
||||
border-radius: 22%;
|
||||
/* GNOME blue gradient - wider range for visible depth */
|
||||
background: linear-gradient(180deg, #62a0ea 0%, #1a5fb4 55%, #144a8a 100%);
|
||||
}
|
||||
/* Sharp top highlight + bottom shadow edges */
|
||||
.icon-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 22%;
|
||||
border-top: 4px solid rgba(255,255,255,0.2);
|
||||
border-bottom: 40px solid rgba(0,0,0,0.12);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.icon {
|
||||
font-size: 286px;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="icon-target">
|
||||
<i class="icon fa-solid fa-receipt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 746 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 679 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3584e4"/>
|
||||
<stop offset="100%" stop-color="#1a73e8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background circle -->
|
||||
<circle cx="64" cy="64" r="60" fill="url(#bg)"/>
|
||||
<!-- Wallet body -->
|
||||
<rect x="24" y="38" width="72" height="52" rx="8" fill="#ffffff" opacity="0.95"/>
|
||||
<!-- Wallet flap -->
|
||||
<path d="M24 50 Q24 38 36 38 L88 38 Q96 38 96 46 L96 50 Z" fill="#e8e8e8"/>
|
||||
<!-- Coin circle -->
|
||||
<circle cx="82" cy="64" r="14" fill="#f5c211" stroke="#e0a800" stroke-width="2"/>
|
||||
<!-- Dollar sign on coin -->
|
||||
<text x="82" y="70" font-family="sans-serif" font-size="18" font-weight="bold" fill="#8b6914" text-anchor="middle">$</text>
|
||||
<!-- Arrow out (expense indicator) -->
|
||||
<path d="M40 64 L54 54 L54 58 L66 58 L66 70 L54 70 L54 74 Z" fill="#2ec27e" opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1015 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=Outlay
|
||||
Comment=Personal income and expense logger
|
||||
Exec=outlay-gtk
|
||||
Icon=io.github.outlay
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Finance;
|
||||
Keywords=expense;budget;money;finance;income;
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="io.github.outlay" path="/io/github/outlay/">
|
||||
<key name="window-width" type="i">
|
||||
<default>900</default>
|
||||
</key>
|
||||
<key name="window-height" type="i">
|
||||
<default>600</default>
|
||||
</key>
|
||||
<key name="window-maximized" type="b">
|
||||
<default>false</default>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
@@ -142,14 +142,17 @@ impl BudgetsView {
|
||||
nav_box.append(&next_btn);
|
||||
nav_box.append(&cycle_btn);
|
||||
|
||||
// Summary
|
||||
let summary_label = gtk::Label::new(None);
|
||||
summary_label.add_css_class("dim-label");
|
||||
summary_label.set_halign(gtk::Align::Center);
|
||||
|
||||
// Budget list
|
||||
let budget_group = adw::PreferencesGroup::builder()
|
||||
.title("BUDGETS")
|
||||
.build();
|
||||
|
||||
// Add budget button
|
||||
let add_btn = gtk::Button::with_label("Add Budget");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
@@ -450,6 +453,7 @@ impl BudgetsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Add budget button
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let year_ref = current_year.clone();
|
||||
@@ -818,6 +822,7 @@ impl BudgetsView {
|
||||
year: i32,
|
||||
month: u32,
|
||||
) {
|
||||
// Clear existing rows
|
||||
let mut rows = Vec::new();
|
||||
Self::collect_action_rows(group.upcast_ref(), &mut rows);
|
||||
for row in &rows {
|
||||
@@ -1129,6 +1134,7 @@ impl BudgetsView {
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
// Category selector
|
||||
let cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap_or_default();
|
||||
@@ -1286,6 +1292,7 @@ impl BudgetsView {
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -1321,6 +1328,7 @@ impl BudgetsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -1376,6 +1384,7 @@ impl BudgetsView {
|
||||
if cap <= 0.0 {
|
||||
label.set_label("No monthly cap set");
|
||||
bar.set_value(0.0);
|
||||
// Remove old warning/error classes
|
||||
bar.remove_css_class("warning");
|
||||
bar.remove_css_class("error");
|
||||
return;
|
||||
|
||||
@@ -66,6 +66,7 @@ pub fn calendar_heatmap(db: &Database, year: i32, month: u32) -> gtk::Box {
|
||||
|
||||
container.append(&grid);
|
||||
|
||||
// Legend
|
||||
let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
legend.set_halign(gtk::Align::Center);
|
||||
legend.set_margin_top(4);
|
||||
|
||||
@@ -1170,18 +1170,22 @@ impl ChartsView {
|
||||
bx = bx.max(4.0);
|
||||
by = by.max(4.0);
|
||||
|
||||
// Shadow
|
||||
Self::rounded_rect(cr, bx + 2.0, by + 3.0, box_w, box_h, r);
|
||||
cr.set_source_rgba(0.0, 0.0, 0.0, 0.18);
|
||||
cr.fill().ok();
|
||||
|
||||
// Background
|
||||
Self::rounded_rect(cr, bx, by, box_w, box_h, r);
|
||||
cr.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
|
||||
cr.fill_preserve().ok();
|
||||
|
||||
// Border
|
||||
cr.set_source_rgba(tc.tooltip_border.0, tc.tooltip_border.1, tc.tooltip_border.2, tc.tooltip_border.3);
|
||||
cr.set_line_width(1.0);
|
||||
cr.stroke().ok();
|
||||
|
||||
// Text
|
||||
cr.set_source_rgb(tc.tooltip_text.0, tc.tooltip_text.1, tc.tooltip_text.2);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
cr.move_to(bx + pad_x, by + pad_y + (i as f64 + 1.0) * line_height - 4.0);
|
||||
@@ -1280,6 +1284,7 @@ impl ChartsView {
|
||||
|
||||
let gap_angle = 1.5_f64.to_radians();
|
||||
|
||||
// Draw segments
|
||||
let mut start = -PI / 2.0;
|
||||
for (idx, seg) in data.iter().enumerate() {
|
||||
let frac = seg.value / total;
|
||||
@@ -1350,6 +1355,7 @@ impl ChartsView {
|
||||
start2 += sweep2;
|
||||
}
|
||||
|
||||
// Center text
|
||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
|
||||
cr.set_font_size(22.0);
|
||||
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
|
||||
@@ -1358,6 +1364,7 @@ impl ChartsView {
|
||||
cr.move_to(cx - ext.width() / 2.0, cy + ext.height() / 2.0);
|
||||
cr.show_text(&total_str).ok();
|
||||
|
||||
// Legend
|
||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||
cr.set_font_size(13.0);
|
||||
|
||||
@@ -1381,6 +1388,7 @@ impl ChartsView {
|
||||
cr.show_text(&label).ok();
|
||||
}
|
||||
|
||||
// Tooltip on hover
|
||||
if hover_idx >= 0 && (hover_idx as usize) < data.len() && hover_progress > 0.1 {
|
||||
let seg = &data[hover_idx as usize];
|
||||
let pct = seg.value / total * 100.0;
|
||||
@@ -1442,6 +1450,7 @@ impl ChartsView {
|
||||
|
||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||
|
||||
// Grid lines
|
||||
cr.set_font_size(13.0);
|
||||
cr.set_line_width(0.5);
|
||||
for i in 0..=4 {
|
||||
@@ -1511,6 +1520,7 @@ impl ChartsView {
|
||||
(center_x - bar_w - gap / 2.0, center_x + gap / 2.0)
|
||||
};
|
||||
|
||||
// Income bar
|
||||
let bh_inc = (bar.income / y_max * chart_h * progress).max(0.0);
|
||||
let bx_inc = bx_inc_offset;
|
||||
let by_inc = margin_top + chart_h - bh_inc;
|
||||
@@ -1525,6 +1535,7 @@ impl ChartsView {
|
||||
cr.set_source_rgba(0.0, 0.0, 0.0, 0.10);
|
||||
cr.fill().ok();
|
||||
|
||||
// Income bar with gradient
|
||||
Self::rounded_top_rect(cr, bx_inc, by_inc, bar_w, bh_inc, corner_r);
|
||||
if bh_inc > 1.0 {
|
||||
let grad = cairo::LinearGradient::new(0.0, by_inc, 0.0, margin_top + chart_h);
|
||||
@@ -1537,6 +1548,7 @@ impl ChartsView {
|
||||
}
|
||||
cr.fill().ok();
|
||||
|
||||
// Expense bar
|
||||
let bh_exp = (bar.expense / y_max * chart_h * progress).max(0.0);
|
||||
let bx_exp = bx_exp_offset;
|
||||
let by_exp = margin_top + chart_h - bh_exp;
|
||||
@@ -1551,6 +1563,7 @@ impl ChartsView {
|
||||
cr.set_source_rgba(0.0, 0.0, 0.0, 0.10);
|
||||
cr.fill().ok();
|
||||
|
||||
// Expense bar with gradient
|
||||
Self::rounded_top_rect(cr, bx_exp, by_exp, bar_w, bh_exp, corner_r);
|
||||
if bh_exp > 1.0 {
|
||||
let grad = cairo::LinearGradient::new(0.0, by_exp, 0.0, margin_top + chart_h);
|
||||
@@ -1563,6 +1576,7 @@ impl ChartsView {
|
||||
}
|
||||
cr.fill().ok();
|
||||
|
||||
// X-axis label
|
||||
cr.set_font_size(14.0);
|
||||
cr.set_source_rgb(tc.text.0, tc.text.1, tc.text.2);
|
||||
let ext = cr.text_extents(&bar.label).unwrap();
|
||||
@@ -1570,6 +1584,7 @@ impl ChartsView {
|
||||
cr.show_text(&bar.label).ok();
|
||||
}
|
||||
|
||||
// Legend
|
||||
let legend_y = h - PAD - 2.0;
|
||||
cr.set_font_size(13.0);
|
||||
let mut lx = margin_left;
|
||||
@@ -1600,6 +1615,7 @@ impl ChartsView {
|
||||
cr.show_text("Prev Year").ok();
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
if let Some((idx, is_expense, is_cmp)) = hovered {
|
||||
let src = if is_cmp { compare.unwrap_or(data) } else { data };
|
||||
let bar = &src[idx];
|
||||
@@ -1677,6 +1693,7 @@ impl ChartsView {
|
||||
cr.select_font_face("JetBrains Mono", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
|
||||
cr.set_font_size(13.0);
|
||||
|
||||
// Grid
|
||||
cr.set_line_width(0.5);
|
||||
for i in 0..=4 {
|
||||
let frac = i as f64 / 4.0;
|
||||
@@ -1695,6 +1712,7 @@ impl ChartsView {
|
||||
cr.show_text(&label).ok();
|
||||
}
|
||||
|
||||
// Zero line
|
||||
if y_min < 0.0 && y_max > 0.0 {
|
||||
let zy = to_y(0.0);
|
||||
cr.set_source_rgba(tc.text.0, tc.text.1, tc.text.2, 0.35);
|
||||
@@ -1706,6 +1724,7 @@ impl ChartsView {
|
||||
cr.set_dash(&[], 0.0);
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
{
|
||||
cr.set_font_size(13.0);
|
||||
let step = if x_range > 20.0 { 5.0 } else if x_range > 10.0 { 2.0 } else { 1.0 };
|
||||
@@ -1746,6 +1765,7 @@ impl ChartsView {
|
||||
let accent = tc.income;
|
||||
let zero_y = to_y(0.0).clamp(margin_top, margin_top + chart_h);
|
||||
|
||||
// Gradient fill
|
||||
{
|
||||
cr.new_path();
|
||||
let first_x = to_x(data[0].day);
|
||||
@@ -1768,6 +1788,7 @@ impl ChartsView {
|
||||
cr.fill().ok();
|
||||
}
|
||||
|
||||
// Line
|
||||
{
|
||||
// Glow/shadow under the line
|
||||
cr.new_path();
|
||||
@@ -1790,6 +1811,7 @@ impl ChartsView {
|
||||
cr.stroke().ok();
|
||||
}
|
||||
|
||||
// Data dots
|
||||
let mut hovered_pt: Option<usize> = None;
|
||||
let (mx, my) = mouse;
|
||||
for (i, p) in data.iter().enumerate() {
|
||||
@@ -1817,6 +1839,7 @@ impl ChartsView {
|
||||
|
||||
cr.restore().ok();
|
||||
|
||||
// Tooltip (drawn after clip restore)
|
||||
if let Some(idx) = hovered_pt {
|
||||
let p = &data[idx];
|
||||
let l1 = format!("Day {}", p.day as i32);
|
||||
@@ -1990,6 +2013,7 @@ impl ChartsView {
|
||||
let n = data.len();
|
||||
let step = if n > 1 { chart_w / (n - 1) as f64 } else { chart_w };
|
||||
|
||||
// Grid lines
|
||||
cr.set_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
|
||||
cr.set_line_width(0.5);
|
||||
for i in 0..5 {
|
||||
@@ -2007,6 +2031,7 @@ impl ChartsView {
|
||||
cr.set_source_rgb(tc.grid.0, tc.grid.1, tc.grid.2);
|
||||
}
|
||||
|
||||
// Zero line
|
||||
let zero_y = val_to_y(0.0);
|
||||
if zero_y >= chart_top && zero_y <= chart_bottom {
|
||||
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
|
||||
@@ -2016,12 +2041,14 @@ impl ChartsView {
|
||||
let _ = cr.stroke();
|
||||
}
|
||||
|
||||
// Compute points
|
||||
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, p)| {
|
||||
let x = chart_left + i as f64 * step;
|
||||
let y = val_to_y(p.value * progress);
|
||||
(x, y)
|
||||
}).collect();
|
||||
|
||||
// Fill area between smooth curve and zero
|
||||
if points.len() >= 2 {
|
||||
let zy = val_to_y(0.0).clamp(chart_top, chart_bottom);
|
||||
let smooth = Self::monotone_subdivide(&points, 16);
|
||||
@@ -2043,6 +2070,7 @@ impl ChartsView {
|
||||
let _ = cr.close_path();
|
||||
let _ = cr.fill();
|
||||
|
||||
// Draw smooth line stroke
|
||||
cr.set_line_width(2.5);
|
||||
if net_sum >= 0.0 {
|
||||
cr.set_source_rgb(tc.income.0, tc.income.1, tc.income.2);
|
||||
@@ -2055,6 +2083,7 @@ impl ChartsView {
|
||||
}
|
||||
let _ = cr.stroke();
|
||||
|
||||
// Draw points
|
||||
for (i, &(x, y)) in points.iter().enumerate() {
|
||||
let val = data[i].value;
|
||||
if val >= 0.0 {
|
||||
@@ -2067,6 +2096,7 @@ impl ChartsView {
|
||||
}
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
cr.set_font_size(12.0);
|
||||
cr.set_source_rgb(tc.text_secondary.0, tc.text_secondary.1, tc.text_secondary.2);
|
||||
for (i, p) in data.iter().enumerate() {
|
||||
@@ -2075,6 +2105,7 @@ impl ChartsView {
|
||||
let _ = cr.show_text(&p.label);
|
||||
}
|
||||
|
||||
// Tooltip on hover
|
||||
if mouse.0 >= chart_left && mouse.0 <= chart_right && mouse.1 >= chart_top && mouse.1 <= chart_bottom {
|
||||
let idx = ((mouse.0 - chart_left) / step + 0.5) as usize;
|
||||
if idx < data.len() {
|
||||
@@ -2090,6 +2121,7 @@ impl ChartsView {
|
||||
let tx = (px - tw / 2.0).clamp(chart_left, chart_right - tw);
|
||||
let ty = py - th - 10.0;
|
||||
|
||||
// Background
|
||||
Self::rounded_rect(cr, tx, ty, tw, th, 6.0);
|
||||
cr.set_source_rgba(tc.tooltip_bg.0, tc.tooltip_bg.1, tc.tooltip_bg.2, tc.tooltip_bg.3);
|
||||
let _ = cr.fill_preserve();
|
||||
|
||||
@@ -22,6 +22,7 @@ impl CreditCardsView {
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
// Summary card
|
||||
let summary_group = adw::PreferencesGroup::builder()
|
||||
.title("SUMMARY")
|
||||
.build();
|
||||
@@ -60,15 +61,18 @@ impl CreditCardsView {
|
||||
summary_group.add(&total_limit_row);
|
||||
summary_group.add(&utilization_row);
|
||||
|
||||
// Cards list
|
||||
let cards_group = adw::PreferencesGroup::builder()
|
||||
.title("CARDS")
|
||||
.build();
|
||||
|
||||
// Populate
|
||||
Self::populate_cards(
|
||||
&db, &cards_group, &toast_overlay,
|
||||
&balance_label, &limit_label, &util_bar, &util_label,
|
||||
);
|
||||
|
||||
// Add card button
|
||||
let add_btn = gtk::Button::with_label("Add Card");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
@@ -116,6 +120,7 @@ impl CreditCardsView {
|
||||
util_bar: >k::LevelBar,
|
||||
util_label: >k::Label,
|
||||
) {
|
||||
// Remove existing rows
|
||||
while let Some(child) = group.first_child() {
|
||||
if let Some(inner) = child.first_child() {
|
||||
if let Some(listbox) = inner.downcast_ref::<gtk::ListBox>() {
|
||||
@@ -237,6 +242,7 @@ impl CreditCardsView {
|
||||
btn_row.set_child(Some(&btn_box));
|
||||
expander.add_row(&btn_row);
|
||||
|
||||
// Wire payment
|
||||
{
|
||||
let card_id = card.id;
|
||||
let card_name = card.name.clone();
|
||||
@@ -255,6 +261,7 @@ impl CreditCardsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Wire edit
|
||||
{
|
||||
let card_id = card.id;
|
||||
let db_ref = db.clone();
|
||||
@@ -272,6 +279,7 @@ impl CreditCardsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Wire delete
|
||||
{
|
||||
let card_id = card.id;
|
||||
let db_ref = db.clone();
|
||||
@@ -313,6 +321,7 @@ impl CreditCardsView {
|
||||
group.add(&expander);
|
||||
}
|
||||
|
||||
// Update summary
|
||||
balance_label.set_label(&format!("{:.2}", total_balance));
|
||||
limit_label.set_label(&format!("{:.2}", total_limit));
|
||||
let util = if total_limit > 0.0 { total_balance / total_limit } else { 0.0 };
|
||||
|
||||
@@ -47,6 +47,7 @@ pub fn show_edit_dialog(
|
||||
.subtitle(type_label)
|
||||
.build();
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", txn.amount))
|
||||
@@ -54,6 +55,7 @@ pub fn show_edit_dialog(
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
|
||||
// Category selector
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let mut cat_ids: Vec<i64> = Vec::new();
|
||||
let mut cat_selected: u32 = 0;
|
||||
@@ -80,6 +82,7 @@ pub fn show_edit_dialog(
|
||||
cat_row.set_list_factory(Some(&make_category_factory()));
|
||||
let cat_ids = Rc::new(cat_ids);
|
||||
|
||||
// Date
|
||||
let date_fmt = db.get_date_format_string();
|
||||
let selected_date = Rc::new(Cell::new(txn.date));
|
||||
let date_label = gtk::Label::new(Some(&txn.date.format(&date_fmt).to_string()));
|
||||
@@ -131,16 +134,19 @@ pub fn show_edit_dialog(
|
||||
popover_ref.popdown();
|
||||
});
|
||||
|
||||
// Payee
|
||||
let payee_row = adw::EntryRow::builder()
|
||||
.title("Payee (optional)")
|
||||
.text(txn.payee.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(txn.note.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
|
||||
// Tags
|
||||
let existing_tags = db.get_transaction_tags(txn_id)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
@@ -240,6 +246,7 @@ pub fn show_edit_dialog(
|
||||
form_group.add(¬e_row);
|
||||
form_group.add(&tags_row);
|
||||
|
||||
// Buttons
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
btn_box.set_margin_top(8);
|
||||
@@ -489,6 +496,7 @@ pub fn show_edit_dialog(
|
||||
|
||||
let on_changed = Rc::new(on_changed);
|
||||
|
||||
// Wire save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -556,6 +564,7 @@ pub fn show_edit_dialog(
|
||||
|
||||
match db_ref.update_transaction(&updated) {
|
||||
Ok(()) => {
|
||||
// Save tags
|
||||
let mut tag_ids = Vec::new();
|
||||
for name in &tag_names {
|
||||
if let Ok(tid) = db_ref.get_or_create_tag(name) {
|
||||
@@ -564,6 +573,7 @@ pub fn show_edit_dialog(
|
||||
}
|
||||
let _ = db_ref.set_transaction_tags(txn_clone.id, &tag_ids);
|
||||
|
||||
// Save splits
|
||||
let _ = db_ref.delete_splits(txn_clone.id);
|
||||
let entries = split_entries_ref.borrow();
|
||||
if !entries.is_empty() {
|
||||
|
||||
@@ -24,6 +24,7 @@ impl ForecastView {
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
// Forecast summary card
|
||||
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
summary_card.add_css_class("card");
|
||||
summary_card.set_margin_start(4);
|
||||
@@ -46,6 +47,7 @@ impl ForecastView {
|
||||
summary_card.append(&summary_title);
|
||||
summary_card.append(&summary_detail);
|
||||
|
||||
// Monthly forecast table
|
||||
let forecast_group = adw::PreferencesGroup::builder()
|
||||
.title("MONTHLY PROJECTION")
|
||||
.build();
|
||||
|
||||
@@ -20,15 +20,18 @@ impl GoalsView {
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
// Active goals group
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("SAVINGS GOALS")
|
||||
.build();
|
||||
|
||||
// Completed goals group
|
||||
let completed_group = adw::PreferencesGroup::builder()
|
||||
.title("COMPLETED")
|
||||
.build();
|
||||
completed_group.set_visible(false);
|
||||
|
||||
// Add goal button
|
||||
let add_btn = gtk::Button::with_label("Add Goal");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
@@ -71,6 +74,7 @@ impl GoalsView {
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing rows
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(completed_group);
|
||||
|
||||
@@ -480,6 +484,7 @@ impl GoalsView {
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -524,6 +529,7 @@ impl GoalsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
|
||||
@@ -808,6 +808,7 @@ impl HistoryView {
|
||||
action_bar: Option<>k::ActionBar>,
|
||||
action_count_label: Option<>k::Label>,
|
||||
) {
|
||||
// Clear existing children
|
||||
while let Some(child) = list_container.first_child() {
|
||||
list_container.remove(&child);
|
||||
}
|
||||
@@ -1199,6 +1200,7 @@ impl HistoryView {
|
||||
updating: &Rc<Cell<bool>>,
|
||||
no_results_page: &adw::StatusPage,
|
||||
) {
|
||||
// Clear existing chips
|
||||
while let Some(child) = chips_box.first_child() {
|
||||
chips_box.remove(&child);
|
||||
}
|
||||
|
||||
@@ -381,6 +381,7 @@ impl InsightsView {
|
||||
header.add_css_class("property");
|
||||
list.append(&header);
|
||||
|
||||
// Category breakdown
|
||||
for cat in &recap.categories {
|
||||
let icon_name = cat.category_icon.as_deref().unwrap_or("folder-symbolic");
|
||||
let row = adw::ActionRow::builder()
|
||||
|
||||
@@ -428,6 +428,7 @@ impl LogView {
|
||||
|
||||
form_group.add(&category_row);
|
||||
|
||||
// Currency
|
||||
let currency_labels: Vec<String> = currencies
|
||||
.iter()
|
||||
.map(|(code, name)| format!("{} - {}", code, name))
|
||||
@@ -449,6 +450,7 @@ impl LogView {
|
||||
rate_label.set_margin_start(16);
|
||||
rate_label.set_visible(false);
|
||||
|
||||
// Date picker
|
||||
let date_fmt = db.get_date_format_string();
|
||||
let today_date = chrono::Local::now().date_naive();
|
||||
let selected_date = Rc::new(Cell::new(today_date));
|
||||
@@ -535,21 +537,25 @@ impl LogView {
|
||||
|
||||
form_group.add(&date_row);
|
||||
|
||||
// Payee
|
||||
let payee_row = adw::EntryRow::builder()
|
||||
.title("Payee (optional)")
|
||||
.build();
|
||||
form_group.add(&payee_row);
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
form_group.add(¬e_row);
|
||||
|
||||
// Tags
|
||||
let tags_row = adw::EntryRow::builder()
|
||||
.title("Tags (comma-separated)")
|
||||
.build();
|
||||
form_group.add(&tags_row);
|
||||
|
||||
// Split toggle
|
||||
let split_switch = adw::SwitchRow::builder()
|
||||
.title("Split across categories")
|
||||
.build();
|
||||
@@ -1196,6 +1202,7 @@ impl LogView {
|
||||
}
|
||||
pending_do.borrow_mut().clear();
|
||||
|
||||
// Save tags
|
||||
if !tag_names.is_empty() {
|
||||
let mut tag_ids = Vec::new();
|
||||
for name in &tag_names {
|
||||
@@ -1206,6 +1213,7 @@ impl LogView {
|
||||
let _ = db_ref.set_transaction_tags(new_id, &tag_ids);
|
||||
}
|
||||
|
||||
// Save splits
|
||||
if !splits.is_empty() {
|
||||
let _ = db_ref.insert_splits(new_id, &splits);
|
||||
}
|
||||
@@ -1626,7 +1634,8 @@ impl LogView {
|
||||
inner.append(&recent_group);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
container.append(&clamp);
|
||||
toast_overlay.set_child(Some(&clamp));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
LogView {
|
||||
container,
|
||||
|
||||
@@ -28,7 +28,7 @@ use gtk::glib;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "com.outlay.app";
|
||||
const APP_ID: &str = "io.github.outlay";
|
||||
|
||||
fn main() {
|
||||
let app = Application::builder()
|
||||
@@ -100,25 +100,6 @@ fn build_ui(app: &Application) {
|
||||
load_css();
|
||||
icon_theme::setup_themed_icons();
|
||||
|
||||
// Register app icon for the hicolor theme so it works for window + tray
|
||||
{
|
||||
let display = gtk::gdk::Display::default().expect("Could not get display");
|
||||
let theme = gtk::IconTheme::for_display(&display);
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let candidates = [
|
||||
exe_dir.join("../../outlay-gtk/data/icons"),
|
||||
exe_dir.join("../share/icons"),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
theme.add_search_path(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
gtk::Window::set_default_icon_name("com.outlay.app");
|
||||
|
||||
let data_dir = glib::user_data_dir().join("outlay");
|
||||
std::fs::create_dir_all(&data_dir).expect("Failed to create data directory");
|
||||
let db_path = data_dir.join("outlay.db");
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(16);
|
||||
|
||||
// Type toggle
|
||||
let is_income = Rc::new(Cell::new(false));
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
@@ -51,6 +52,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
|
||||
});
|
||||
}
|
||||
|
||||
// Amount entry
|
||||
let amount_group = adw::PreferencesGroup::new();
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
@@ -59,6 +61,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
|
||||
amount_group.add(&amount_row);
|
||||
content.append(&amount_group);
|
||||
|
||||
// Category
|
||||
let cat_group = adw::PreferencesGroup::new();
|
||||
let expense_cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
@@ -120,6 +123,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
|
||||
});
|
||||
}
|
||||
|
||||
// Note entry
|
||||
let note_group = adw::PreferencesGroup::new();
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
@@ -127,6 +131,7 @@ pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Wi
|
||||
note_group.add(¬e_row);
|
||||
content.append(¬e_group);
|
||||
|
||||
// Save button
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
@@ -247,6 +252,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(16);
|
||||
|
||||
// Type toggle
|
||||
let is_income = Rc::new(Cell::new(false));
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
@@ -268,6 +274,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
});
|
||||
}
|
||||
|
||||
// Amount entry
|
||||
let amount_group = adw::PreferencesGroup::new();
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
@@ -276,6 +283,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
amount_group.add(&amount_row);
|
||||
content.append(&amount_group);
|
||||
|
||||
// Category
|
||||
let cat_group = adw::PreferencesGroup::new();
|
||||
let expense_cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
@@ -337,6 +345,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
});
|
||||
}
|
||||
|
||||
// Note entry
|
||||
let note_group = adw::PreferencesGroup::new();
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
@@ -344,6 +353,7 @@ pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
note_group.add(¬e_row);
|
||||
content.append(¬e_group);
|
||||
|
||||
// Save button
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
|
||||
@@ -35,16 +35,19 @@ impl RecurringView {
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
// Active section
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("ACTIVE")
|
||||
.build();
|
||||
|
||||
// Paused section
|
||||
let paused_group = adw::PreferencesGroup::builder()
|
||||
.title("PAUSED")
|
||||
.build();
|
||||
|
||||
let on_change: ChangeCb = Rc::new(RefCell::new(None));
|
||||
|
||||
// Add button
|
||||
let add_btn = gtk::Button::with_label("Add Recurring");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
@@ -53,6 +56,7 @@ impl RecurringView {
|
||||
|
||||
Self::load_recurring(&db, &active_group, &paused_group, &toast_overlay, &on_change);
|
||||
|
||||
// Wire add button
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
@@ -284,6 +288,7 @@ impl RecurringView {
|
||||
step1.set_margin_start(16);
|
||||
step1.set_margin_end(16);
|
||||
|
||||
// Type toggle
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.add_css_class("linked");
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
@@ -301,6 +306,7 @@ impl RecurringView {
|
||||
|
||||
let form1 = adw::PreferencesGroup::new();
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
@@ -308,6 +314,7 @@ impl RecurringView {
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form1.add(&amount_row);
|
||||
|
||||
// Category
|
||||
let category_model = gtk::StringList::new(&[]);
|
||||
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let category_row = adw::ComboRow::builder()
|
||||
@@ -319,6 +326,7 @@ impl RecurringView {
|
||||
Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense);
|
||||
form1.add(&category_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
@@ -377,6 +385,7 @@ impl RecurringView {
|
||||
|
||||
let form2 = adw::PreferencesGroup::new();
|
||||
|
||||
// Currency
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
@@ -410,6 +419,7 @@ impl RecurringView {
|
||||
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", "");
|
||||
form2.add(&end_row);
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
@@ -534,6 +544,7 @@ impl RecurringView {
|
||||
toolbar.set_content(Some(&scroll));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Wire save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -693,6 +704,7 @@ impl RecurringView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", rec.amount))
|
||||
@@ -701,6 +713,7 @@ impl RecurringView {
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form.add(&amount_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_idx = match rec.frequency {
|
||||
@@ -727,6 +740,7 @@ impl RecurringView {
|
||||
let (end_row, end_date_label) = crate::date_picker::make_date_row("End Date (optional)", &end_str);
|
||||
form.add(&end_row);
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(rec.note.as_deref().unwrap_or(""))
|
||||
@@ -758,6 +772,7 @@ impl RecurringView {
|
||||
});
|
||||
}
|
||||
|
||||
// Buttons
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
|
||||
@@ -782,6 +797,7 @@ impl RecurringView {
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
@@ -1018,6 +1034,7 @@ impl RecurringView {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
|
||||
@@ -1244,12 +1244,14 @@ impl SettingsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Name row
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.text(&cat.name)
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
// Icon row
|
||||
let selected_icon: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(cat.icon.clone()));
|
||||
|
||||
let initial_icon = icon_theme::resolve_category_icon(&cat.icon, &cat.color)
|
||||
@@ -1285,6 +1287,7 @@ impl SettingsView {
|
||||
}
|
||||
form.add(&icon_row);
|
||||
|
||||
// Color row
|
||||
let current_color = cat.color.as_deref()
|
||||
.and_then(|hex| {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
@@ -1398,6 +1401,7 @@ impl SettingsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Name row
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.build();
|
||||
@@ -2055,9 +2059,11 @@ impl SettingsView {
|
||||
db.execute_raw(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))
|
||||
.map_err(|e| format!("Backup failed: {}", e))?;
|
||||
|
||||
// Update last backup timestamp
|
||||
let _ = db.set_setting("auto_backup_last", &now.format("%Y-%m-%d %H:%M").to_string());
|
||||
let _ = db.set_setting("last_backup_date", &now.format("%Y-%m-%d").to_string());
|
||||
|
||||
// Prune old backups
|
||||
let retention = db.get_setting("auto_backup_retention")
|
||||
.ok().flatten()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
@@ -2773,6 +2779,7 @@ impl SettingsView {
|
||||
group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Remove existing rows
|
||||
let mut rows_to_remove = Vec::new();
|
||||
let mut child = group.first_child();
|
||||
while let Some(widget) = child {
|
||||
@@ -2853,6 +2860,7 @@ impl SettingsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Type selector
|
||||
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
||||
let type_row = adw::ComboRow::builder()
|
||||
.title("Type")
|
||||
@@ -2868,11 +2876,13 @@ impl SettingsView {
|
||||
.build();
|
||||
form.add(&field_row);
|
||||
|
||||
// Pattern
|
||||
let pattern_row = adw::EntryRow::builder()
|
||||
.title("Contains text")
|
||||
.build();
|
||||
form.add(&pattern_row);
|
||||
|
||||
// Category
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
||||
@@ -2985,6 +2995,7 @@ impl SettingsView {
|
||||
group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Remove existing rows
|
||||
let mut rows_to_remove = Vec::new();
|
||||
let mut child = group.first_child();
|
||||
while let Some(widget) = child {
|
||||
@@ -3071,11 +3082,13 @@ impl SettingsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Name
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Template name")
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
// Type
|
||||
let type_model = gtk::StringList::new(&["Expense", "Income"]);
|
||||
let type_row = adw::ComboRow::builder()
|
||||
.title("Type")
|
||||
@@ -3083,6 +3096,7 @@ impl SettingsView {
|
||||
.build();
|
||||
form.add(&type_row);
|
||||
|
||||
// Amount (optional)
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount (optional)")
|
||||
.build();
|
||||
@@ -3090,6 +3104,7 @@ impl SettingsView {
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form.add(&amount_row);
|
||||
|
||||
// Category
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) {
|
||||
@@ -3122,6 +3137,7 @@ impl SettingsView {
|
||||
} else {
|
||||
TransactionType::Income
|
||||
};
|
||||
// Clear existing
|
||||
let n = cat_model.n_items();
|
||||
if n > 0 {
|
||||
cat_model.splice(0, n, &[] as &[&str]);
|
||||
@@ -3141,11 +3157,13 @@ impl SettingsView {
|
||||
});
|
||||
}
|
||||
|
||||
// Payee (optional)
|
||||
let payee_row = adw::EntryRow::builder()
|
||||
.title("Payee (optional)")
|
||||
.build();
|
||||
form.add(&payee_row);
|
||||
|
||||
// Note (optional)
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
@@ -3322,11 +3340,13 @@ impl SettingsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Name
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
// Icon
|
||||
let icon_row = adw::ActionRow::builder()
|
||||
.title("Icon")
|
||||
.subtitle("None")
|
||||
@@ -3349,6 +3369,7 @@ impl SettingsView {
|
||||
}
|
||||
form.add(&icon_row);
|
||||
|
||||
// Color
|
||||
let color_btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new()));
|
||||
color_btn.set_valign(gtk::Align::Center);
|
||||
let default_color = gdk::RGBA::parse("#95a5a6").unwrap_or(gdk::RGBA::BLACK);
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
|
||||
(0.42, 0.75, 0.45) // Green-ish for expenses going down
|
||||
};
|
||||
|
||||
// Build points
|
||||
let step = if data.len() > 1 { chart_w / (data.len() - 1) as f64 } else { chart_w };
|
||||
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, &val)| {
|
||||
let x = padding + i as f64 * step;
|
||||
@@ -49,6 +50,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
|
||||
(x, y)
|
||||
}).collect();
|
||||
|
||||
// Fill area under curve
|
||||
ctx.move_to(points[0].0, h - padding);
|
||||
for &(x, y) in &points {
|
||||
ctx.line_to(x, y);
|
||||
@@ -58,6 +60,7 @@ pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingAr
|
||||
ctx.set_source_rgba(r, g, b, 0.15);
|
||||
let _ = ctx.fill();
|
||||
|
||||
// Draw line
|
||||
ctx.move_to(points[0].0, points[0].1);
|
||||
for &(x, y) in points.iter().skip(1) {
|
||||
ctx.line_to(x, y);
|
||||
|
||||
@@ -72,10 +72,12 @@ impl SubscriptionsView {
|
||||
cost_card.append(&monthly_label);
|
||||
cost_card.append(&yearly_label);
|
||||
|
||||
// Active subscriptions group
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("ACTIVE SUBSCRIPTIONS")
|
||||
.build();
|
||||
|
||||
// Paused subscriptions group
|
||||
let paused_group = adw::PreferencesGroup::builder()
|
||||
.title("PAUSED SUBSCRIPTIONS")
|
||||
.build();
|
||||
@@ -88,6 +90,7 @@ impl SubscriptionsView {
|
||||
&toast_overlay, &on_change,
|
||||
);
|
||||
|
||||
// Add button
|
||||
let add_btn = gtk::Button::with_label("Add Subscription");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
@@ -262,6 +265,7 @@ impl SubscriptionsView {
|
||||
equiv_label.add_css_class("dim-label");
|
||||
suffix_box.append(&equiv_label);
|
||||
|
||||
// Pause/resume button
|
||||
let pause_btn = if sub.active {
|
||||
let btn = gtk::Button::from_icon_name("tabler-player-pause");
|
||||
btn.set_tooltip_text(Some("Pause"));
|
||||
@@ -294,6 +298,7 @@ impl SubscriptionsView {
|
||||
}
|
||||
suffix_box.append(&pause_btn);
|
||||
|
||||
// Edit button
|
||||
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
|
||||
edit_btn.add_css_class("flat");
|
||||
edit_btn.set_tooltip_text(Some("Edit"));
|
||||
@@ -317,6 +322,7 @@ impl SubscriptionsView {
|
||||
}
|
||||
suffix_box.append(&edit_btn);
|
||||
|
||||
// Delete button
|
||||
let del_btn = gtk::Button::from_icon_name("tabler-trash");
|
||||
del_btn.add_css_class("flat");
|
||||
del_btn.set_tooltip_text(Some("Delete"));
|
||||
@@ -380,11 +386,13 @@ impl SubscriptionsView {
|
||||
|
||||
let form1 = adw::PreferencesGroup::new();
|
||||
|
||||
// Name
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.build();
|
||||
form1.add(&name_row);
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
@@ -392,6 +400,7 @@ impl SubscriptionsView {
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form1.add(&amount_row);
|
||||
|
||||
// Category
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
if let Ok(cats) = db.list_subscription_categories() {
|
||||
@@ -413,6 +422,7 @@ impl SubscriptionsView {
|
||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||
form1.add(&cat_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
@@ -444,6 +454,7 @@ impl SubscriptionsView {
|
||||
|
||||
let form2 = adw::PreferencesGroup::new();
|
||||
|
||||
// Currency
|
||||
let base_cur = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
@@ -466,15 +477,18 @@ impl SubscriptionsView {
|
||||
.build();
|
||||
form2.add(¤cy_row);
|
||||
|
||||
// Start date
|
||||
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
|
||||
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &today_str);
|
||||
form2.add(&start_row);
|
||||
|
||||
// Note (optional)
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
form2.add(¬e_row);
|
||||
|
||||
// URL (optional)
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("URL (optional)")
|
||||
.build();
|
||||
@@ -674,12 +688,14 @@ impl SubscriptionsView {
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Name
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.text(&sub.name)
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", sub.amount))
|
||||
@@ -688,6 +704,7 @@ impl SubscriptionsView {
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form.add(&amount_row);
|
||||
|
||||
// Currency
|
||||
let mut currencies = ExchangeRateService::supported_currencies();
|
||||
let base_cur = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
@@ -711,6 +728,7 @@ impl SubscriptionsView {
|
||||
.build();
|
||||
form.add(¤cy_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let selected_freq = match sub.frequency {
|
||||
@@ -727,6 +745,7 @@ impl SubscriptionsView {
|
||||
.build();
|
||||
form.add(&freq_row);
|
||||
|
||||
// Category
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut selected_cat = 0u32;
|
||||
@@ -753,16 +772,19 @@ impl SubscriptionsView {
|
||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||
form.add(&cat_row);
|
||||
|
||||
// Start date
|
||||
let start_str = sub.start_date.format("%Y-%m-%d").to_string();
|
||||
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &start_str);
|
||||
form.add(&start_row);
|
||||
|
||||
// Note (optional)
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(sub.note.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
form.add(¬e_row);
|
||||
|
||||
// URL (optional)
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("URL (optional)")
|
||||
.text(sub.url.as_deref().unwrap_or(""))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{Category, Icon, ToolTip, Tray, TrayMethods};
|
||||
use ksni::{Category, ToolTip, Tray, TrayMethods};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub enum TrayCommand {
|
||||
@@ -10,11 +11,9 @@ pub enum TrayCommand {
|
||||
Quit,
|
||||
}
|
||||
|
||||
static TRAY_ICON_32: &[u8] = include_bytes!("../data/icons/tray-icon-32x32.argb");
|
||||
static TRAY_ICON_48: &[u8] = include_bytes!("../data/icons/tray-icon-48x48.argb");
|
||||
|
||||
struct OutlayTray {
|
||||
sender: mpsc::Sender<TrayCommand>,
|
||||
icon_theme_path: String,
|
||||
}
|
||||
|
||||
impl Tray for OutlayTray {
|
||||
@@ -27,22 +26,11 @@ impl Tray for OutlayTray {
|
||||
}
|
||||
|
||||
fn icon_name(&self) -> String {
|
||||
String::new()
|
||||
"io.github.outlay".into()
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<Icon> {
|
||||
vec![
|
||||
Icon {
|
||||
width: 32,
|
||||
height: 32,
|
||||
data: TRAY_ICON_32.to_vec(),
|
||||
},
|
||||
Icon {
|
||||
width: 48,
|
||||
height: 48,
|
||||
data: TRAY_ICON_48.to_vec(),
|
||||
},
|
||||
]
|
||||
fn icon_theme_path(&self) -> String {
|
||||
self.icon_theme_path.clone()
|
||||
}
|
||||
|
||||
fn category(&self) -> Category {
|
||||
@@ -110,8 +98,34 @@ impl Tray for OutlayTray {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_icon_theme_path() -> String {
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
|
||||
let candidates = [
|
||||
exe_dir.join("../../outlay-gtk/data/icons"),
|
||||
exe_dir.join("../share/icons"),
|
||||
PathBuf::from("/usr/share/icons"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
if let Ok(resolved) = candidate.canonicalize() {
|
||||
return resolved.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub fn spawn_tray(sender: mpsc::Sender<TrayCommand>) {
|
||||
let tray = OutlayTray { sender };
|
||||
let icon_theme_path = find_icon_theme_path();
|
||||
|
||||
let tray = OutlayTray {
|
||||
sender,
|
||||
icon_theme_path,
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
|
||||
@@ -83,14 +83,15 @@ impl MainWindow {
|
||||
let content_stack = gtk::Stack::new();
|
||||
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
||||
|
||||
// Log view
|
||||
let log_view = Rc::new(LogView::new(db.clone(), app));
|
||||
let log_scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&log_view.container)
|
||||
.build();
|
||||
log_view.toast_overlay.set_child(Some(&log_scroll));
|
||||
content_stack.add_named(&log_view.toast_overlay, Some("log"));
|
||||
content_stack.add_named(&log_scroll, Some("log"));
|
||||
|
||||
// History view
|
||||
let history_view = HistoryView::new(db.clone());
|
||||
let history_scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
@@ -98,18 +99,23 @@ impl MainWindow {
|
||||
.build();
|
||||
content_stack.add_named(&history_scroll, Some("history"));
|
||||
|
||||
// Charts view
|
||||
let charts_view = ChartsView::new(db.clone());
|
||||
content_stack.add_named(&charts_view.container, Some("charts"));
|
||||
|
||||
// Budgets view
|
||||
let budgets_view = BudgetsView::new(db.clone());
|
||||
content_stack.add_named(&budgets_view.container, Some("budgets"));
|
||||
|
||||
// Goals view
|
||||
let goals_view = GoalsView::new(db.clone());
|
||||
content_stack.add_named(&goals_view.container, Some("goals"));
|
||||
|
||||
// Recurring view
|
||||
let recurring_view = Rc::new(RecurringView::new(db.clone()));
|
||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||
|
||||
// Subscriptions view
|
||||
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
|
||||
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
|
||||
|
||||
@@ -127,18 +133,23 @@ impl MainWindow {
|
||||
});
|
||||
}
|
||||
|
||||
// Wishlist view
|
||||
let wishlist_view = WishlistView::new(db.clone());
|
||||
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
|
||||
|
||||
// Forecast view
|
||||
let forecast_view = ForecastView::new(db.clone());
|
||||
content_stack.add_named(&forecast_view.container, Some("forecast"));
|
||||
|
||||
// Insights view
|
||||
let insights_view = InsightsView::new(db.clone());
|
||||
content_stack.add_named(&insights_view.container, Some("insights"));
|
||||
|
||||
// Credit Cards view
|
||||
let credit_cards_view = CreditCardsView::new(db.clone());
|
||||
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
|
||||
|
||||
// Settings view
|
||||
let settings_view = SettingsView::new(db.clone(), app);
|
||||
content_stack.add_named(&settings_view.container, Some("settings"));
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ impl WishlistView {
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
// Total card
|
||||
let total_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
total_card.add_css_class("card");
|
||||
total_card.set_margin_start(4);
|
||||
@@ -48,16 +49,19 @@ impl WishlistView {
|
||||
total_card.append(&total_title);
|
||||
total_card.append(&total_label);
|
||||
|
||||
// Active items
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("WANTED")
|
||||
.build();
|
||||
|
||||
// Purchased items
|
||||
let purchased_group = adw::PreferencesGroup::builder()
|
||||
.title("PURCHASED")
|
||||
.build();
|
||||
|
||||
Self::load_items(&db, &active_group, &purchased_group, &total_label, &base_currency, &toast_overlay);
|
||||
|
||||
// Add button
|
||||
let add_btn = gtk::Button::with_label("Add Item");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
|
||||
@@ -13,6 +13,7 @@ echo "==> Creating AppDir structure..."
|
||||
rm -rf AppDir
|
||||
mkdir -p AppDir/usr/bin
|
||||
mkdir -p AppDir/usr/share/applications
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/scalable/apps
|
||||
mkdir -p AppDir/usr/share/glib-2.0/schemas
|
||||
mkdir -p AppDir/usr/share/fonts/truetype/liberation
|
||||
|
||||
@@ -20,29 +21,19 @@ mkdir -p AppDir/usr/share/fonts/truetype/liberation
|
||||
cp target/release/outlay-gtk AppDir/usr/bin/
|
||||
|
||||
# Copy desktop file
|
||||
cp outlay-gtk/data/com.outlay.app.desktop AppDir/
|
||||
cp outlay-gtk/data/com.outlay.app.desktop AppDir/usr/share/applications/
|
||||
cp outlay-gtk/data/io.github.outlay.desktop AppDir/
|
||||
cp outlay-gtk/data/io.github.outlay.desktop AppDir/usr/share/applications/
|
||||
|
||||
# Copy app icons (all hicolor sizes)
|
||||
for size_dir in outlay-gtk/data/icons/hicolor/*/apps; do
|
||||
size=$(basename "$(dirname "$size_dir")")
|
||||
mkdir -p "AppDir/usr/share/icons/hicolor/$size/apps"
|
||||
cp "$size_dir"/com.outlay.app.* "AppDir/usr/share/icons/hicolor/$size/apps/" 2>/dev/null || true
|
||||
done
|
||||
# Copy app icon
|
||||
cp outlay-gtk/data/icons/hicolor/scalable/apps/io.github.outlay.svg AppDir/usr/share/icons/hicolor/scalable/apps/
|
||||
|
||||
# Copy Tabler action icons
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/scalable/actions
|
||||
cp outlay-gtk/data/icons/hicolor/scalable/actions/outlay-*.svg AppDir/usr/share/icons/hicolor/scalable/actions/
|
||||
cp outlay-gtk/data/icons/hicolor/scalable/actions/tabler-*.svg AppDir/usr/share/icons/hicolor/scalable/actions/
|
||||
|
||||
# Copy metainfo and screenshots
|
||||
mkdir -p AppDir/usr/share/metainfo
|
||||
cp outlay-gtk/data/com.outlay.app.metainfo.xml AppDir/usr/share/metainfo/
|
||||
mkdir -p AppDir/usr/share/screenshots
|
||||
cp data/screenshots/*.png AppDir/usr/share/screenshots/
|
||||
|
||||
# Copy GSettings schema
|
||||
cp outlay-gtk/data/com.outlay.app.gschema.xml AppDir/usr/share/glib-2.0/schemas/
|
||||
cp outlay-gtk/data/io.github.outlay.gschema.xml AppDir/usr/share/glib-2.0/schemas/
|
||||
glib-compile-schemas AppDir/usr/share/glib-2.0/schemas/
|
||||
|
||||
# Bundle Liberation Sans fonts (needed for PDF report generation)
|
||||
@@ -111,8 +102,8 @@ export DEPLOY_GTK_VERSION=4
|
||||
--appdir AppDir \
|
||||
--plugin gtk \
|
||||
--output appimage \
|
||||
--desktop-file AppDir/com.outlay.app.desktop \
|
||||
--icon-file AppDir/usr/share/icons/hicolor/256x256/apps/com.outlay.app.png
|
||||
--desktop-file AppDir/io.github.outlay.desktop \
|
||||
--icon-file AppDir/usr/share/icons/hicolor/scalable/apps/io.github.outlay.svg
|
||||
|
||||
echo "==> Done! AppImage created."
|
||||
ls -lh Outlay-*.AppImage 2>/dev/null || echo "Note: Output filename may vary."
|
||||
|
||||