diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2f2e1d..4c00a69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,45 +154,3 @@ jobs: uses: softprops/action-gh-release@v2 with: files: dist/*.apk - - ios: - # iOS builds need Apple's toolchain (Xcode), so this must run on a macOS - # runner. We build an UNSIGNED app bundle — no Apple Developer certificate or - # provisioning profile required. `fyne package` produces an unsigned .app - # (signing is a separate `fyne release` step, which we deliberately skip). - # - # The resulting .ipa is NOT installable as-is: iOS requires code signing to - # run on a device. Sideload it by re-signing with a free Apple ID using a - # tool like Sideloadly (https://sideloadly.io) or AltStore - # (https://altstore.io). Free-provisioning signatures expire after 7 days, so - # you'll need to re-sign/reinstall roughly weekly. - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install the fyne CLI - run: go install fyne.io/tools/cmd/fyne@latest - - - name: Build the unsigned iOS app bundle - shell: bash - run: | - export PATH="$PATH:$(go env GOPATH)/bin" - # Unsigned .app bundle for arm64 devices (no certificate needed). - fyne package -os ios -app-id app.financy -icon Icon.png - - # Wrap the .app in the standard Payload/ layout and zip it into an - # .ipa so sideloading tools (Sideloadly, AltStore) can re-sign it. - mkdir -p Payload - mv Financy.app Payload/ - mkdir -p dist - zip -r "dist/Financy-${GITHUB_REF_NAME}-ios-arm64-unsigned.ipa" Payload - echo "::warning::iOS .ipa is UNSIGNED. Re-sign it with a free Apple ID via Sideloadly or AltStore before installing on a device." - - - name: Attach the unsigned .ipa to the GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: dist/*.ipa diff --git a/CHANGELOG.md b/CHANGELOG.md index f27b9e8..5a778f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,86 @@ See `RELEASING.md`. ## [Unreleased] +### Added +- **Recurring, forward-looking.** The Recurring screen no longer just lists + templates — it leads with what your schedule actually means: **MONTHLY + BILLS / MONTHLY INCOME / NET PER MONTH / ACTIVE** stat cards (every + frequency normalized to a monthly figure) and a **Next 30 days timeline** + with occurrences bucketed into *Overdue & due* (amber, with an inline + Review & Post), *This week* and *Later this month*, each row wording its + distance ("today", "in 5 days", "3 days overdue"). The Accounts overview + gains an **Upcoming** card — due alert plus the next 14 days of + occurrences — that deep-links to the Recurring screen. Due entries are now + re-checked once a minute while the app runs (midnight rollover included), + not just when a file opens; the prompt only reappears when something *new* + becomes due, so "Later" means later. Review-before-post is unchanged: + nothing ever posts without your confirmation. + +- **Cover overspending.** An overspent envelope can now be fixed in place: + the overspend banner's **Cover…** button and the category row's + right-click menu open a dialog that moves money from another envelope (or + from Ready to Assign) into the red one — source picker sorted by what each + envelope can give, amount prefilled with the shortfall, and validation so + you can't move more than a source has. Backed by a single-notification + `MoveAssigned` store operation. + +### Changed +- **Desktop redesign — Recurring page.** Rebuilt around the forward-looking + layout above (stats → timeline → templates). The due nudge became a proper + amber alert banner with the amount due, the template list moved into a + titled **All templates** panel, and first-run shows an explainer ("Put your + bills on autopilot — with a veto") instead of a bare empty state. Row + actions (post early, edit, pause, delete) and the review dialog are + unchanged. +- **Desktop redesign — Budget page.** Spotting trouble no longer requires + reading numbers: every category row gains a traffic-light **usage bar** + (emerald while healthy, amber from 85% used, rose when overspent), overspent + envelopes show their Available as a filled red pill, and a banner above the + table counts them out loud. The Activity column became **Spent** — plain + figures, "—" for untouched envelopes, green "+" for refunds — and unassigned + categories show "—" instead of a wall of $ 0.00. The Ready-to-Assign hero + got the same padding/centering treatment as the Accounts hero. Assign + dialog, quick-assign, Auto-Assign and month locking are unchanged. +- **Desktop redesign — Transactions page.** The journal keeps its daily + grouping but each day is now its own card, headed by the date and the day's + net (shown only when there is one). The filter panel collapsed from two rows + to one slim line — month / type / account selects plus a full-width search — + and the summary cards read INCOME / EXPENSES / NET without the "(filtered)" + clutter. Transaction type chips are now soft circular badges. Selection + mode, row context menus and editing are unchanged. +- **Desktop redesign — Accounts page.** The card grid is now a grouped table, + matching the Debts page: Assets and Liabilities panels with one row per + account — name + institution (with a compact Off-budget chip), a + share-of-group bar, transaction count, and a right-aligned colored balance + column that's actually scannable. Row click still opens the register; + the ⋮ / right-click menu is unchanged. The net-worth hero stays. +- **Desktop redesign — modals.** Every dialog (setup wizard, transaction and + debt forms, reconcile, preferences, alerts, confirmations…) moved off Fyne's + boxy stock dialogs onto a custom modal: a dimming scrim with a centered + rounded card that matches the design system — soft shadow, hairline border, + title header and right-aligned footer actions. Delete confirmations now use + an explicit red **Delete** button instead of Yes/No. Native file open/save + pickers are unchanged. +- **Desktop redesign — a modern, slick shell.** The icon-only top toolbar and + bottom status bar are gone; navigation now lives in a labeled left sidebar + with the app brand, a prominent **New Transaction** button, clear icon+label + entries with an active highlight, quick access to Categories / Preferences / + theme toggle, and a live **net-worth summary card** (assets & liabilities) + pinned at the bottom. The design system was refreshed across both themes: + an indigo accent, calmer emerald/rose money colors (replacing raw + green/red), a deeper indigo-tinted dark palette, softer rounded corners, + slimmer pill progress bars, airier screen headers and stat cards. With no + document open the sidebar now hides entirely — the welcome screen has + nothing to navigate — and reappears when a file opens. Desktop only — the + mobile layout is unchanged. + +### Removed +- **iOS release build.** The release workflow no longer produces the unsigned + `.ipa`: it wasn't installable without re-signing through Sideloadly/AltStore + (and expired weekly on free provisioning), so it added a macOS runner to + every release for an artifact almost nobody could use. Android, Linux, + Windows and macOS release assets are unchanged. + ## [0.13.0] - 2026-07-11 ### Added diff --git a/Makefile b/Makefile index f9d1538..dca2c07 100644 --- a/Makefile +++ b/Makefile @@ -72,10 +72,10 @@ build: shot: build go run . shot $(SHOTDIR) @echo "Copying screenshots into docs/screenshots and website/static/img…" - @for n in accounts analytics categories data-summary recurring reports setup transactions; do \ + @for n in accounts analytics budget categories data-summary recurring setup transactions; do \ cp "$(SHOTDIR)/$$n.png" "docs/screenshots/$$n.png"; \ done - @for n in accounts analytics categories reports transactions; do \ + @for n in accounts analytics budget budget-assign categories transactions; do \ cp "$(SHOTDIR)/$$n.png" "website/static/img/$$n.png"; \ done @cp "$(SHOTDIR)/recurring.png" website/static/img/recurring-screen.png diff --git a/docs/screenshots/accounts.png b/docs/screenshots/accounts.png index 845ed25..638174e 100644 Binary files a/docs/screenshots/accounts.png and b/docs/screenshots/accounts.png differ diff --git a/docs/screenshots/analytics.png b/docs/screenshots/analytics.png index b0f2ca2..28aba3a 100644 Binary files a/docs/screenshots/analytics.png and b/docs/screenshots/analytics.png differ diff --git a/docs/screenshots/budget.png b/docs/screenshots/budget.png index 47b4af9..961712f 100644 Binary files a/docs/screenshots/budget.png and b/docs/screenshots/budget.png differ diff --git a/docs/screenshots/categories.png b/docs/screenshots/categories.png index beb3010..c87591c 100644 Binary files a/docs/screenshots/categories.png and b/docs/screenshots/categories.png differ diff --git a/docs/screenshots/data-summary.png b/docs/screenshots/data-summary.png index 6550aeb..cecb1b7 100644 Binary files a/docs/screenshots/data-summary.png and b/docs/screenshots/data-summary.png differ diff --git a/docs/screenshots/recurring.png b/docs/screenshots/recurring.png index 488f693..bbaddef 100644 Binary files a/docs/screenshots/recurring.png and b/docs/screenshots/recurring.png differ diff --git a/docs/screenshots/setup.png b/docs/screenshots/setup.png index f96b282..f8c1556 100644 Binary files a/docs/screenshots/setup.png and b/docs/screenshots/setup.png differ diff --git a/docs/screenshots/transactions.png b/docs/screenshots/transactions.png index b3f3840..f8d3c79 100644 Binary files a/docs/screenshots/transactions.png and b/docs/screenshots/transactions.png differ diff --git a/internal/core/budget.go b/internal/core/budget.go index 6903776..f8e640d 100644 --- a/internal/core/budget.go +++ b/internal/core/budget.go @@ -106,27 +106,55 @@ func (s *Store) Assigned(month, catID string) int { // SetAssigned records (or clears, when amount == 0) a category's assignment for // a month and writes through to disk. func (s *Store) SetAssigned(month, catID string, amount int) { + if s.setAssigned(month, catID, amount) { + s.notify() + } +} + +// setAssigned is SetAssigned without the notification; it reports whether +// anything changed so callers batching several writes can notify once. +func (s *Store) setAssigned(month, catID string, amount int) bool { if s.assignments == nil { s.assignments = map[string]int{} } key := monthKey(month, catID) if amount == 0 { if _, ok := s.assignments[key]; !ok { - return + return false } delete(s.assignments, key) if err := s.dbDeleteAssignment(month, catID); err != nil { s.reportError(err) - return + return false } } else { s.assignments[key] = amount if err := s.dbUpsertAssignment(month, catID, amount); err != nil { s.reportError(err) - return + return false } } - s.notify() + return true +} + +// MoveAssigned shifts amount between two categories' assignments for a month — +// the "cover overspending" move. An empty fromID takes the money from Ready to +// Assign instead: assigning to the target alone is what reduces RTA, so no +// source needs decrementing. Subscribers are notified once for the whole move. +func (s *Store) MoveAssigned(month, fromID, toID string, amount int) { + if amount <= 0 || toID == "" || fromID == toID { + return + } + changed := false + if fromID != "" { + changed = s.setAssigned(month, fromID, s.Assigned(month, fromID)-amount) + } + if s.setAssigned(month, toID, s.Assigned(month, toID)+amount) { + changed = true + } + if changed { + s.notify() + } } // ---- derived flows ---- diff --git a/internal/core/budget_test.go b/internal/core/budget_test.go index 5ab5f87..e6f6435 100644 --- a/internal/core/budget_test.go +++ b/internal/core/budget_test.go @@ -88,6 +88,45 @@ func TestAssignedRoundTripAndClear(t *testing.T) { } } +// MoveAssigned shifts money between envelopes (the cover-overspending move), +// treats an empty source as Ready to Assign, and ignores no-op moves. +func TestMoveAssigned(t *testing.T) { + s := budgetStore() + s.SetAssigned("2026-07", "groceries", 500) + + s.MoveAssigned("2026-07", "groceries", "housing", 200) + if got := s.Assigned("2026-07", "groceries"); got != 300 { + t.Fatalf("source Assigned = %d, want 300", got) + } + if got := s.Assigned("2026-07", "housing"); got != 200 { + t.Fatalf("target Assigned = %d, want 200", got) + } + + // From Ready to Assign: only the target changes. + s.MoveAssigned("2026-07", "", "housing", 100) + if got := s.Assigned("2026-07", "housing"); got != 300 { + t.Fatalf("after RTA move target Assigned = %d, want 300", got) + } + if got := s.Assigned("2026-07", "groceries"); got != 300 { + t.Fatalf("RTA move touched the wrong envelope: groceries = %d", got) + } + + // No-ops: zero/negative amounts, same source and target. + s.MoveAssigned("2026-07", "groceries", "housing", 0) + s.MoveAssigned("2026-07", "groceries", "housing", -50) + s.MoveAssigned("2026-07", "groceries", "groceries", 100) + if s.Assigned("2026-07", "groceries") != 300 || s.Assigned("2026-07", "housing") != 300 { + t.Fatal("no-op MoveAssigned changed assignments") + } + + // A move can drive the source negative — that's the YNAB behavior when + // covering from an envelope whose Available came from rollover. + s.MoveAssigned("2026-07", "groceries", "housing", 400) + if got := s.Assigned("2026-07", "groceries"); got != -100 { + t.Fatalf("source Assigned = %d, want -100", got) + } +} + func TestPreviewAutoAssign(t *testing.T) { s := budgetStore() // Last month (June): groceries and housing assigned. diff --git a/internal/core/recurring.go b/internal/core/recurring.go index 1ea29e7..eb55258 100644 --- a/internal/core/recurring.go +++ b/internal/core/recurring.go @@ -149,6 +149,125 @@ func addMonths(t time.Time, months int) time.Time { return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, time.UTC) } +// ---- projection / summary ---- + +// Occurrence is one projected instance of a recurring template — used by the +// forward-looking UI (upcoming timeline, overview card). Unlike DueItem it +// carries no duplicate candidates and building it never advances a schedule. +type Occurrence struct { + RecurringID string + Kind string + Date int // Excel serial + Payee string + Memo string + Amount int + AcctA string + AcctB string + Freq string + Due bool // Date <= asOf: overdue or due today +} + +// maxProjection bounds how many occurrences one template can contribute, so a +// years-stale weekly template can't expand into thousands of rows. +const maxProjection = 200 + +// UpcomingRecurring expands every enabled template from its NextDue through +// until (inclusive) without advancing any schedule. Occurrences on or before +// asOf are flagged Due — the same set PendingRecurring would prompt for. +// Sorted by date, then payee. +func (s *Store) UpcomingRecurring(asOf, until int) []Occurrence { + var out []Occurrence + for _, r := range s.recurring { + if !r.Enabled { + continue + } + n := 0 + for d := r.NextDue; d <= until && n < maxProjection; d = nextDate(d, r.Freq) { + out = append(out, Occurrence{ + RecurringID: r.ID, + Kind: r.Kind, + Date: d, + Payee: r.Payee, + Memo: r.Memo, + Amount: r.Amount, + AcctA: r.AcctA, + AcctB: r.AcctB, + Freq: r.Freq, + Due: d <= asOf, + }) + n++ + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Date != out[j].Date { + return out[i].Date < out[j].Date + } + return out[i].Payee < out[j].Payee + }) + return out +} + +// MonthlyEquivalent converts a per-period amount to its monthly figure: +// Weekly ×52/12, Biweekly ×26/12, Monthly ×1, Quarterly ÷3, Yearly ÷12, +// rounded half-up. +func MonthlyEquivalent(amount int, freq string) int { + switch freq { + case "Weekly": + return mulDivRound(amount, 52, 12) + case "Biweekly": + return mulDivRound(amount, 26, 12) + case "Quarterly": + return mulDivRound(amount, 1, 3) + case "Yearly": + return mulDivRound(amount, 1, 12) + default: // Monthly + return amount + } +} + +func mulDivRound(a int, num, den int64) int { + v := int64(a) * num + return int((2*v + den) / (2 * den)) +} + +// RecurringSummary is the commitment overview shown on the Recurring screen +// header and the Accounts upcoming card. +type RecurringSummary struct { + MonthlyIncome int // monthly-equivalent of enabled Income templates + MonthlyExpense int // monthly-equivalent of enabled Expense templates + MonthlyNet int // MonthlyIncome − MonthlyExpense (transfers excluded) + Active int + Paused int + DueCount int // occurrences due as of asOf, catch-up included + DueTotal int // sum of |amount| across due occurrences +} + +// RecurringSummaryFor computes the recurring commitment summary as of a date. +// Transfers count toward Active but not the monthly totals — they move money +// between accounts rather than in or out. +func (s *Store) RecurringSummaryFor(asOf int) RecurringSummary { + var sum RecurringSummary + for _, r := range s.recurring { + if !r.Enabled { + sum.Paused++ + continue + } + sum.Active++ + switch r.Kind { + case KindIncome: + sum.MonthlyIncome += MonthlyEquivalent(r.Amount, r.Freq) + case KindExpense: + sum.MonthlyExpense += MonthlyEquivalent(r.Amount, r.Freq) + } + } + sum.MonthlyNet = sum.MonthlyIncome - sum.MonthlyExpense + for _, o := range s.UpcomingRecurring(asOf, asOf) { + sum.DueCount++ + sum.DueTotal += absInt(o.Amount) + } + return sum +} + // ---- due / post ---- // DueItem is one occurrence of a recurring template that's due. The user either diff --git a/internal/core/recurring_test.go b/internal/core/recurring_test.go index ec356ba..b9928d4 100644 --- a/internal/core/recurring_test.go +++ b/internal/core/recurring_test.go @@ -155,6 +155,127 @@ func TestPostRecurringNowEarly(t *testing.T) { } } +func TestUpcomingRecurringProjection(t *testing.T) { + s := recurStore() + start := TodaySerial - 65 // ~2 months overdue + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "housing", + Amount: 140000, Payee: "Rent", Freq: "Monthly", NextDue: start, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindIncome, AcctA: "checking", AcctB: "salary", + Amount: 450000, Payee: "Payroll", Freq: "Monthly", NextDue: TodaySerial + 5, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "fun", + Amount: 1500, Payee: "Paused", Freq: "Weekly", NextDue: TodaySerial, Enabled: false}) + + occ := s.UpcomingRecurring(TodaySerial, TodaySerial+30) + if len(occ) < 4 { // >=3 overdue/due rent + 1 future rent-ish + payroll + t.Fatalf("expected >=4 occurrences, got %d", len(occ)) + } + for i, o := range occ { + if o.Payee == "Paused" { + t.Fatal("disabled template must not project") + } + if o.Due != (o.Date <= TodaySerial) { + t.Errorf("occurrence %d Due=%v for date %s", i, o.Due, FmtSerialDate(o.Date)) + } + if i > 0 && occ[i-1].Date > o.Date { + t.Fatal("occurrences not sorted by date") + } + if o.Date > TodaySerial+30 { + t.Fatalf("occurrence past until: %s", FmtSerialDate(o.Date)) + } + } + // Projection must not advance any schedule. + for _, r := range s.Recurrings() { + if r.Payee == "Rent" && r.NextDue != start { + t.Fatalf("projection mutated NextDue: %s", FmtSerialDate(r.NextDue)) + } + } +} + +func TestUpcomingRecurringBounds(t *testing.T) { + s := recurStore() + due := TodaySerial + 10 + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "housing", + Amount: 140000, Payee: "Rent", Freq: "Monthly", NextDue: due, Enabled: true}) + + if occ := s.UpcomingRecurring(TodaySerial, due-1); len(occ) != 0 { + t.Fatalf("until before NextDue: got %d occurrences, want 0", len(occ)) + } + occ := s.UpcomingRecurring(TodaySerial, due) + if len(occ) != 1 || occ[0].Date != due || occ[0].Due { + t.Fatalf("until on the day: got %+v, want 1 non-due occurrence on %s", occ, FmtSerialDate(due)) + } +} + +func TestMonthlyEquivalent(t *testing.T) { + cases := []struct { + freq string + amount int + want int + }{ + {"Monthly", 140000, 140000}, + {"Weekly", 1200, 5200}, // 1200*52/12 = 5200 + {"Biweekly", 1200, 2600}, // 1200*26/12 = 2600 + {"Quarterly", 30000, 10000}, + {"Yearly", 120000, 10000}, + {"Weekly", 1000, 4333}, // 52000/12 = 4333.33 → 4333 + {"Yearly", 100, 8}, // 8.33 → 8 + {"Quarterly", 100, 33}, // 33.33 → 33 + {"Yearly", 90, 8}, // 7.5 → 8 (half-up) + } + for _, c := range cases { + if got := MonthlyEquivalent(c.amount, c.freq); got != c.want { + t.Errorf("MonthlyEquivalent(%d, %s) = %d, want %d", c.amount, c.freq, got, c.want) + } + } +} + +func TestRecurringSummaryFor(t *testing.T) { + s := recurStore() + s.AddRecurring(Recurring{Kind: KindIncome, AcctA: "checking", AcctB: "salary", + Amount: 450000, Payee: "Payroll", Freq: "Monthly", NextDue: TodaySerial + 3, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "housing", + Amount: 140000, Payee: "Rent", Freq: "Monthly", NextDue: TodaySerial - 1, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "fun", + Amount: 30000, Payee: "Gym", Freq: "Quarterly", NextDue: TodaySerial + 40, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindTransfer, AcctA: "checking", AcctB: "savings", + Amount: 50000, Payee: "Savings", Freq: "Monthly", NextDue: TodaySerial, Enabled: true}) + s.AddRecurring(Recurring{Kind: KindExpense, AcctA: "checking", AcctB: "fun", + Amount: 9999, Payee: "Paused", Freq: "Monthly", NextDue: TodaySerial, Enabled: false}) + + sum := s.RecurringSummaryFor(TodaySerial) + if sum.MonthlyIncome != 450000 { + t.Errorf("MonthlyIncome = %d, want 450000", sum.MonthlyIncome) + } + if want := 140000 + 10000; sum.MonthlyExpense != want { // transfer + paused excluded + t.Errorf("MonthlyExpense = %d, want %d", sum.MonthlyExpense, want) + } + if sum.MonthlyNet != sum.MonthlyIncome-sum.MonthlyExpense { + t.Errorf("MonthlyNet = %d", sum.MonthlyNet) + } + if sum.Active != 4 || sum.Paused != 1 { + t.Errorf("Active/Paused = %d/%d, want 4/1", sum.Active, sum.Paused) + } + // Due now: rent (yesterday) + transfer (today). + if sum.DueCount != 2 || sum.DueTotal != 140000+50000 { + t.Errorf("DueCount/DueTotal = %d/%d, want 2/190000", sum.DueCount, sum.DueTotal) + } +} + +func TestRefreshToday(t *testing.T) { + orig := TodaySerial + defer func() { TodaySerial = orig }() + TodaySerial-- + if !RefreshToday() { + t.Fatal("RefreshToday should report a change after the date drifted") + } + if TodaySerial != orig { + t.Fatalf("TodaySerial = %d, want %d", TodaySerial, orig) + } + if RefreshToday() { + t.Fatal("RefreshToday should be a no-op when the date is current") + } +} + func TestRecurringPersistence(t *testing.T) { path := filepath.Join(t.TempDir(), "r.financy") s, err := NewDocument(path) diff --git a/internal/core/store.go b/internal/core/store.go index 6a80d79..8f05493 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -66,6 +66,16 @@ func todaySerial() int { return TimeToSerial(time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC)) } +// RefreshToday recomputes TodaySerial — the app can run across midnight — and +// reports whether the date changed. +func RefreshToday() bool { + if n := todaySerial(); n != TodaySerial { + TodaySerial = n + return true + } + return false +} + // NewStore creates an in-memory store seeded with default categories — used for // tests and ephemeral sessions. It is not persisted (db is nil). func NewStore() *Store { diff --git a/internal/ui/aliases.go b/internal/ui/aliases.go index 7889ce5..6bec512 100644 --- a/internal/ui/aliases.go +++ b/internal/ui/aliases.go @@ -15,6 +15,8 @@ type ( Account = core.Account Posting = core.Posting Transaction = core.Transaction + + modal = component.Modal ) const ( @@ -33,6 +35,8 @@ var ( panel = component.Panel sectionTitle = component.SectionTitle spacerH = component.SpacerH + spacerW = component.SpacerW + moneyColor = component.MoneyColor primaryButton = component.PrimaryButton secondaryButton = component.SecondaryButton ) @@ -45,6 +49,8 @@ var ( colText color.NRGBA colTextDim color.NRGBA colPrimary color.NRGBA + colPositive color.NRGBA + colNegative color.NRGBA ) func init() { syncPalette() } @@ -59,6 +65,8 @@ func syncPalette() { colText = style.Text colTextDim = style.TextDim colPrimary = style.Primary + colPositive = style.Positive + colNegative = style.Negative } // store is the single live data store, set up in Run. diff --git a/internal/ui/app.go b/internal/ui/app.go index b8f1e06..5b72f43 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -11,11 +11,15 @@ import ( // appController owns the live shell: it swaps the active screen and re-renders // the current screen whenever the store changes. type appController struct { - win fyne.Window - content *fyne.Container - currentUID string - statusLeft *canvas.Text - statusRight *canvas.Text + win fyne.Window + content *fyne.Container + sidebar fyne.CanvasObject // hidden while no document is open + currentUID string + // Sidebar footer figures, kept live by updateStatus. + sbCaption *canvas.Text + sbNet *canvas.Text + sbAssets *canvas.Text + sbLiab *canvas.Text } var ctl *appController @@ -39,15 +43,21 @@ func (c *appController) render() { c.renderWelcome() return } + if c.sidebar != nil { + c.sidebar.Show() + } highlightNav(c.currentUID) c.content.Objects = []fyne.CanvasObject{ - container.NewScroll(container.NewPadded(n.build())), + container.NewScroll(container.New(padCell(12, 16), n.build())), } c.content.Refresh() c.updateStatus() } func (c *appController) renderWelcome() { + if c.sidebar != nil { + c.sidebar.Hide() // no document → no screens to navigate + } highlightNav("") c.content.Objects = []fyne.CanvasObject{welcomeScreen()} c.content.Refresh() @@ -61,54 +71,40 @@ func (c *appController) refresh() { } } +// updateStatus refreshes the sidebar's net-worth footer from the open store. func (c *appController) updateStatus() { - if c.statusLeft != nil { - if store == nil { - c.statusLeft.Text = " No file open" - } else { - c.statusLeft.Text = " " + nav[c.currentUID].label - } - c.statusLeft.Refresh() + if c.sbNet == nil { + return } - if c.statusRight != nil { - if store == nil { - c.statusRight.Text = "" - } else { - c.statusRight.Text = "Assets: " + fmtMoney(store.TotalAssets()) + - " Liabilities: " + fmtMoney(store.TotalLiabilities()) + - " Net Worth: " + fmtMoney(store.NetWorth()) + " " - } - c.statusRight.Refresh() + if store == nil { + c.sbNet.Text = "No file open" + c.sbNet.Color = colTextDim + c.sbAssets.Text = "—" + c.sbLiab.Text = "—" + } else { + c.sbNet.Text = fmtMoney(store.NetWorth()) + c.sbNet.Color = moneyColor(store.NetWorth()) + c.sbAssets.Text = fmtMoney(store.TotalAssets()) + c.sbLiab.Text = fmtMoney(store.TotalLiabilities()) } + c.sbNet.Refresh() + c.sbAssets.Refresh() + c.sbLiab.Refresh() } -// assembleShell builds toolbar + status bar + content and returns the root. +// assembleShell builds sidebar + content and returns the root. func assembleShell(c *appController) fyne.CanvasObject { c.content = container.NewStack() - c.statusLeft = txt(" Ready", colTextDim, 11.5, false) - c.statusRight = txt("", colText, 11.5, true) - - toolbar := buildToolbar(c) - statusBar := buildStatusBar(c) + c.sidebar = buildSidebar(c) contentBG := container.NewStack(canvas.NewRectangle(colBG), c.content) - border := container.NewBorder(toolbar, statusBar, nil, nil, contentBG) + border := container.NewBorder(nil, nil, c.sidebar, nil, contentBG) // A floating tooltip layer sits above everything; it never intercepts input. initTooltipLayer() return container.NewStack(border, tooltipLayer) } -func buildStatusBar(c *appController) fyne.CanvasObject { - bar := canvas.NewRectangle(colSurfaceHi) - line := canvas.NewRectangle(colBorder) - line.SetMinSize(fyne.NewSize(0, 1)) - row := container.NewBorder(line, nil, - container.NewPadded(c.statusLeft), - container.NewPadded(c.statusRight)) - return container.NewStack(bar, row) -} - // quickAdd is the toolbar "+" — it always opens the add-transaction form so the // button means the same thing on every screen. Per-screen adds (accounts, // budget categories, …) live as dedicated buttons within those screens. diff --git a/internal/ui/chrome_test.go b/internal/ui/chrome_test.go index 8869ebf..434e4fe 100644 --- a/internal/ui/chrome_test.go +++ b/internal/ui/chrome_test.go @@ -5,7 +5,6 @@ import ( "testing" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/theme" ) @@ -51,42 +50,46 @@ func TestPromptNewDocument(t *testing.T) { assertUITextContains(t, d, "New document") } -// The toolbar builds and exposes the navigation buttons. -func TestBuildToolbar(t *testing.T) { +// The sidebar builds and registers the six navigation items. +func TestBuildSidebar(t *testing.T) { c, _ := newShellTest(t) - tb := buildToolbar(c) - // Tooltips/labels live on the tool buttons; ensure the bar assembles. - if tb == nil { - t.Fatal("buildToolbar returned nil") + sb := buildSidebar(c) + if sb == nil { + t.Fatal("buildSidebar returned nil") + } + if len(navItems) != 6 { + t.Fatalf("sidebar registered %d nav items, want 6", len(navItems)) + } + got := uiText(sb) + for _, want := range []string{"Financy", "New Transaction", "Accounts", "Transactions", + "Budget", "Recurring", "Debts", "Analytics", "Categories", "Preferences", "NET WORTH"} { + if !strings.Contains(got, want) { + t.Errorf("sidebar missing %q; text:\n%s", want, got) + } } } -// A tool button shows its tooltip on hover and hides it on mouse-out. -func TestToolButtonTooltip(t *testing.T) { +// The floating tooltip layer shows and hides on demand (charts drive it). +func TestTooltipLayer(t *testing.T) { _, _ = newShellTest(t) - b := newToolBtn(theme.ContentAddIcon(), "Add entry", func() {}) - w := test.NewWindow(b) - defer w.Close() - w.Resize(fyne.NewSize(120, 60)) - - b.MouseIn(nil) + showToolTip("hello", fyne.NewPos(10, 10)) if ttBox == nil || !ttBox.Visible() { - t.Error("tooltip not shown on MouseIn") + t.Error("tooltip not shown by showToolTip") } - b.MouseOut() + hideToolTip() if ttBox != nil && ttBox.Visible() { - t.Error("tooltip not hidden on MouseOut") + t.Error("tooltip not hidden by hideToolTip") } } -// Tapping a tool button fires its action. -func TestToolButtonTapped(t *testing.T) { +// Tapping a sidebar item fires its action. +func TestSideItemTapped(t *testing.T) { _, _ = newShellTest(t) fired := false - b := newToolBtn(theme.ContentAddIcon(), "Add", func() { fired = true }) + b := newSideItem(theme.ContentAddIcon(), "Add", func() { fired = true }) b.Tapped(nil) if !fired { - t.Error("tool button action did not fire on tap") + t.Error("sidebar item action did not fire on tap") } } diff --git a/internal/ui/component/components.go b/internal/ui/component/components.go index fd5df00..2065e2b 100644 --- a/internal/ui/component/components.go +++ b/internal/ui/component/components.go @@ -85,11 +85,12 @@ func (r *TappableRow) SetBordered() { } // appBar is the in-content header: title + subtitle on the left, action buttons -// on the right, on a chrome strip with a bottom rule. +// on the right. It sits directly on the window background — no chrome strip — +// so screens open with a clean, airy heading. func appBar(title, subtitle string, actions ...fyne.CanvasObject) fyne.CanvasObject { - left := container.NewVBox(txt(title, colText, 17, true)) + left := container.NewVBox(txt(title, colText, 20, true)) if subtitle != "" { - left = container.NewVBox(txt(title, colText, 17, true), txt(subtitle, colTextDim, 11, false)) + left = container.NewVBox(txt(title, colText, 20, true), spacerH(1), txt(subtitle, colTextDim, 11.5, false)) } right := container.NewHBox() for _, a := range actions { @@ -97,11 +98,8 @@ func appBar(title, subtitle string, actions ...fyne.CanvasObject) fyne.CanvasObj right.Add(a) } } - bg := canvas.NewRectangle(colBG) - rule := canvas.NewRectangle(colBorder) - rule.SetMinSize(fyne.NewSize(0, 1)) row := container.NewBorder(nil, nil, container.NewPadded(left), container.NewCenter(right)) - return container.NewBorder(nil, rule, nil, nil, container.NewStack(bg, row)) + return container.New(padCell(4, 0), row) } // primaryButton is a filled accent action button. diff --git a/internal/ui/component/modal.go b/internal/ui/component/modal.go new file mode 100644 index 0000000..2876217 --- /dev/null +++ b/internal/ui/component/modal.go @@ -0,0 +1,173 @@ +package component + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + + "fyne.io/fyne/v2/widget" + + "github.com/raihanstark/financy/internal/ui/style" +) + +// Modal is the app's replacement for Fyne's stock dialogs: a full-canvas +// overlay with a dimming scrim and a centered rounded card that matches the +// design system (surface fill, hairline border, soft halo). Content and +// footer buttons are supplied by the caller; Show/Hide manage the overlay. +type Modal struct { + widget.BaseWidget + cv fyne.Canvas + title string + content fyne.CanvasObject + buttons []fyne.CanvasObject + onClosed func() + cardSize fyne.Size // requested card size; zero → content-driven +} + +// NewModal builds a modal card titled title around content, shown on cv when +// Show is called. Footer buttons (right-aligned) are optional via SetButtons — +// content that carries its own action row simply doesn't set any. +func NewModal(cv fyne.Canvas, title string, content fyne.CanvasObject) *Modal { + m := &Modal{cv: cv, title: title, content: content} + m.ExtendBaseWidget(m) + return m +} + +// SetButtons puts actions in the card footer, right-aligned in given order. +func (m *Modal) SetButtons(buttons ...fyne.CanvasObject) { m.buttons = buttons } + +// SetCardSize requests a card size (clamped to the canvas and the content's +// minimum). Zero leaves the card sized to its content. +func (m *Modal) SetCardSize(s fyne.Size) { + m.cardSize = s + m.Refresh() +} + +// SetOnClosed registers fn to run whenever the modal is hidden. +func (m *Modal) SetOnClosed(fn func()) { m.onClosed = fn } + +// Show puts the modal on the canvas overlay stack, sized to the full canvas. +func (m *Modal) Show() { + if m.cv == nil { + return + } + m.BaseWidget.Show() + m.cv.Overlays().Add(m) + m.Resize(m.cv.Size()) +} + +// Hide removes the modal from the overlay stack and fires onClosed. +func (m *Modal) Hide() { + if m.cv != nil { + m.cv.Overlays().Remove(m) + } + m.BaseWidget.Hide() + if m.onClosed != nil { + m.onClosed() + } +} + +// Tapped swallows clicks on the scrim so the UI beneath stays inert. The modal +// intentionally does not dismiss on outside taps — same as the dialogs it +// replaces. +func (m *Modal) Tapped(_ *fyne.PointEvent) {} + +func (m *Modal) CreateRenderer() fyne.WidgetRenderer { + r := &modalRenderer{m: m} + r.scrim = canvas.NewRectangle(color.Transparent) + r.halo = canvas.NewRectangle(color.Transparent) + r.halo.CornerRadius = style.Radius + 6 + r.card = canvas.NewRectangle(colSurface) + r.card.StrokeWidth = 1 + r.card.CornerRadius = style.Radius + 2 + + r.titleTxt = txt(m.title, colText, 15, true) + header := container.New(padCell(14, 18), r.titleTxt) + body := container.New(layout.NewCustomPaddedLayout(0, 14, 18, 18), m.content) + + var footer fyne.CanvasObject + if len(m.buttons) > 0 { + row := container.NewHBox(layout.NewSpacer()) + for _, b := range m.buttons { + row.Add(b) + } + footer = container.New(layout.NewCustomPaddedLayout(0, 14, 18, 18), row) + } + r.inner = container.NewStack(r.card, container.NewBorder(header, footer, nil, nil, body)) + + r.applyColors() + return r +} + +type modalRenderer struct { + m *Modal + scrim *canvas.Rectangle + halo *canvas.Rectangle + card *canvas.Rectangle + titleTxt *canvas.Text + inner *fyne.Container +} + +func (r *modalRenderer) applyColors() { + r.scrim.FillColor = color.NRGBA{A: 0x5c} + haloA := uint8(0x16) + if style.Dark { + r.scrim.FillColor = color.NRGBA{A: 0x84} + haloA = 0x42 + } + r.halo.FillColor = color.NRGBA{A: haloA} + r.card.FillColor = colSurface + r.card.StrokeColor = colBorder + r.titleTxt.Color = colText +} + +// cardBounds computes the centered card rectangle for the given canvas size. +func (r *modalRenderer) cardBounds(size fyne.Size) (fyne.Position, fyne.Size) { + const margin = 24 + min := r.inner.MinSize() + w, h := min.Width, min.Height + if r.m.cardSize.Width > w { + w = r.m.cardSize.Width + } + if r.m.cardSize.Height > h { + h = r.m.cardSize.Height + } + if maxW := size.Width - 2*margin; w > maxW { + w = maxW + } + if maxH := size.Height - 2*margin; h > maxH { + h = maxH + } + pos := fyne.NewPos((size.Width-w)/2, (size.Height-h)/2) + return pos, fyne.NewSize(w, h) +} + +func (r *modalRenderer) Layout(size fyne.Size) { + r.scrim.Resize(size) + r.scrim.Move(fyne.NewPos(0, 0)) + pos, cs := r.cardBounds(size) + r.halo.Resize(fyne.NewSize(cs.Width+8, cs.Height+8)) + r.halo.Move(pos.SubtractXY(4, 4)) + r.inner.Resize(cs) + r.inner.Move(pos) +} + +func (r *modalRenderer) MinSize() fyne.Size { return r.inner.MinSize() } + +func (r *modalRenderer) Refresh() { + r.applyColors() + r.scrim.Refresh() + r.halo.Refresh() + r.card.Refresh() + r.titleTxt.Refresh() + r.Layout(r.m.Size()) +} + +func (r *modalRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.scrim, r.halo, r.inner} +} + +func (r *modalRenderer) Destroy() {} diff --git a/internal/ui/component/palette.go b/internal/ui/component/palette.go index 6eb28d1..d582854 100644 --- a/internal/ui/component/palette.go +++ b/internal/ui/component/palette.go @@ -9,7 +9,6 @@ import ( // Local aliases for the shared palette so component code stays concise. They are // copies of the active palette; SyncPalette refreshes them after a theme switch. var ( - colBG color.NRGBA colSurface color.NRGBA colSurfaceHi color.NRGBA colAltRow color.NRGBA @@ -26,7 +25,6 @@ func init() { SyncPalette() } // SyncPalette refreshes the local palette aliases from the active style palette. // Call it after style.SetDark so freshly built widgets pick up the new colors. func SyncPalette() { - colBG = style.BG colSurface = style.Surface colSurfaceHi = style.SurfaceHi colAltRow = style.AltRow diff --git a/internal/ui/component/widgets.go b/internal/ui/component/widgets.go index b2a1504..0ad038b 100644 --- a/internal/ui/component/widgets.go +++ b/internal/ui/component/widgets.go @@ -67,16 +67,16 @@ func moneyColor(amount int) color.Color { // statCard is a compact bordered summary tile: caption above, value below. func statCard(caption, value string, valueCol color.Color, sub string) fyne.CanvasObject { cap := txt(caption, colTextDim, 10.5, true) - val := txt(value, valueCol, 17, true) - items := []fyne.CanvasObject{cap, spacerH(2), val} + val := txt(value, valueCol, 19, true) + items := []fyne.CanvasObject{cap, spacerH(3), val} if sub != "" { - items = append(items, txt(sub, colTextDim, 10, false)) + items = append(items, spacerH(2), txt(sub, colTextDim, 10.5, false)) } bg := canvas.NewRectangle(colSurface) bg.StrokeColor = colBorder bg.StrokeWidth = 1 bg.CornerRadius = radius - return container.NewStack(bg, container.New(layout.NewCustomPaddedLayout(10, 10, 12, 12), container.NewVBox(items...))) + return container.NewStack(bg, container.New(layout.NewCustomPaddedLayout(12, 12, 14, 14), container.NewVBox(items...))) } // sectionTitle is a heading used above a block of content. @@ -114,12 +114,10 @@ func progressBar(ratio float64, col color.Color) fyne.CanvasObject { ratio = 1 } track := canvas.NewRectangle(colSurfaceHi) - track.StrokeColor = colBorder - track.StrokeWidth = 1 - track.CornerRadius = radiusSm - track.SetMinSize(fyne.NewSize(0, 14)) + track.CornerRadius = 5 + track.SetMinSize(fyne.NewSize(0, 10)) fill := canvas.NewRectangle(col) - fill.CornerRadius = radiusSm + fill.CornerRadius = 5 var filler fyne.CanvasObject if ratio <= 0 { filler = layout.NewSpacer() @@ -135,7 +133,7 @@ func progressBar(ratio float64, col color.Color) fyne.CanvasObject { // ratioLayout splits horizontal space between two children by weight. type ratioLayout struct{ leftW, rightW float32 } -func (r *ratioLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.NewSize(0, 14) } +func (r *ratioLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { return fyne.NewSize(0, 10) } func (r *ratioLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) { if len(objs) != 2 { return diff --git a/internal/ui/crypto.go b/internal/ui/crypto.go index 025d474..adf821a 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -6,7 +6,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" "github.com/raihanstark/financy/internal/core" @@ -30,12 +29,12 @@ func askPassword(title, message string, onOK func(pass string)) { items = append(items, widget.NewFormItem("", hint)) } items = append(items, widget.NewFormItem("Password", entry)) - d := dialog.NewForm(title, "Open", "Cancel", items, func(ok bool) { + d := newModalForm(title, "Open", "Cancel", items, func(ok bool) { if ok && entry.Text != "" { onOK(entry.Text) } - }, ctl.win) - d.Resize(fyne.NewSize(440, 0)) + }) + d.SetCardSize(fyne.NewSize(440, 0)) d.Show() ctl.win.Canvas().Focus(entry) } @@ -58,20 +57,20 @@ func askNewPassword(title, confirmLabel string, onOK func(pass string)) { widget.NewFormItem("Confirm", p2), widget.NewFormItem("", warn), } - d := dialog.NewForm(title, confirmLabel, "Cancel", items, func(ok bool) { + d := newModalForm(title, confirmLabel, "Cancel", items, func(ok bool) { if !ok { return } switch { case p1.Text == "": - dialog.ShowError(errors.New("password can't be empty"), ctl.win) + showError(errors.New("password can't be empty")) case p1.Text != p2.Text: - dialog.ShowError(errors.New("the passwords don't match"), ctl.win) + showError(errors.New("the passwords don't match")) default: onOK(p1.Text) } - }, ctl.win) - d.Resize(fyne.NewSize(440, 0)) + }) + d.SetCardSize(fyne.NewSize(440, 0)) d.Show() ctl.win.Canvas().Focus(p1) } @@ -86,14 +85,14 @@ func openEncryptedAt(path string) { if err != nil { switch { case errors.Is(err, core.ErrBadPassphrase): - dialog.ShowError(errors.New("incorrect password — please try again"), ctl.win) + showError(errors.New("incorrect password — please try again")) openEncryptedAt(path) // retry case errors.Is(err, core.ErrFileTooNew): - dialog.ShowInformation("Can't open this file", + showInfo("Can't open this file", filepath.Base(path)+" was created by a newer version of Financy than this one "+ - "(you're running v"+core.Version+"). Update Financy, then open it again.", ctl.win) + "(you're running v"+core.Version+"). Update Financy, then open it again.") default: - dialog.ShowError(err, ctl.win) + showError(err) } return } @@ -110,7 +109,7 @@ func doSave() { return } if err := store.Save(); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } updateTitle() @@ -124,12 +123,12 @@ func guardUnsaved(proceed func()) { proceed() return } - var d *dialog.CustomDialog + var d *modal msg := txt("You have unsaved changes. Save before continuing?", colText, 13, false) save := primaryButton("Save", nil, func() { d.Hide() if err := store.Save(); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } updateTitle() @@ -139,7 +138,7 @@ func guardUnsaved(proceed func()) { cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) content := container.NewVBox(msg, spacerH(14), container.NewCenter(container.NewHBox(cancel, discard, save))) - d = dialog.NewCustomWithoutButtons("Unsaved changes", content, ctl.win) + d = newModal("Unsaved changes", content) d.Show() } @@ -156,12 +155,12 @@ func doSetPassword() { } askNewPassword(title, label, func(pass string) { if err := store.SetPassword(pass); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } updateTitle() refreshMenu() - dialog.ShowInformation(title, "This document is now protected by a password.", ctl.win) + showInfo(title, "This document is now protected by a password.") }) } @@ -170,19 +169,16 @@ func doRemovePassword() { if store == nil || !store.Encrypted() { return } - dialog.ShowConfirm("Remove Password", + showConfirm("Remove Password", "Remove encryption from this document? It will be stored unencrypted on disk.", - func(ok bool) { - if !ok { - return - } + "Remove", func() { if err := store.RemovePassword(); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } updateTitle() refreshMenu() - }, ctl.win) + }) } // updateTitle refreshes the window title so the unsaved-changes marker tracks diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go new file mode 100644 index 0000000..dd188eb --- /dev/null +++ b/internal/ui/dialogs.go @@ -0,0 +1,85 @@ +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" + + "github.com/raihanstark/financy/internal/ui/component" +) + +// Shell-level modal helpers mirroring the view package's — every dialog the +// chrome opens (setup, updates, passwords, alerts) uses the same rounded +// component.Modal card as the screens. + +// newModal wraps content in a bare modal card; the content brings its own +// action row. Callers Show() it themselves. +func newModal(title string, content fyne.CanvasObject) *modal { + return component.NewModal(ctl.win.Canvas(), title, content) +} + +// newModalForm mirrors dialog.NewForm: labeled form items with validation — +// confirm only proceeds (and closes) when every item validates. +func newModalForm(title, confirmLabel, cancelLabel string, items []*widget.FormItem, cb func(bool)) *modal { + form := widget.NewForm(items...) + m := newModal(title, form) + m.SetButtons( + secondaryButton(cancelLabel, nil, func() { + m.Hide() + cb(false) + }), + primaryButton(confirmLabel, nil, func() { + if form.Validate() != nil { + return + } + m.Hide() + cb(true) + }), + ) + return m +} + +// wrapLabel is a word-wrapped message body for info/error modals. +func wrapLabel(msg string) fyne.CanvasObject { + l := widget.NewLabel(msg) + l.Wrapping = fyne.TextWrapWord + return l +} + +func showInfo(title, msg string) { + if ctl == nil || ctl.win == nil { + return + } + m := newModal(title, wrapLabel(msg)) + m.SetButtons(secondaryButton("OK", nil, func() { m.Hide() })) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() +} + +// showError presents err in a modal alert — the in-app stand-in for +// dialog.ShowError. +func showError(err error) { + if err == nil || ctl == nil || ctl.win == nil { + return + } + m := newModal("Error", wrapLabel(err.Error())) + m.SetButtons(secondaryButton("OK", nil, func() { m.Hide() })) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() +} + +// showConfirm asks a yes/no question; onYes runs only on confirm. +func showConfirm(title, msg, confirmLabel string, onYes func()) { + if ctl == nil || ctl.win == nil { + return + } + m := newModal(title, wrapLabel(msg)) + m.SetButtons( + secondaryButton("Cancel", nil, func() { m.Hide() }), + primaryButton(confirmLabel, nil, func() { + m.Hide() + onYes() + }), + ) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() +} diff --git a/internal/ui/document.go b/internal/ui/document.go index ee19391..643057c 100644 --- a/internal/ui/document.go +++ b/internal/ui/document.go @@ -47,7 +47,7 @@ func useStore(s *core.Store, path string) { store.Subscribe(ctl.refresh) store.Subscribe(updateTitle) // keep the unsaved-changes marker current store.SetErrorHandler(func(err error) { - dialog.ShowError(err, ctl.win) + showError(err) }) } if ctl.win != nil { @@ -74,7 +74,7 @@ func useStore(s *core.Store, path string) { // version still reads it if they ever need to downgrade. func openDocumentAt(path string) { if enc, err := core.IsEncrypted(path); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } else if enc { openEncryptedAt(path) @@ -98,10 +98,10 @@ func openDocumentAt(path string) { "this one (you're running v" + core.Version + ").\n\n" + "Update Financy to the latest version, then open the file again. " + "It has not been changed." - dialog.ShowInformation("Can't open this file", msg, ctl.win) + showInfo("Can't open this file", msg) return } - dialog.ShowError(err, ctl.win) + showError(err) return } useStore(s, path) @@ -114,7 +114,7 @@ func openDocumentAt(path string) { } else { msg += "Keep it if you may want to downgrade to an older version of Financy." } - dialog.ShowInformation("File upgraded", msg, ctl.win) + showInfo("File upgraded", msg) } } @@ -149,10 +149,10 @@ func doSaveCopy() { _ = w.Close() _ = os.Remove(path) // VACUUM INTO requires a non-existent target if err := store.SaveCopy(path); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } - dialog.ShowInformation("Saved a Copy", "Copy written to:\n"+path, ctl.win) + showInfo("Saved a Copy", "Copy written to:\n"+path) }, ctl.win) d.SetFileName("copy.financy") d.Show() @@ -165,7 +165,7 @@ func doExportCSV() { } data, err := store.ExportCSV() if err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } d := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) { @@ -174,10 +174,10 @@ func doExportCSV() { } defer func() { _ = w.Close() }() if _, err := w.Write(data); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } - dialog.ShowInformation("Exported", "Transactions exported to:\n"+w.URI().Path(), ctl.win) + showInfo("Exported", "Transactions exported to:\n"+w.URI().Path()) }, ctl.win) d.SetFileName(exportFileName()) d.SetFilter(storage.NewExtensionFileFilter([]string{".csv"})) diff --git a/internal/ui/duetick.go b/internal/ui/duetick.go new file mode 100644 index 0000000..8c682e2 --- /dev/null +++ b/internal/ui/duetick.go @@ -0,0 +1,31 @@ +package ui + +import ( + "time" + + "fyne.io/fyne/v2" + + "github.com/raihanstark/financy/internal/core" + "github.com/raihanstark/financy/internal/ui/view" +) + +// startDueTicker re-evaluates the date and due recurring entries once a minute, +// so an app left running notices midnight rollover and newly due items without +// the document being reopened. It only ever *shows* the existing review prompt; +// nothing posts without confirmation. +func startDueTicker() { + go func() { + for range time.NewTicker(time.Minute).C { // app-lifetime; never stopped + fyne.Do(func() { + if store == nil || ctl == nil || ctl.win == nil { + return + } + if core.RefreshToday() { + view.SyncToday() + ctl.refresh() // due tints and time buckets are date-derived + } + view.RecheckRecurringDue() + }) + } + }() +} diff --git a/internal/ui/run.go b/internal/ui/run.go index 3d09dec..13036a4 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -61,7 +61,7 @@ func Run(icon fyne.Resource) { if icon != nil { w.SetIcon(icon) } - w.Resize(fyne.NewSize(1320, 860)) + w.Resize(fyne.NewSize(1420, 880)) ctl = &appController{win: w} w.SetContent(assembleShell(ctl)) @@ -76,6 +76,7 @@ func Run(icon fyne.Resource) { startup() // opens last-used or a default document, then renders + startDueTicker() // date rollover + newly due recurring entries checkForUpdatesAsync(false) // silent, throttled background check w.ShowAndRun() diff --git a/internal/ui/setup.go b/internal/ui/setup.go index c791f97..506cea7 100644 --- a/internal/ui/setup.go +++ b/internal/ui/setup.go @@ -68,14 +68,14 @@ func showSetup() { labeledRow("Confirm", pw2), ) - var d *dialog.CustomDialog + var d *modal cont := primaryButton("Continue…", nil, func() { demo := mode.Selected == setupDemo cur := currency.Selected pass := "" if !demo { if pw.Text != pw2.Text { - dialog.ShowError(errors.New("the passwords don't match"), ctl.win) + showError(errors.New("the passwords don't match")) return } pass = pw.Text @@ -86,8 +86,8 @@ func showSetup() { cancel := secondaryButton("Close", nil, func() { d.Hide() }) content := container.NewVBox(body, spacerH(16), container.NewCenter(container.NewHBox(cancel, cont))) - d = dialog.NewCustomWithoutButtons("Setup", content, ctl.win) - d.Resize(fyne.NewSize(480, 420)) + d = newModal("Setup", content) + d.SetCardSize(fyne.NewSize(480, 420)) d.Show() } @@ -115,10 +115,10 @@ func promptNewDocument() { labeledRow("Confirm", pw2), ) - var d *dialog.CustomDialog + var d *modal cont := primaryButton("Continue…", nil, func() { if pw.Text != pw2.Text { - dialog.ShowError(errors.New("the passwords don't match"), ctl.win) + showError(errors.New("the passwords don't match")) return } cur := currency.Selected @@ -129,8 +129,8 @@ func promptNewDocument() { cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) content := container.NewVBox(body, spacerH(16), container.NewCenter(container.NewHBox(cancel, cont))) - d = dialog.NewCustomWithoutButtons("New", content, ctl.win) - d.Resize(fyne.NewSize(460, 320)) + d = newModal("New", content) + d.SetCardSize(fyne.NewSize(460, 320)) d.Show() } @@ -155,7 +155,7 @@ func promptCreateDocument(demo bool, currency, passphrase string) { s, e = core.NewDocument(path) } if e != nil { - dialog.ShowError(e, ctl.win) + showError(e) return } if demo { @@ -166,7 +166,7 @@ func promptCreateDocument(demo bool, currency, passphrase string) { if s.Encrypted() { // Persist the seed/currency so the encrypted file on disk is complete. if err := s.Save(); err != nil { - dialog.ShowError(err, ctl.win) + showError(err) return } } diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 5aa8694..83c95b4 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -13,9 +13,9 @@ import ( "github.com/raihanstark/financy/internal/ui/style" ) -// newShellTest spins up the full application shell (toolbar + status bar + -// content) wired to a demo-seeded document, exactly as Run does, and returns the -// controller and its window. Package globals are reset on cleanup. +// newShellTest spins up the full application shell (sidebar + content) wired to +// a demo-seeded document, exactly as Run does, and returns the controller and +// its window. Package globals are reset on cleanup. func newShellTest(t *testing.T) (*appController, fyne.Window) { t.Helper() a := test.NewApp() @@ -97,7 +97,7 @@ func TestShellNavigatesAllScreens(t *testing.T) { c, _ := newShellTest(t) want := map[string]string{ "accounts": "NET WORTH", - "transactions": "Journal", + "transactions": "SHOWING", "analytics": "SAVINGS RATE", "recurring": "Recurring", } @@ -112,18 +112,29 @@ func TestShellNavigatesAllScreens(t *testing.T) { } } -// The status bar reflects the active screen and the live net-worth figure. -func TestShellStatusBar(t *testing.T) { +// The sidebar footer shows the live net-worth, assets and liabilities figures. +func TestShellSidebarFooter(t *testing.T) { c, _ := newShellTest(t) c.show("transactions") - if !strings.Contains(c.statusLeft.Text, "Transactions") { - t.Errorf("status left = %q, want it to mention Transactions", c.statusLeft.Text) + if c.sbNet.Text != fmtMoney(store.NetWorth()) { + t.Errorf("footer net worth = %q, want %q", c.sbNet.Text, fmtMoney(store.NetWorth())) } - if !strings.Contains(c.statusRight.Text, "Net Worth") { - t.Errorf("status right = %q, want a Net Worth figure", c.statusRight.Text) + if c.sbAssets.Text != fmtMoney(store.TotalAssets()) { + t.Errorf("footer assets = %q, want %q", c.sbAssets.Text, fmtMoney(store.TotalAssets())) } - if !strings.Contains(c.statusRight.Text, fmtMoney(store.NetWorth())) { - t.Errorf("status right net worth doesn't match store") + if c.sbLiab.Text != fmtMoney(store.TotalLiabilities()) { + t.Errorf("footer liabilities = %q, want %q", c.sbLiab.Text, fmtMoney(store.TotalLiabilities())) + } +} + +// The sidebar highlights the active screen's nav item and only that one. +func TestShellSidebarHighlight(t *testing.T) { + c, _ := newShellTest(t) + c.show("budget") + for _, b := range navItems { + if want := b.uid == "budget"; b.active != want { + t.Errorf("nav item %q active = %v, want %v", b.uid, b.active, want) + } } } @@ -156,15 +167,27 @@ func TestShellQuickAdd(t *testing.T) { } } -// With no document open the shell shows the welcome screen and an empty status. +// With no document open the shell shows the welcome screen and an empty status, +// and the sidebar hides — there are no screens to navigate without a file. func TestShellWelcomeWhenNoStore(t *testing.T) { c, _ := newShellTest(t) useStore(nil, "") if c.currentUID != "" { t.Errorf("currentUID = %q after closing document, want empty", c.currentUID) } - if !strings.Contains(c.statusLeft.Text, "No file open") { - t.Errorf("status left = %q, want 'No file open'", c.statusLeft.Text) + if !strings.Contains(c.sbNet.Text, "No file open") { + t.Errorf("sidebar footer = %q, want 'No file open'", c.sbNet.Text) + } + if c.sidebar.Visible() { + t.Error("sidebar should be hidden while no document is open") + } + + // Opening a document brings it back. + s := core.NewStore() + core.SeedDemo(s, "$") + useStore(s, "") + if !c.sidebar.Visible() { + t.Error("sidebar should be visible again once a document opens") } } diff --git a/internal/ui/shot.go b/internal/ui/shot.go index 1796b9b..d9766ac 100644 --- a/internal/ui/shot.go +++ b/internal/ui/shot.go @@ -4,6 +4,7 @@ import ( "image" "image/png" "os" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/test" @@ -18,8 +19,17 @@ import ( func runShots(outDir string) { _ = os.MkdirAll(outDir, 0o755) + // FINANCY_SHOT_DARK=1 captures the dark palette instead (handy for eyeballing + // theme changes; the docs embed the light set). + applyPalette(os.Getenv("FINANCY_SHOT_DARK") == "1") + a := test.NewApp() a.Settings().SetTheme(style.Theme{}) + // SetTheme schedules an async theme-apply on the Fyne goroutine. Give it a + // beat to drain while no canvases exist yet — otherwise it walks the window + // we're about to build concurrently with its first layout, racing the SVG + // icon decode in canvas.Image (nil-decoder panic). + time.Sleep(150 * time.Millisecond) c := &appController{} ctl = c @@ -27,7 +37,7 @@ func runShots(outDir string) { w := test.NewWindow(root) c.win = w view.Init(store, c.show, c.refresh, w) - w.Resize(fyne.NewSize(1320, 860)) + w.Resize(fyne.NewSize(1420, 880)) capture := func(name string) { writePNG(outDir+"/"+name+".png", w.Canvas().Capture()) } dropOverlay := func() { w.Canvas().Overlays().Remove(w.Canvas().Overlays().Top()) } diff --git a/internal/ui/sidebar.go b/internal/ui/sidebar.go new file mode 100644 index 0000000..d165cc9 --- /dev/null +++ b/internal/ui/sidebar.go @@ -0,0 +1,227 @@ +package ui + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/raihanstark/financy/internal/ui/view" +) + +// sidebarWidth is the fixed width of the navigation rail. +const sidebarWidth float32 = 224 + +// ---- sidebar item ---- + +// sideItem is a full-width sidebar row: icon + label with a hover highlight and +// a primary-tinted pill when it is the active screen. +type sideItem struct { + widget.BaseWidget + icon fyne.Resource + label string + uid string // nav target, "" for non-nav actions + action func() + bg *canvas.Rectangle + lbl *canvas.Text + // Both icon tints are built up front and toggled via Show/Hide — calling + // SetResource on a live widget.Icon re-runs the SVG decode, which is the + // slow path we avoid on every nav change. + ic *widget.Icon + icActive *widget.Icon + active bool +} + +// navItems collects the screen-switching sidebar items so the active one can be +// highlighted as the user moves between screens. +var navItems []*sideItem + +func newSideItem(icon fyne.Resource, label string, action func()) *sideItem { + b := &sideItem{icon: icon, label: label, action: action} + b.bg = canvas.NewRectangle(color.Transparent) + b.bg.CornerRadius = 8 + b.bg.SetMinSize(fyne.NewSize(0, 34)) + b.lbl = txt(label, colText, 13, false) + b.ic = widget.NewIcon(icon) + b.icActive = widget.NewIcon(theme.NewPrimaryThemedResource(icon)) + b.icActive.Hide() + b.ExtendBaseWidget(b) + return b +} + +// newSideNav is a sidebar item that switches to a screen and shows an active +// state. Registered in navItems so highlightNav can manage the selection. +func newSideNav(icon fyne.Resource, label, uid string, action func()) *sideItem { + b := newSideItem(icon, label, action) + b.uid = uid + navItems = append(navItems, b) + return b +} + +func (b *sideItem) CreateRenderer() fyne.WidgetRenderer { + icCell := container.NewGridWrap(fyne.NewSize(18, 18), container.NewStack(b.ic, b.icActive)) + row := container.NewHBox(spacerW(6), container.NewCenter(icCell), container.NewCenter(b.lbl)) + return widget.NewSimpleRenderer(container.NewStack(b.bg, row)) +} + +// restFill is the item's background when not hovered — tinted if it's the +// active screen, otherwise transparent. +func (b *sideItem) restFill() color.Color { + if b.active { + return withAlpha(colPrimary, 0x1f) + } + return color.Transparent +} + +func (b *sideItem) setActive(on bool) { + if b.active == on { + return + } + b.active = on + b.bg.FillColor = b.restFill() + if on { + b.lbl.Color = colPrimary + b.lbl.TextStyle = fyne.TextStyle{Bold: true} + b.ic.Hide() + b.icActive.Show() + } else { + b.lbl.Color = colText + b.lbl.TextStyle = fyne.TextStyle{} + b.icActive.Hide() + b.ic.Show() + } + b.bg.Refresh() + b.lbl.Refresh() +} + +func (b *sideItem) Tapped(_ *fyne.PointEvent) { + if b.action != nil { + b.action() + } +} + +func (b *sideItem) MouseIn(_ *desktop.MouseEvent) { + if !b.active { + b.bg.FillColor = colSurfaceHi + b.bg.Refresh() + } +} + +func (b *sideItem) MouseMoved(_ *desktop.MouseEvent) {} + +func (b *sideItem) MouseOut() { + b.bg.FillColor = b.restFill() + b.bg.Refresh() +} + +func (b *sideItem) Cursor() desktop.Cursor { return desktop.PointerCursor } + +// highlightNav marks the sidebar item for uid as active and clears the rest. +func highlightNav(uid string) { + for _, b := range navItems { + b.setActive(b.uid == uid) + } +} + +// ---- sidebar assembly ---- + +// buildSidebar assembles the left navigation rail: brand, the New Transaction +// primary action, labeled nav items, utilities and a live net-worth footer. +func buildSidebar(c *appController) fyne.CanvasObject { + navItems = nil + + brand := buildBrand() + newBtn := primaryButton("New Transaction", theme.ContentAddIcon(), func() { quickAdd(c) }) + + nav := container.NewVBox( + newSideNav(iconAccounts(), "Accounts", "accounts", func() { c.show("accounts") }), + newSideNav(iconTransactions(), "Transactions", "transactions", func() { c.show("transactions") }), + newSideNav(iconBudget(), "Budget", "budget", func() { c.show("budget") }), + newSideNav(iconRecurring(), "Recurring", "recurring", func() { c.show("recurring") }), + newSideNav(iconDebts(), "Debts", "debts", func() { c.show("debts") }), + newSideNav(iconAnalytics(), "Analytics", "analytics", func() { c.show("analytics") }), + ) + + utilities := container.NewVBox( + newSideItem(theme.ListIcon(), "Categories", func() { + if store != nil { + view.OpenConfig(1) + } + }), + newSideItem(theme.SettingsIcon(), "Preferences", func() { + if store != nil { + view.OpenConfig(0) + } + }), + newSideItem(theme.ColorPaletteIcon(), themeToggleLabel(), toggleTheme), + ) + + top := container.NewVBox( + spacerH(14), + brand, + spacerH(14), + container.New(padCell(0, 10), newBtn), + spacerH(10), + ) + bottom := container.NewVBox( + sideRule(), + spacerH(8), + container.New(padCell(0, 6), utilities), + spacerH(10), + container.New(padCell(0, 10), buildNetWorthFooter(c)), + spacerH(12), + ) + mid := container.NewVScroll(container.New(padCell(0, 6), nav)) + + inner := container.NewBorder(top, bottom, nil, nil, mid) + + bg := canvas.NewRectangle(colSurface) + width := canvas.NewRectangle(color.Transparent) + width.SetMinSize(fyne.NewSize(sidebarWidth, 0)) + rightLine := canvas.NewRectangle(colBorder) + rightLine.SetMinSize(fyne.NewSize(1, 0)) + + side := container.NewStack(bg, width, inner) + return container.NewBorder(nil, nil, nil, rightLine, side) +} + +// buildBrand is the logo mark + wordmark at the top of the sidebar. +func buildBrand() fyne.CanvasObject { + mark := canvas.NewRectangle(colPrimary) + mark.CornerRadius = 8 + letter := txt("F", color.White, 14, true) + markCell := container.NewGridWrap(fyne.NewSize(26, 26), + container.NewStack(mark, container.NewCenter(letter))) + name := txt("Financy", colText, 16.5, true) + return container.NewHBox(spacerW(10), container.NewCenter(markCell), container.NewCenter(name)) +} + +// sideRule is a hairline separator inset from the sidebar edges. +func sideRule() fyne.CanvasObject { + line := canvas.NewRectangle(colBorder) + line.SetMinSize(fyne.NewSize(0, 1)) + return container.New(padCell(0, 10), line) +} + +// buildNetWorthFooter is the live summary tile pinned to the bottom of the +// sidebar; updateStatus keeps its figures current. +func buildNetWorthFooter(c *appController) fyne.CanvasObject { + c.sbCaption = txt("NET WORTH", colTextDim, 10, true) + c.sbNet = txt("—", colText, 16, true) + c.sbAssets = txt("", colPositive, 10.5, false) + c.sbAssets.Alignment = fyne.TextAlignTrailing + c.sbLiab = txt("", colNegative, 10.5, false) + c.sbLiab.Alignment = fyne.TextAlignTrailing + + assetsRow := container.NewBorder(nil, nil, txt("Assets", colTextDim, 10.5, false), nil, c.sbAssets) + liabRow := container.NewBorder(nil, nil, txt("Liabilities", colTextDim, 10.5, false), nil, c.sbLiab) + + bg := canvas.NewRectangle(colSurfaceHi) + bg.CornerRadius = 10 + box := container.NewVBox(c.sbCaption, spacerH(2), c.sbNet, spacerH(8), assetsRow, spacerH(2), liabRow) + return container.NewStack(bg, container.New(padCell(10, 12), box)) +} diff --git a/internal/ui/style/style.go b/internal/ui/style/style.go index 6f8c0e2..24bd363 100644 --- a/internal/ui/style/style.go +++ b/internal/ui/style/style.go @@ -38,36 +38,37 @@ type palette struct { Positive, Negative, Warning color.NRGBA } -// Light — a clean, modern light look: soft off-white chrome, crisp white -// surfaces, hairline borders and a confident blue accent. +// Light — a clean, modern light look: soft cool-gray chrome, crisp white +// surfaces, hairline borders and a confident indigo accent. Money colors are +// emerald/rose rather than raw green/red so figures read calm, not alarming. var light = palette{ - BG: color.NRGBA{R: 0xf5, G: 0xf6, B: 0xf8, A: 0xff}, + BG: color.NRGBA{R: 0xf6, G: 0xf7, B: 0xf9, A: 0xff}, Surface: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, - SurfaceHi: color.NRGBA{R: 0xed, G: 0xf0, B: 0xf4, A: 0xff}, - AltRow: color.NRGBA{R: 0xf7, G: 0xf9, B: 0xfb, A: 0xff}, - Border: color.NRGBA{R: 0xe2, G: 0xe6, B: 0xec, A: 0xff}, - Text: color.NRGBA{R: 0x16, G: 0x19, B: 0x1d, A: 0xff}, + SurfaceHi: color.NRGBA{R: 0xee, G: 0xf0, B: 0xf4, A: 0xff}, + AltRow: color.NRGBA{R: 0xf8, G: 0xf9, B: 0xfb, A: 0xff}, + Border: color.NRGBA{R: 0xe4, G: 0xe7, B: 0xec, A: 0xff}, + Text: color.NRGBA{R: 0x11, G: 0x18, B: 0x27, A: 0xff}, TextDim: color.NRGBA{R: 0x6b, G: 0x72, B: 0x80, A: 0xff}, - Primary: color.NRGBA{R: 0x25, G: 0x63, B: 0xeb, A: 0xff}, - Positive: color.NRGBA{R: 0x15, G: 0x80, B: 0x3d, A: 0xff}, - Negative: color.NRGBA{R: 0xdc, G: 0x26, B: 0x26, A: 0xff}, - Warning: color.NRGBA{R: 0xb4, G: 0x53, B: 0x09, A: 0xff}, + Primary: color.NRGBA{R: 0x4f, G: 0x46, B: 0xe5, A: 0xff}, // indigo + Positive: color.NRGBA{R: 0x05, G: 0x96, B: 0x69, A: 0xff}, // emerald + Negative: color.NRGBA{R: 0xe1, G: 0x1d, B: 0x48, A: 0xff}, // rose + Warning: color.NRGBA{R: 0xd9, G: 0x77, B: 0x06, A: 0xff}, // amber } -// Dark — a deep slate look mirroring the light palette: near-black chrome, -// raised slate surfaces, hairline borders and brighter accents tuned to read -// well on a dark ground. +// Dark — a deep indigo-tinted slate mirroring the light palette: near-black +// chrome, raised slate surfaces, hairline borders and brighter accents tuned to +// read well on a dark ground. var dark = palette{ - BG: color.NRGBA{R: 0x0f, G: 0x11, B: 0x15, A: 0xff}, // window chrome - Surface: color.NRGBA{R: 0x1a, G: 0x1d, B: 0x23, A: 0xff}, // cards / panels / inputs - SurfaceHi: color.NRGBA{R: 0x23, G: 0x27, B: 0x2f, A: 0xff}, // header / hover / toolbar - AltRow: color.NRGBA{R: 0x1e, G: 0x22, B: 0x29, A: 0xff}, // alternating grid row - Border: color.NRGBA{R: 0x2d, G: 0x33, B: 0x3c, A: 0xff}, // hairline borders / grid lines - Text: color.NRGBA{R: 0xe6, G: 0xe8, B: 0xec, A: 0xff}, // primary text - TextDim: color.NRGBA{R: 0x97, G: 0x9e, B: 0xab, A: 0xff}, // muted captions - Primary: color.NRGBA{R: 0x3b, G: 0x82, B: 0xf6, A: 0xff}, // brighter blue accent - Positive: color.NRGBA{R: 0x4a, G: 0xde, B: 0x80, A: 0xff}, // light emerald - Negative: color.NRGBA{R: 0xf8, G: 0x71, B: 0x71, A: 0xff}, // light red + BG: color.NRGBA{R: 0x0b, G: 0x0d, B: 0x12, A: 0xff}, // window chrome + Surface: color.NRGBA{R: 0x15, G: 0x18, B: 0x21, A: 0xff}, // cards / panels / inputs + SurfaceHi: color.NRGBA{R: 0x1f, G: 0x23, B: 0x30, A: 0xff}, // header / hover / sidebar + AltRow: color.NRGBA{R: 0x19, G: 0x1d, B: 0x28, A: 0xff}, // alternating grid row + Border: color.NRGBA{R: 0x2a, G: 0x2f, B: 0x3d, A: 0xff}, // hairline borders / grid lines + Text: color.NRGBA{R: 0xe7, G: 0xe9, B: 0xee, A: 0xff}, // primary text + TextDim: color.NRGBA{R: 0x9a, G: 0xa1, B: 0xb2, A: 0xff}, // muted captions + Primary: color.NRGBA{R: 0x81, G: 0x8c, B: 0xf8, A: 0xff}, // light indigo accent + Positive: color.NRGBA{R: 0x34, G: 0xd3, B: 0x99, A: 0xff}, // light emerald + Negative: color.NRGBA{R: 0xfb, G: 0x71, B: 0x85, A: 0xff}, // light rose Warning: color.NRGBA{R: 0xfb, G: 0xbf, B: 0x24, A: 0xff}, // light amber } @@ -94,8 +95,8 @@ func SetDark(on bool) { // Corner radii used across the custom canvas widgets (cards, chips, banners). const ( - Radius float32 = 10 // cards / panels - RadiusSm float32 = 6 // chips / badges / small controls + Radius float32 = 12 // cards / panels + RadiusSm float32 = 7 // chips / badges / small controls ) // Theme implements fyne.Theme with the active palette above. @@ -167,9 +168,9 @@ func (Theme) Size(name fyne.ThemeSizeName) float32 { case theme.SizeNameSubHeadingText: return 15 case theme.SizeNameInputRadius: - return 8 + return 9 case theme.SizeNameSelectionRadius: - return 6 + return 7 case theme.SizeNameScrollBar: return 12 case theme.SizeNameScrollBarSmall: diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 45f4fb2..1f249e6 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -19,13 +19,13 @@ func applyPalette(dark bool) { view.SyncPalette() } -// themeToggleLabel is the tooltip for the toolbar theme button — it names the -// mode the button switches to. +// themeToggleLabel is the label for the sidebar theme item — it names the mode +// the item switches to. func themeToggleLabel() string { if style.Dark { - return "Switch to light mode" + return "Light mode" } - return "Switch to dark mode" + return "Dark mode" } // toggleTheme flips between the light and dark palettes and rebuilds the window diff --git a/internal/ui/toolbar.go b/internal/ui/toolbar.go deleted file mode 100644 index 3b666eb..0000000 --- a/internal/ui/toolbar.go +++ /dev/null @@ -1,220 +0,0 @@ -package ui - -import ( - "image/color" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - - "github.com/raihanstark/financy/internal/ui/component" - "github.com/raihanstark/financy/internal/ui/view" -) - -// ---- tooltip layer ---- -// -// Fyne 2.7 has no built-in tooltips, so we keep a tiny floating bubble in a -// no-layout layer at the root of the window and position it under whatever -// toolbar button is being hovered. - -var ( - tooltipLayer *fyne.Container - ttBox *fyne.Container - ttText *canvas.Text -) - -func initTooltipLayer() { - ttText = canvas.NewText("", color.NRGBA{R: 0xf2, G: 0xf4, B: 0xf6, A: 0xff}) - ttText.TextSize = 11.5 - bg := canvas.NewRectangle(color.NRGBA{R: 0x2a, G: 0x2f, B: 0x35, A: 0xff}) - bg.CornerRadius = 4 - ttBox = container.NewStack(bg, container.New(padCell(3, 8), ttText)) - ttBox.Hide() - tooltipLayer = container.NewWithoutLayout(ttBox) - - // Let interactive components (charts) drive the same floating tooltip. - component.ShowTooltip = showToolTip - component.HideTooltip = hideToolTip -} - -func showToolTip(text string, pos fyne.Position) { - if ttBox == nil { - return - } - ttText.Text = text - ttText.Refresh() - sz := ttBox.MinSize() - ttBox.Resize(sz) - - // Keep the bubble inside the window — important for far-right buttons. - // pos is in canvas-absolute coordinates (from AbsolutePositionForObject or a - // mouse event's AbsolutePosition), so clamp against the canvas here. - if ctl != nil && ctl.win != nil { - const margin = 6 - cs := ctl.win.Canvas().Size() - if pos.X+sz.Width > cs.Width-margin { - pos.X = cs.Width - sz.Width - margin - } - if pos.X < margin { - pos.X = margin - } - if pos.Y+sz.Height > cs.Height-margin { - pos.Y = cs.Height - sz.Height - margin - } - if pos.Y < margin { - pos.Y = margin - } - } - - // ttBox.Move is relative to the tooltip layer, which can sit below the canvas - // origin (e.g. the main menu bar pushes the content down). Convert the - // canvas-absolute position into layer-local coords so the bubble lands exactly - // at the cursor rather than menu-height too low. - origin := fyne.CurrentApp().Driver().AbsolutePositionForObject(tooltipLayer) - ttBox.Move(pos.Subtract(origin)) - ttBox.Show() - ttBox.Refresh() -} - -func hideToolTip() { - if ttBox != nil { - ttBox.Hide() - } -} - -// ---- toolbar button ---- - -type toolBtn struct { - widget.BaseWidget - icon fyne.Resource - label string - uid string // nav target, "" for non-nav actions - action func() - bg *canvas.Rectangle - active bool -} - -// navButtons collects the screen-switching toolbar buttons so the active one -// can be highlighted as the user moves between screens. -var navButtons []*toolBtn - -func newToolBtn(icon fyne.Resource, label string, action func()) *toolBtn { - b := &toolBtn{icon: icon, label: label, action: action} - b.bg = canvas.NewRectangle(color.Transparent) - b.bg.CornerRadius = 6 - b.ExtendBaseWidget(b) - return b -} - -// newNavBtn is a toolbar button that switches to a screen and can show an active -// state. Registered in navButtons so highlightNav can manage the selection. -func newNavBtn(icon fyne.Resource, label, uid string, action func()) *toolBtn { - b := newToolBtn(icon, label, action) - b.uid = uid - navButtons = append(navButtons, b) - return b -} - -func (b *toolBtn) CreateRenderer() fyne.WidgetRenderer { - ic := widget.NewIcon(b.icon) - content := container.NewGridWrap(fyne.NewSize(40, 34), - container.NewStack(b.bg, container.NewPadded(ic))) - return widget.NewSimpleRenderer(content) -} - -// restFill is the button's color when not hovered — tinted if it's the active -// screen, otherwise transparent. -func (b *toolBtn) restFill() color.Color { - if b.active { - return withAlpha(colPrimary, 0x24) - } - return color.Transparent -} - -func (b *toolBtn) setActive(on bool) { - if b.active == on { - return - } - b.active = on - b.bg.FillColor = b.restFill() - b.bg.Refresh() -} - -func (b *toolBtn) Tapped(_ *fyne.PointEvent) { - hideToolTip() - if b.action != nil { - b.action() - } -} - -func (b *toolBtn) MouseIn(_ *desktop.MouseEvent) { - b.bg.FillColor = withAlpha(colPrimary, 0x33) - b.bg.Refresh() - pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(b) - pos.Y += b.Size().Height + 3 - showToolTip(b.label, pos) -} - -func (b *toolBtn) MouseMoved(_ *desktop.MouseEvent) {} - -func (b *toolBtn) MouseOut() { - b.bg.FillColor = b.restFill() - b.bg.Refresh() - hideToolTip() -} - -func (b *toolBtn) Cursor() desktop.Cursor { return desktop.PointerCursor } - -// highlightNav marks the toolbar button for uid as active and clears the rest. -func highlightNav(uid string) { - for _, b := range navButtons { - b.setActive(b.uid == uid) - } -} - -// toolSep is a thin vertical divider between toolbar groups. -func toolSep() fyne.CanvasObject { - line := canvas.NewRectangle(colBorder) - line.SetMinSize(fyne.NewSize(1, 18)) - return container.NewPadded(line) -} - -// buildToolbar assembles the custom toolbar with hover + tooltips. The -// screen-switching buttons highlight to show which screen is active. -func buildToolbar(c *appController) fyne.CanvasObject { - navButtons = nil - left := container.NewHBox( - newToolBtn(theme.ContentAddIcon(), "Add transaction", func() { quickAdd(c) }), - toolSep(), - newNavBtn(theme.StorageIcon(), "Accounts", "accounts", func() { c.show("accounts") }), - newNavBtn(theme.HistoryIcon(), "Transactions", "transactions", func() { c.show("transactions") }), - newNavBtn(theme.AccountIcon(), "Budget", "budget", func() { c.show("budget") }), - newNavBtn(theme.MediaReplayIcon(), "Recurring", "recurring", func() { c.show("recurring") }), - newNavBtn(iconDebts(), "Debts", "debts", func() { c.show("debts") }), - toolSep(), - newNavBtn(theme.GridIcon(), "Analytics", "analytics", func() { c.show("analytics") }), - ) - right := container.NewHBox( - newToolBtn(theme.ColorPaletteIcon(), themeToggleLabel(), toggleTheme), - toolSep(), - newToolBtn(theme.ListIcon(), "Categories", func() { - if store != nil { - view.OpenConfig(1) - } - }), - newToolBtn(theme.SettingsIcon(), "Preferences", func() { - if store != nil { - view.OpenConfig(0) - } - }), - ) - - bar := canvas.NewRectangle(colSurface) - line := canvas.NewRectangle(colBorder) - line.SetMinSize(fyne.NewSize(0, 1)) - inner := container.NewBorder(nil, nil, container.NewPadded(left), container.NewPadded(right)) - return container.NewBorder(nil, line, nil, nil, container.NewStack(bar, inner)) -} diff --git a/internal/ui/tooltip.go b/internal/ui/tooltip.go new file mode 100644 index 0000000..837e0f3 --- /dev/null +++ b/internal/ui/tooltip.go @@ -0,0 +1,82 @@ +package ui + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + + "github.com/raihanstark/financy/internal/ui/component" +) + +// ---- tooltip layer ---- +// +// Fyne 2.7 has no built-in tooltips, so we keep a tiny floating bubble in a +// no-layout layer at the root of the window and position it under whatever +// element is being hovered (charts drive it via component.ShowTooltip). + +var ( + tooltipLayer *fyne.Container + ttBox *fyne.Container + ttText *canvas.Text +) + +func initTooltipLayer() { + ttText = canvas.NewText("", color.NRGBA{R: 0xf2, G: 0xf4, B: 0xf6, A: 0xff}) + ttText.TextSize = 11.5 + bg := canvas.NewRectangle(color.NRGBA{R: 0x2a, G: 0x2f, B: 0x35, A: 0xff}) + bg.CornerRadius = 4 + ttBox = container.NewStack(bg, container.New(padCell(3, 8), ttText)) + ttBox.Hide() + tooltipLayer = container.NewWithoutLayout(ttBox) + + // Let interactive components (charts) drive the same floating tooltip. + component.ShowTooltip = showToolTip + component.HideTooltip = hideToolTip +} + +func showToolTip(text string, pos fyne.Position) { + if ttBox == nil { + return + } + ttText.Text = text + ttText.Refresh() + sz := ttBox.MinSize() + ttBox.Resize(sz) + + // Keep the bubble inside the window — important near the edges. + // pos is in canvas-absolute coordinates (from AbsolutePositionForObject or a + // mouse event's AbsolutePosition), so clamp against the canvas here. + if ctl != nil && ctl.win != nil { + const margin = 6 + cs := ctl.win.Canvas().Size() + if pos.X+sz.Width > cs.Width-margin { + pos.X = cs.Width - sz.Width - margin + } + if pos.X < margin { + pos.X = margin + } + if pos.Y+sz.Height > cs.Height-margin { + pos.Y = cs.Height - sz.Height - margin + } + if pos.Y < margin { + pos.Y = margin + } + } + + // ttBox.Move is relative to the tooltip layer, which can sit below the canvas + // origin (e.g. the main menu bar pushes the content down). Convert the + // canvas-absolute position into layer-local coords so the bubble lands exactly + // at the cursor rather than menu-height too low. + origin := fyne.CurrentApp().Driver().AbsolutePositionForObject(tooltipLayer) + ttBox.Move(pos.Subtract(origin)) + ttBox.Show() + ttBox.Refresh() +} + +func hideToolTip() { + if ttBox != nil { + ttBox.Hide() + } +} diff --git a/internal/ui/update.go b/internal/ui/update.go index b437457..7fbf35c 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -8,7 +8,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" "github.com/raihanstark/financy/internal/core" @@ -48,14 +47,14 @@ func checkForUpdatesAsync(manual bool) { } if err != nil { if manual { - dialog.ShowError(err, ctl.win) + showError(err) } return } if !available { if manual { - dialog.ShowInformation("Up to date", - "You're running the latest version of Financy (v"+core.Version+").", ctl.win) + showInfo("Up to date", + "You're running the latest version of Financy (v"+core.Version+").") } return } @@ -88,7 +87,7 @@ func showUpdateDialog(rel core.Release) { body.Add(scroll) } - var d *dialog.CustomDialog + var d *modal download := primaryButton("Download", nil, func() { if u, err := url.Parse(rel.URL); err == nil && rel.URL != "" { _ = fyne.CurrentApp().OpenURL(u) @@ -109,8 +108,8 @@ func showUpdateDialog(rel core.Release) { spacerH(14), container.NewCenter(container.NewHBox(later, skip, download)), ) - d = dialog.NewCustomWithoutButtons("Software Update", content, ctl.win) - d.Resize(fyne.NewSize(500, 380)) + d = newModal("Software Update", content) + d.SetCardSize(fyne.NewSize(500, 380)) d.Show() } diff --git a/internal/ui/view/accounts.go b/internal/ui/view/accounts.go index fde7c11..bc8b361 100644 --- a/internal/ui/view/accounts.go +++ b/internal/ui/view/accounts.go @@ -1,21 +1,25 @@ package view import ( + "image/color" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) func ScreenAccounts() fyne.CanvasObject { - bar := appBar("Accounts", "Click a card to open its register · right-click for actions", + bar := appBar("Accounts", "Click a row to open its register · right-click for actions", primaryButton("Add Account", theme.ContentAddIcon(), func() { AccountForm(nil) }), ) body := container.NewVBox( netWorthHero(), spacerH(6), + upcomingBills(), accountGroup("Assets", store.AssetAccounts(), store.TotalAssets(), false), spacerH(6), accountGroup("Liabilities", store.LiabilityAccounts(), store.TotalLiabilities(), true), @@ -23,54 +27,173 @@ func ScreenAccounts() fyne.CanvasObject { return container.NewBorder(bar, nil, nil, nil, container.NewPadded(body)) } +// upcomingBills surfaces the recurring schedule on the overview: a due alert +// when something needs review, then the next two weeks of occurrences. Hidden +// until the user has recurring templates at all. +func upcomingBills() fyne.CanvasObject { + if len(store.Recurrings()) == 0 { + return spacerH(0) + } + sum := store.RecurringSummaryFor(todaySerial) + + box := container.NewVBox() + if sum.DueCount > 0 { + msg := itoa(sum.DueCount) + " recurring entr" + plural(sum.DueCount, "y", "ies") + " due · " + fmtMoney(sum.DueTotal) + box.Add(alertBanner(theme.WarningIcon(), msg, colWarning, + secondaryButton("Review & Post", theme.MediaPlayIcon(), postDueNow))) + box.Add(spacerH(6)) + } + + netSub := "net " + fmtMoney(sum.MonthlyNet) + "/mo" + head := container.NewBorder(nil, nil, + sectionTitle("Upcoming"), + txt("next 14 days · "+netSub, colTextDim, 11, false)) + rows := []fyne.CanvasObject{head, spacerH(6)} + + occ := store.UpcomingRecurring(todaySerial, todaySerial+14) + if len(occ) == 0 { + rows = append(rows, txt("Nothing due in the next 14 days.", colTextDim, 12, false)) + } + for i, o := range occ { + if i == 5 { + rows = append(rows, container.New(padCell(4, 12), + txt("+ "+itoa(len(occ)-5)+" more on the Recurring screen", colTextDim, 11, false))) + break + } + rows = append(rows, upcomingBillRow(o)) + } + + more := newTappableRow(container.New(padCell(5, 12), + txt("View all recurring →", colPrimary, 11.5, true)), colSurface, + func() { nav("recurring") }) + rows = append(rows, spacerH(2), more) + + box.Add(panel(container.New(padCell(10, 12), container.NewVBox(rows...)))) + box.Add(spacerH(6)) + return box +} + +// upcomingBillRow is a compact occurrence line on the overview; tapping goes to +// the Recurring screen. +func upcomingBillRow(o Occurrence) fyne.CanvasObject { + amtCol := colNegative + switch o.Kind { + case "Income": + amtCol = colPositive + case "Transfer": + amtCol = colText + } + title := o.Payee + if title == "" { + title = o.Kind + } + state, stateCol := dueLabel(o.Date) + line := container.NewBorder(nil, nil, + container.NewHBox( + mono(fmtSerialDate(o.Date), colTextDim, 11, false), + spacerW(8), + txt(title, colText, 12.5, false), + ), + container.NewHBox( + txt(state, stateCol, 10.5, o.Due), + spacerW(10), + txt(amountLabel(o.Kind, o.Amount), amtCol, 12.5, true), + )) + + var fill color.Color = colSurface + if o.Due { + fill = withAlpha(colWarning, 0x14) + } + row := newTappableRow(container.New(padCell(5, 12), line), fill, func() { nav("recurring") }) + row.SetBordered() + return row +} + // netWorthHero is the prominent summary banner at the top. func netWorthHero() fyne.CanvasObject { nw := store.NetWorth() netBlock := container.NewVBox( txt("NET WORTH", colTextDim, 11, true), + spacerH(4), txt(fmtMoney(nw), moneyColor(nw), 30, true), + spacerH(3), txt("Assets − Liabilities", colTextDim, 11, false), ) assetsBlock := container.NewVBox( txt("TOTAL ASSETS", colTextDim, 10.5, true), + spacerH(4), txt(fmtMoney(store.TotalAssets()), colPositive, 18, true), + spacerH(3), txt(itoa(len(store.AssetAccounts()))+" accounts", colTextDim, 10, false), ) liabBlock := container.NewVBox( txt("TOTAL LIABILITIES", colTextDim, 10.5, true), + spacerH(4), txt(fmtMoney(store.TotalLiabilities()), colNegative, 18, true), + spacerH(3), txt(itoa(len(store.LiabilityAccounts()))+" accounts", colTextDim, 10, false), ) - right := container.NewGridWithColumns(2, assetsBlock, liabBlock) - return panel(container.New(&columnsLayout{Weights: []float32{1.1, 2}, Gap: 24}, netBlock, right)) + // Center the shorter side blocks against the taller net-worth block so the + // row reads as one aligned band rather than three floating columns. + vCenter := func(o fyne.CanvasObject) fyne.CanvasObject { + return container.NewVBox(spacerV(), o, spacerV()) + } + right := container.NewGridWithColumns(2, vCenter(assetsBlock), vCenter(liabBlock)) + inner := container.New(&columnsLayout{Weights: []float32{1.1, 2}, Gap: 24}, netBlock, right) + return panel(container.New(padCell(10, 12), inner)) } -// accountGroup renders a titled section with a subtotal and a grid of cards. +// accountGroup renders a titled table panel: a header with the subtotal, a +// column header row, and one tappable line per account (mirroring the Debts +// table so the two screens read the same). func accountGroup(title string, accts []Account, subtotal int, liability bool) fyne.CanvasObject { subCol := colPositive if liability { subCol = colNegative } head := container.NewBorder(nil, nil, - txt(title, colText, 14, true), - container.NewHBox(txt(itoa(len(accts))+" accounts ", colTextDim, 11, false), - txt(fmtMoney(subtotal), subCol, 14, true)), + txt(title, colText, 13.5, true), + container.NewHBox( + txt(itoa(len(accts))+" account"+plural(len(accts), "", "s"), colTextDim, 11, false), + spacerW(10), + txt(fmtMoney(subtotal), subCol, 13.5, true), + ), ) if len(accts) == 0 { - return container.NewVBox(head, spacerH(4), panel(emptyState("No "+title+" yet"))) + return panel(container.NewVBox(container.New(padCell(4, 8), head), spacerH(4), + container.New(padCell(16, 8), emptyState("No "+title+" yet")))) } - cards := make([]fyne.CanvasObject, 0, len(accts)) - for _, a := range accts { - cards = append(cards, accountCard(a, liability)) + cols := accountCols() + h := func(s string) fyne.CanvasObject { return txt(s, colTextDim, 10.5, true) } + header := container.New(cols, + h("Account"), h("Share"), + alignRight(txt("Txns", colTextDim, 10.5, true)), + alignRight(txt("Balance", colTextDim, 10.5, true)), + h("")) + + table := container.NewVBox( + container.New(padCell(4, 8), head), + spacerH(6), + container.New(padCell(2, 8), header), + divider(), + ) + for i, a := range accts { + table.Add(accountRow(a, liability, subtotal, i)) } - grid := container.NewGridWithColumns(3, cards...) - return container.NewVBox(head, spacerH(4), grid) + return panel(container.New(padCell(6, 6), table)) +} + +// accountCols is the shared column layout for the account header and rows. +func accountCols() *columnsLayout { + return &columnsLayout{Weights: []float32{2.9, 1.9, 0.7, 1.5, 0.4}, Gap: 10} } -// accountCard is a single, scannable account tile with a context menu. -func accountCard(a Account, liability bool) fyne.CanvasObject { +// accountRow is one account as a table line: name + institution, its share of +// the group's total, transaction count, balance, and the ⋮ menu. Tapping the +// row opens the register; right-click opens the same menu. +func accountRow(a Account, liability bool, subtotal, idx int) fyne.CanvasObject { amtCol := colPositive if liability { amtCol = colNegative @@ -78,6 +201,28 @@ func accountCard(a Account, liability bool) fyne.CanvasObject { bal := store.DisplayBalance(a) entries := store.TxnCountForAccount(a.ID) + nameCell := container.NewHBox(container.NewCenter(txt(a.Name, colText, 12.5, true))) + if a.Institution != "" { + nameCell.Add(container.NewCenter(txt(" · "+a.Institution, colTextDim, 11.5, false))) + } + if a.OffBudget { + nameCell.Add(spacerW(6)) + nameCell.Add(container.NewCenter(miniBadge("Off-budget"))) + } + + // Share of the group's total — the at-a-glance composition the old card + // grid never offered. Indigo on purpose: it's a proportion, not a verdict. + ratio, pct := 0.0, "—" + if subtotal > 0 && bal > 0 { + ratio = float64(bal) / float64(subtotal) + pct = itoa(int(ratio*100+0.5)) + "%" + } + bar := container.NewVBox(spacerV(), container.New(padCell(0, 4), progressBar(ratio, colPrimary)), spacerV()) + shareCell := container.NewBorder(nil, nil, nil, + container.NewCenter(container.NewGridWrap(fyne.NewSize(38, 16), alignRight(mono(pct, colTextDim, 11, false)))), + bar, + ) + menuBtn := widget.NewButtonWithIcon("", theme.MoreVerticalIcon(), nil) menuBtn.Importance = widget.LowImportance menuBtn.OnTapped = func() { @@ -86,25 +231,36 @@ func accountCard(a Account, liability bool) fyne.CanvasObject { showContextMenu(pos, accountMenuItems(a)...) } - top := container.NewBorder(nil, nil, - txt(a.Name, colText, 14, true), menuBtn) - subText := string(a.Type) + " · " + orDash(a.Institution) - if a.OffBudget { - subText += " · Off-budget" - } - sub := txt(subText, colTextDim, 10.5, false) - balance := txt(fmtMoney(bal), amtCol, 22, true) - footer := txt(itoa(entries)+" transactions", colTextDim, 10, false) - - inner := container.NewVBox(top, sub, spacerH(8), balance, spacerH(2), footer) + cells := container.New(accountCols(), + nameCell, + shareCell, + alignRight(mono(itoa(entries), colTextDim, 12, false)), + alignRight(mono(fmtMoney(bal), amtCol, 12.5, false)), + container.NewCenter(menuBtn), + ) - row := newTappableRow(container.New(padCell(10, 12), inner), colSurface, + var fill = colSurface + if idx%2 == 1 { + fill = colAltRow + } + row := newTappableRow(container.New(padCell(6, 8), cells), fill, func() { showRegisterDialog(a) }) - row.SetBordered() row.SetOnSecondary(func(pos fyne.Position) { showContextMenu(pos, accountMenuItems(a)...) }) return row } +// miniBadge is a compact inline chip that doesn't inflate a table row's +// height the way the full-size badge would. +func miniBadge(label string) fyne.CanvasObject { + bg := canvas.NewRectangle(withAlpha(colTextDim, 0x1f)) + bg.CornerRadius = 6 + return container.NewStack(bg, container.New(padCell(1, 6), txt(label, colTextDim, 9.5, true))) +} + +// spacerV is an expanding vertical filler used to center fixed-height content +// inside a stretched table cell. +func spacerV() fyne.CanvasObject { return layout.NewSpacer() } + // accountMenuItems is the shared right-click / ⋮ menu for an account. func accountMenuItems(a Account) []*fyne.MenuItem { newTxn := fyne.NewMenuItem("New Transaction…", func() { TransactionForm("", a.Name) }) @@ -163,8 +319,8 @@ func showRegisterDialog(a Account) { container.NewScroll(registerTable(a)), ) - d := dialog.NewCustom("Register — "+a.Name, "Close", body, win) - d.Resize(fyne.NewSize(820, 560)) + d := newModalWithClose("Register — "+a.Name, "Close", body) + d.SetCardSize(fyne.NewSize(820, 560)) d.Show() } diff --git a/internal/ui/view/accounts_test.go b/internal/ui/view/accounts_test.go index 349809b..bf2dc27 100644 --- a/internal/ui/view/accounts_test.go +++ b/internal/ui/view/accounts_test.go @@ -29,11 +29,30 @@ func TestScreenAccountsRendersAccountsAndTotals(t *testing.T) { assertText(t, screen, fmtMoney(s.NetWorth())) } -// With no money accounts the asset/liability groups fall back to empty states. +// With no money accounts the asset/liability groups fall back to empty states, +// and the Upcoming card stays hidden (no recurring templates yet). func TestScreenAccountsEmptyState(t *testing.T) { _, w := emptyViewTest(t) screen := mount(w, ScreenAccounts()) assertText(t, screen, "No Assets yet", "No Liabilities yet") + assertNoText(t, screen, "Upcoming") +} + +// With recurring templates the overview shows the Upcoming card: the next +// occurrences and a link that deep-links to the Recurring screen. +func TestAccountsUpcomingCard(t *testing.T) { + s, w := newViewTest(t) + addDemoRecurring(s, todaySerial+3) + + screen := mount(w, ScreenAccounts()) + assertText(t, screen, "Upcoming", "next 14 days", "Rent", "View all recurring") + + if !tapRowWithText(screen, "View all recurring") { + t.Fatal("no tappable 'View all recurring' row") + } + if gotNav != "recurring" { + t.Fatalf("nav target = %q, want \"recurring\"", gotNav) + } } // Tapping a card's tap target opens the register dialog for that account. diff --git a/internal/ui/view/analytics.go b/internal/ui/view/analytics.go index 149a8a3..763e02e 100644 --- a/internal/ui/view/analytics.go +++ b/internal/ui/view/analytics.go @@ -104,7 +104,7 @@ func kpiRow(s AnalyticsSummary) fyne.CanvasObject { func incomeExpensePanel(flows []MonthFlow) fyne.CanvasObject { if len(flows) == 0 { - return panel(emptyState("No activity in this period")) + return panel(container.New(padCell(10, 12), emptyState("No activity in this period"))) } incomes := make([]int, len(flows)) expenses := make([]int, len(flows)) @@ -121,17 +121,17 @@ func incomeExpensePanel(flows []MonthFlow) fyne.CanvasObject { container.NewHBox(legendSwatch("Income", colPositive), spacerW(10), legendSwatch("Expenses", colNegative)), ) chart := barPairChart(incomes, expenses, colPositive, colNegative, tips, fmtMoneyShort) - return panel(container.NewVBox( + return panel(container.New(padCell(10, 12), container.NewVBox( header, spacerH(8), chart, spacerH(4), axisLabels(labels), - )) + ))) } // ---- net worth trend ---- func netWorthPanel(points []NetWorthPoint) fyne.CanvasObject { if len(points) == 0 { - return panel(emptyState("No history in this period")) + return panel(container.New(padCell(10, 12), emptyState("No history in this period"))) } vals := make([]int, len(points)) tips := make([]string, len(points)) @@ -145,17 +145,18 @@ func netWorthPanel(points []NetWorthPoint) fyne.CanvasObject { sectionTitle("Net Worth Over Time"), txt(fmtMoney(vals[len(vals)-1]), moneyColor(vals[len(vals)-1]), 13, true), ) - return panel(container.NewVBox( + return panel(container.New(padCell(10, 12), container.NewVBox( header, spacerH(8), lineChart(vals, colPrimary, tips, fmtMoneyShort), spacerH(4), axisLabels(labels), - )) + ))) } // ---- spending by category ---- func spendingPanel(cats []CategorySlice) fyne.CanvasObject { if len(cats) == 0 { - return panel(container.NewVBox(sectionTitle("Spending by Category"), spacerH(8), emptyState("No spending in this period"))) + return panel(container.New(padCell(10, 12), container.NewVBox( + sectionTitle("Spending by Category"), spacerH(8), emptyState("No spending in this period")))) } top := cats[0].Total rows := make([]fyne.CanvasObject, 0, len(cats)+2) @@ -168,7 +169,7 @@ func spendingPanel(cats []CategorySlice) fyne.CanvasObject { label := fmt.Sprintf("%s · %.0f%%", c.Account.Name, c.Pct) rows = append(rows, hbar(label, fmtMoney(c.Total), ratio, colPrimary), spacerH(6)) } - return panel(container.NewVBox(rows...)) + return panel(container.New(padCell(10, 12), container.NewVBox(rows...))) } // axisLabels lays the X-axis labels out under the plot, inset by the chart's diff --git a/internal/ui/view/budget.go b/internal/ui/view/budget.go index f22c416..b30a22b 100644 --- a/internal/ui/view/budget.go +++ b/internal/ui/view/budget.go @@ -1,11 +1,15 @@ package view import ( + "errors" "image/color" + "sort" "strings" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -35,7 +39,9 @@ func ScreenBudget() fyne.CanvasObject { body := container.NewVBox( readyToAssignHero(bm), - spacerH(8), + spacerH(4), + overspendBanner(bm), + spacerH(4), budgetTable(bm), spacerH(8), budgetFootnote(), @@ -43,6 +49,105 @@ func ScreenBudget() fyne.CanvasObject { return container.NewBorder(bar, nil, nil, nil, container.NewPadded(body)) } +// overspendBanner calls out envelopes in the red so they can't be missed; +// hidden when everything is funded. Its Cover… button walks the overspent +// categories one at a time (the banner re-renders after each move). +func overspendBanner(bm BudgetMonth) fyne.CanvasObject { + var over []BudgetCategory + for _, c := range bm.Categories { + if c.Available < 0 { + over = append(over, c) + } + } + if len(over) == 0 { + return spacerH(0) + } + msg := itoa(len(over)) + " categor" + plural(len(over), "y", "ies") + " overspent — cover the shortfall from another envelope" + var action fyne.CanvasObject + if !budgetLocked() { + first := over[0] + action = secondaryButton("Cover…", nil, func() { coverOverspendDialog(first) }) + } + return alertBanner(theme.WarningIcon(), msg, colNegative, action) +} + +// coverOverspendDialog moves money into an overspent envelope from another +// category (or from Ready to Assign) — YNAB's "cover overspending" flow. +func coverOverspendDialog(c BudgetCategory) { + if win == nil || budgetLocked() || c.Available >= 0 { + return + } + shortfall := -c.Available + bm := store.BudgetFor(budgetMonth) + + // Possible sources: Ready to Assign first, then every envelope with money + // available, richest first — the label carries what each can give. + type source struct { + id string + avail int + } + byLabel := map[string]source{} + var labels []string + addSource := func(label string, s source) { + byLabel[label] = s + labels = append(labels, label) + } + if bm.ReadyToAssign > 0 { + addSource("Ready to Assign · "+fmtMoney(bm.ReadyToAssign), source{"", bm.ReadyToAssign}) + } + cats := append([]BudgetCategory(nil), bm.Categories...) + sort.SliceStable(cats, func(i, j int) bool { return cats[i].Available > cats[j].Available }) + for _, o := range cats { + if o.ID != c.ID && o.Available > 0 { + addSource(o.Name+" · "+fmtMoney(o.Available)+" available", source{o.ID, o.Available}) + } + } + if len(labels) == 0 { + showInfo("Cover overspending", "No envelope has money available to move. Add funds or reduce other assignments first.") + return + } + + sel := widget.NewSelect(labels, nil) + sel.SetSelectedIndex(0) + entry := newAmountEntry() + entry.SetText(fmtMoneyInput(shortfall)) + + form := container.NewVBox( + detailField("Category", c.Name, colText), + detailField("Overspent by", fmtMoney(shortfall), colNegative), + spacerH(10), + txt("MOVE FROM", colTextDim, 10.5, true), + sel, + spacerH(8), + txt("AMOUNT", colTextDim, 10.5, true), + entry, + ) + + var m *modal + move := primaryButton("Move", theme.ConfirmIcon(), func() { + src, ok := byLabel[sel.Selected] + if !ok { + return + } + amt := parseAmount(entry.Text) + switch { + case amt <= 0: + showError(errors.New("enter an amount greater than zero")) + return + case amt > src.avail: + showError(errors.New("that envelope only has " + fmtMoney(src.avail) + " available")) + return + } + m.Hide() + store.MoveAssigned(budgetMonth, src.id, c.ID, amt) + }) + cancel := secondaryButton("Cancel", nil, func() { m.Hide() }) + m = newModal("Cover overspending — "+c.Name, form) + m.SetButtons(cancel, move) + m.SetCardSize(fyne.NewSize(460, 0)) + m.Show() +} + // budgetMonthNav is the ◀ Month ▶ control plus a jump-to-today button. func budgetMonthNav() fyne.CanvasObject { prev := widget.NewButtonWithIcon("", theme.NavigateBackIcon(), func() { @@ -90,7 +195,9 @@ func readyToAssignHero(bm BudgetMonth) fyne.CanvasObject { left := container.NewVBox( txt(headline, colTextDim, 11, true), + spacerH(4), txt(fmtMoney(rta), tone, 30, true), + spacerH(3), txt(sub, colTextDim, 11, false), ) // Show where the number comes from: on-budget funds − money already committed @@ -103,8 +210,10 @@ func readyToAssignHero(bm BudgetMonth) fyne.CanvasObject { spacerH(4), alignRight(txt("Assigned this month "+fmtMoney(bm.TotalAssigned), colTextDim, 10.5, false)), ) + vCentered := container.NewVBox(spacerV(), right, spacerV()) - return panel(container.New(&columnsLayout{Weights: []float32{2, 1.6}, Gap: 16}, left, right)) + inner := container.New(&columnsLayout{Weights: []float32{2, 1.6}, Gap: 16}, left, vCentered) + return panel(container.New(padCell(10, 12), inner)) } // budgetTable renders the Category / Assigned / Activity / Available grid. @@ -154,26 +263,72 @@ func budgetHeaderRow() fyne.CanvasObject { cells := []fyne.CanvasObject{ txt("CATEGORY", colTextDim, 11, true), alignRight(txt("ASSIGNED", colTextDim, 11, true)), - alignRight(txt("ACTIVITY", colTextDim, 11, true)), + alignRight(txt("SPENT", colTextDim, 11, true)), + txt(" USED", colTextDim, 11, true), alignRight(txt("AVAILABLE", colTextDim, 11, true)), } grid := container.New(&columnsLayout{Weights: budgetWeights(), Gap: 14}, cells...) return container.NewVBox(container.New(padCell(4, 6), grid), divider()) } -func budgetWeights() []float32 { return []float32{2.6, 1, 1, 1.2} } +func budgetWeights() []float32 { return []float32{2.2, 1, 1, 1.5, 1.2} } + +// spentOf is the money that left the envelope this month, as a positive figure. +func spentOf(c BudgetCategory) int { + if c.Activity < 0 { + return -c.Activity + } + return 0 +} + +// spentCell shows spending without sign noise: "—" when untouched, the amount +// when money went out, a green "+" figure for refunds/credits. +func spentCell(activity int) fyne.CanvasObject { + switch { + case activity < 0: + return alignRight(mono(fmtMoney(-activity), colText, 12.5, false)) + case activity > 0: + return alignRight(mono("+"+fmtMoney(activity), colPositive, 12.5, false)) + default: + return alignRight(mono("—", colTextDim, 12.5, false)) + } +} + +// usageBar is the traffic-light envelope gauge: emerald while healthy, amber +// when nearly (or fully) used, rose when overspent. This is the column that +// makes trouble visible without reading a single number. +func usageBar(c BudgetCategory) fyne.CanvasObject { + spent := spentOf(c) + total := spent + c.Available // the envelope's funds this month + + ratio, col := 0.0, colPositive + switch { + case c.Available < 0: + ratio, col = 1, colNegative + case total <= 0: + ratio = 0 // empty envelope, nothing spent — bare track + default: + ratio = float64(spent) / float64(total) + if ratio >= 0.85 { + col = colWarning + } + } + bar := container.NewVBox(spacerV(), container.New(padCell(0, 6), progressBar(ratio, col)), spacerV()) + return bar +} // budgetCategoryRow is one tappable category line; tapping opens the assigner. func budgetCategoryRow(c BudgetCategory, idx int) fyne.CanvasObject { - name := txt(c.Name, colText, 13, false) + name := container.NewCenter(txt(c.Name, colText, 13, false)) + nameCell := container.NewHBox(name) assigned := alignRight(mono(fmtMoney(c.Assigned), assignedColor(c.Assigned), 12.5, false)) - activity := alignRight(mono(fmtMoney(c.Activity), moneyColor(c.Activity), 12.5, false)) - - avail := availablePill(c) + if c.Assigned == 0 { + assigned = alignRight(mono("—", colTextDim, 12.5, false)) + } grid := container.New(&columnsLayout{Weights: budgetWeights(), Gap: 14}, - name, assigned, activity, avail, + nameCell, vCenterCell(assigned), vCenterCell(spentCell(c.Activity)), usageBar(c), vCenterCell(availablePill(c)), ) fill := colSurface @@ -189,15 +344,25 @@ func budgetCategoryRow(c BudgetCategory, idx int) fyne.CanvasObject { return row } +// vCenterCell vertically centers a fixed-height cell within a stretched row. +func vCenterCell(o fyne.CanvasObject) fyne.CanvasObject { + return container.NewVBox(spacerV(), o, spacerV()) +} + // budgetLocked reports whether the month being viewed is a locked past month. func budgetLocked() bool { return !monthEditable(budgetMonth) } -// availablePill colours the rolling balance: green when funded, red when -// overspent, amber when there's spending but nothing left, grey when zero. +// availablePill colours the rolling balance: green when funded, a filled rose +// pill when overspent (so red rows jump out), amber when there's spending but +// nothing left, grey when zero. func availablePill(c BudgetCategory) fyne.CanvasObject { switch { case c.Available < 0: - return alignRight(mono(fmtMoney(c.Available), colNegative, 12.5, true)) + bg := canvas.NewRectangle(withAlpha(colNegative, 0x1c)) + bg.CornerRadius = 7 + pill := container.NewStack(bg, container.New(padCell(2, 7), + mono(fmtMoney(c.Available), colNegative, 12.5, true))) + return container.NewHBox(layout.NewSpacer(), pill) case c.Available == 0 && c.Activity < 0: return alignRight(mono(fmtMoney(c.Available), colWarning, 12.5, true)) case c.Available == 0: @@ -208,11 +373,13 @@ func availablePill(c BudgetCategory) fyne.CanvasObject { } func budgetTotalsRow(bm BudgetMonth) fyne.CanvasObject { + totalBar := usageBar(BudgetCategory{Activity: bm.TotalActivity, Available: bm.TotalAvailable}) cells := []fyne.CanvasObject{ - txt("Total", colText, 13, true), - alignRight(mono(fmtMoney(bm.TotalAssigned), colText, 12.5, true)), - alignRight(mono(fmtMoney(bm.TotalActivity), moneyColor(bm.TotalActivity), 12.5, true)), - alignRight(mono(fmtMoney(bm.TotalAvailable), moneyColor(bm.TotalAvailable), 12.5, true)), + vCenterCell(txt("Total", colText, 13, true)), + vCenterCell(alignRight(mono(fmtMoney(bm.TotalAssigned), colText, 12.5, true))), + vCenterCell(spentCell(bm.TotalActivity)), + totalBar, + vCenterCell(alignRight(mono(fmtMoney(bm.TotalAvailable), moneyColor(bm.TotalAvailable), 12.5, true))), } grid := container.New(&columnsLayout{Weights: budgetWeights(), Gap: 14}, cells...) return container.NewVBox(divider(), container.New(padCell(7, 6), grid)) @@ -289,7 +456,8 @@ func quickAssignBtn(label string, amount int, set func(int)) fyne.CanvasObject { return b } -// budgetMenuItems is the right-click menu for a category row. +// budgetMenuItems is the right-click menu for a category row; an overspent +// envelope gets "Cover overspending…" on top. func budgetMenuItems(c BudgetCategory) []*fyne.MenuItem { assign := fyne.NewMenuItem("Assign…", func() { assignDialog(c) }) assign.Icon = theme.DocumentCreateIcon() @@ -301,7 +469,14 @@ func budgetMenuItems(c BudgetCategory) []*fyne.MenuItem { store.SetAssigned(budgetMonth, c.ID, 0) }) clear.Icon = theme.ContentClearIcon() - return []*fyne.MenuItem{assign, coverLast, fyne.NewMenuItemSeparator(), clear} + + items := []*fyne.MenuItem{assign, coverLast, fyne.NewMenuItemSeparator(), clear} + if c.Available < 0 { + cover := fyne.NewMenuItem("Cover overspending…", func() { coverOverspendDialog(c) }) + cover.Icon = theme.MoveDownIcon() + items = append([]*fyne.MenuItem{cover}, items...) + } + return items } // AddBudgetCategory is a fast, one-field way to create a new budget category diff --git a/internal/ui/view/budget_test.go b/internal/ui/view/budget_test.go new file mode 100644 index 0000000..20ff2af --- /dev/null +++ b/internal/ui/view/budget_test.go @@ -0,0 +1,61 @@ +package view + +import ( + "testing" +) + +// Covering an overspent envelope from another category zeroes the shortfall +// and leaves no envelope in the red (the demo data ships with one overspent +// debt envelope: money paid to a debt that was never assigned). +func TestCoverOverspending(t *testing.T) { + s, w := newViewTest(t) + prevMonth := budgetMonth + budgetMonth = currentMonthKey() + t.Cleanup(func() { budgetMonth = prevMonth }) + + bm := s.BudgetFor(budgetMonth) + var over *BudgetCategory + for i := range bm.Categories { + if bm.Categories[i].Available < 0 { + over = &bm.Categories[i] + break + } + } + if over == nil { + t.Fatal("demo data has no overspent category to cover") + } + + coverOverspendDialog(*over) + d := dialogContent(w) + if d == nil { + t.Fatal("cover dialog did not open") + } + // Defaults: richest source preselected, amount prefilled with the + // shortfall — so a bare "Move" fully covers the envelope. + if !tapButton(d, "Move") { + t.Fatal("no Move button in cover dialog") + } + + after := s.BudgetFor(budgetMonth) + for _, c := range after.Categories { + if c.ID == over.ID && c.Available != 0 { + t.Fatalf("covered category Available = %d, want 0", c.Available) + } + if c.Available < 0 { + t.Fatalf("category %q still overspent after cover", c.Name) + } + } +} + +// A locked past month refuses the cover flow. +func TestCoverOverspendingLockedMonth(t *testing.T) { + _, w := newViewTest(t) + prevMonth := budgetMonth + budgetMonth = shiftMonth(currentMonthKey(), -1) + t.Cleanup(func() { budgetMonth = prevMonth }) + + coverOverspendDialog(BudgetCategory{ID: "x", Name: "X", Available: -100}) + if d := dialogContent(w); d != nil { + t.Fatal("cover dialog opened for a locked month") + } +} diff --git a/internal/ui/view/bulkadd.go b/internal/ui/view/bulkadd.go index cb924e3..e4f1464 100644 --- a/internal/ui/view/bulkadd.go +++ b/internal/ui/view/bulkadd.go @@ -3,7 +3,6 @@ package view import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -199,13 +198,13 @@ func QuickAddForm() { secondaryButton("+ Add transfer", theme.ContentAddIcon(), func() { addTRRow() }), ) - var d *dialog.CustomDialog + var d *modal cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) save := primaryButton("Save all", theme.ConfirmIcon(), func() { n, err := commit() d.Hide() if err != nil { - dialog.ShowError(err, win) + showError(err) return } showInfo("Added", itoa(n)+" transaction"+plural(n, "", "s")+" added.") @@ -216,8 +215,8 @@ func QuickAddForm() { nil, nil, container.NewScroll(container.NewPadded(body)), ) - d = dialog.NewCustomWithoutButtons("Quick Add", content, win) - d.Resize(fyne.NewSize(820, 620)) + d = newModal("Quick Add", content) + d.SetCardSize(fyne.NewSize(820, 620)) d.Show() } diff --git a/internal/ui/view/config.go b/internal/ui/view/config.go index 125ba08..d56c267 100644 --- a/internal/ui/view/config.go +++ b/internal/ui/view/config.go @@ -3,7 +3,6 @@ package view import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -42,7 +41,7 @@ func OpenConfig(tab int) { } content := container.NewGridWrap(fyne.NewSize(760, 540), tabs) - d := dialog.NewCustom("Preferences", "Close", content, win) + d := newModalWithClose("Preferences", "Close", content) d.Show() } diff --git a/internal/ui/view/config_test.go b/internal/ui/view/config_test.go index 68908c4..a2ec93f 100644 --- a/internal/ui/view/config_test.go +++ b/internal/ui/view/config_test.go @@ -99,9 +99,9 @@ func TestDeleteUnusedCategory(t *testing.T) { if d == nil { t.Fatal("expected delete-confirm dialog") } - // dialog.ShowConfirm uses Yes/No; tap the affirmative. - if !tapButton(d, "Yes") { - t.Fatal("no confirm (Yes) button in delete dialog") + // The delete-confirm modal's affirmative button is labeled "Delete". + if !tapButton(d, "Delete") { + t.Fatal("no confirm (Delete) button in delete dialog") } if got := len(s.ExpenseAccounts()); got != before-1 { t.Fatalf("expense categories = %d, want %d", got, before-1) diff --git a/internal/ui/view/debts_detail.go b/internal/ui/view/debts_detail.go index d4763ff..87c5607 100644 --- a/internal/ui/view/debts_detail.go +++ b/internal/ui/view/debts_detail.go @@ -5,7 +5,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -18,7 +17,7 @@ import ( // subscriber (registered once per store) rebuilds its content on every store // change, and closes it when the debt itself is gone. var ( - debtDlg *dialog.CustomDialog + debtDlg *modal debtDlgID string debtDlgBody *fyne.Container debtDlgStore *core.Store @@ -39,14 +38,14 @@ func showDebtDetail(id string) { } debtDlgID = id debtDlgBody = container.NewStack(debtTabContent(*d)) - dlg := dialog.NewCustom(orDash(d.Name), "Close", container.NewScroll(debtDlgBody), win) + dlg := newModalWithClose(orDash(d.Name), "Close", container.NewScroll(debtDlgBody)) debtDlg = dlg dlg.SetOnClosed(func() { if debtDlg == dlg { debtDlg, debtDlgID, debtDlgBody = nil, "", nil } }) - dlg.Resize(fyne.NewSize(820, 620)) + dlg.SetCardSize(fyne.NewSize(820, 620)) dlg.Show() } @@ -459,7 +458,7 @@ func payInstallmentDialog(d Debt, in Installment) { sel.widget, ) - var dlg *dialog.CustomDialog + var dlg *modal cancel := secondaryButton("Cancel", nil, func() { dlg.Hide() }) confirm := primaryButton("Confirm", theme.ConfirmIcon(), func() { postNew, picked := sel.isPostNew(), sel.picked() @@ -478,8 +477,8 @@ func payInstallmentDialog(d Debt, in Installment) { content := container.NewVBox(body, spacerH(16), container.NewHBox(layout.NewSpacer(), cancel, confirm)) - dlg = dialog.NewCustomWithoutButtons("Pay installment", content, win) - dlg.Resize(fyne.NewSize(520, 300)) + dlg = newModal("Pay installment", content) + dlg.SetCardSize(fyne.NewSize(520, 300)) dlg.Show() } @@ -616,7 +615,7 @@ func statementReviewDialog(sd StatementDue) { amt, ) - var dlg *dialog.CustomDialog + var dlg *modal cancel := secondaryButton("Cancel", nil, func() { dlg.Hide() }) skip := secondaryButton("No interest", theme.ContentClearIcon(), func() { dlg.Hide() @@ -637,7 +636,7 @@ func statementReviewDialog(sd StatementDue) { content := container.NewVBox(body, spacerH(16), container.NewHBox(layout.NewSpacer(), cancel, skip, post)) - dlg = dialog.NewCustomWithoutButtons("Review statement", content, win) - dlg.Resize(fyne.NewSize(520, 280)) + dlg = newModal("Review statement", content) + dlg.SetCardSize(fyne.NewSize(520, 280)) dlg.Show() } diff --git a/internal/ui/view/debts_form.go b/internal/ui/view/debts_form.go index 5a0f16e..dcf4c72 100644 --- a/internal/ui/view/debts_form.go +++ b/internal/ui/view/debts_form.go @@ -5,7 +5,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -31,7 +30,7 @@ func DebtForm(existing *Debt) { body := container.NewVBox() footer := container.NewVBox() - var dlg *dialog.CustomDialog + var dlg *modal rebuild := func(k string) { f := newDebtFields(k, nil) body.Objects = []fyne.CanvasObject{f.grid()} @@ -51,14 +50,14 @@ func DebtForm(existing *Debt) { head := container.New(layout.NewFormLayout(), widget.NewLabel("Type"), kind) content := container.NewVBox(head, spacerH(4), body, spacerH(8), footer) - dlg = dialog.NewCustomWithoutButtons("Add Debt", container.NewScroll(content), win) - dlg.Resize(fyne.NewSize(520, 520)) + dlg = newModal("Add Debt", container.NewScroll(content)) + dlg.SetCardSize(fyne.NewSize(520, 520)) dlg.Show() } func editDebtForm(d Debt) { f := newDebtFields(d.Type, &d) - var dlg *dialog.CustomDialog + var dlg *modal cancel := secondaryButton("Cancel", nil, func() { dlg.Hide() }) save := primaryButton("Save", theme.ConfirmIcon(), func() { if f.commit() { @@ -70,8 +69,8 @@ func editDebtForm(d Debt) { spacerH(4), f.grid(), spacerH(8), container.NewHBox(layout.NewSpacer(), cancel, save), ) - dlg = dialog.NewCustomWithoutButtons("Edit Debt", container.NewScroll(content), win) - dlg.Resize(fyne.NewSize(520, 520)) + dlg = newModal("Edit Debt", container.NewScroll(content)) + dlg.SetCardSize(fyne.NewSize(520, 520)) dlg.Show() } diff --git a/internal/ui/view/debts_strategy.go b/internal/ui/view/debts_strategy.go index 54d404f..00e4fad 100644 --- a/internal/ui/view/debts_strategy.go +++ b/internal/ui/view/debts_strategy.go @@ -3,7 +3,6 @@ package view import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" ) @@ -46,15 +45,15 @@ func strategyDialog() { head := container.NewBorder(nil, nil, txt("Extra per month, on top of all required payments: ", colText, 12, false), nil, extra) - var dlg *dialog.CustomDialog + var dlg *modal closeBtn := secondaryButton("Close", theme.CancelIcon(), func() { dlg.Hide() }) content := container.NewBorder( container.NewVBox(head, spacerH(12)), container.NewVBox(spacerH(10), container.NewHBox(layout.NewSpacer(), closeBtn)), nil, nil, results, ) - dlg = dialog.NewCustomWithoutButtons("Payoff strategies", content, win) - dlg.Resize(fyne.NewSize(860, 520)) + dlg = newModal("Payoff strategies", content) + dlg.SetCardSize(fyne.NewSize(860, 520)) dlg.Show() } diff --git a/internal/ui/view/dialogs.go b/internal/ui/view/dialogs.go index 0460ace..261ee33 100644 --- a/internal/ui/view/dialogs.go +++ b/internal/ui/view/dialogs.go @@ -2,34 +2,90 @@ package view import ( "fyne.io/fyne/v2" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" + + "github.com/raihanstark/financy/internal/ui/component" ) +// All modal surfaces go through component.Modal — the rounded, scrimmed card +// that replaced Fyne's boxy stock dialogs. The helpers here mirror the old +// dialog.* semantics so screens read the same as before. + +// newModal wraps content in a bare modal card; the content brings its own +// action row. Callers Show() it themselves. +func newModal(title string, content fyne.CanvasObject) *modal { + return component.NewModal(win.Canvas(), title, content) +} + +// newModalWithClose is a read-only modal with a single dismiss button. +func newModalWithClose(title, closeLabel string, content fyne.CanvasObject) *modal { + m := newModal(title, content) + m.SetButtons(secondaryButton(closeLabel, nil, func() { m.Hide() })) + return m +} + +// newModalConfirm mirrors dialog.NewCustomConfirm: cancel/confirm buttons in +// the footer, cb(true) on confirm, cb(false) on cancel. +func newModalConfirm(title, confirmLabel, cancelLabel string, content fyne.CanvasObject, cb func(bool)) *modal { + m := newModal(title, content) + m.SetButtons( + secondaryButton(cancelLabel, nil, func() { + m.Hide() + cb(false) + }), + primaryButton(confirmLabel, nil, func() { + m.Hide() + cb(true) + }), + ) + return m +} + +// newModalForm mirrors dialog.NewForm: labeled form items with validation — +// confirm only proceeds (and closes) when every item validates. +func newModalForm(title, confirmLabel, cancelLabel string, items []*widget.FormItem, cb func(bool)) *modal { + form := widget.NewForm(items...) + m := newModal(title, form) + m.SetButtons( + secondaryButton(cancelLabel, nil, func() { + m.Hide() + cb(false) + }), + primaryButton(confirmLabel, nil, func() { + if form.Validate() != nil { + return + } + m.Hide() + cb(true) + }), + ) + return m +} + // showForm presents a modal form; onConfirm runs only when the user saves. func showForm(title string, items []*widget.FormItem, onConfirm func()) { if win == nil { return } - d := dialog.NewForm(title, "Save", "Cancel", items, func(ok bool) { + m := newModalForm(title, "Save", "Cancel", items, func(ok bool) { if ok { onConfirm() } - }, win) - d.Resize(fyne.NewSize(460, 420)) - d.Show() + }) + m.SetCardSize(fyne.NewSize(460, 0)) + m.Show() } // dialogWithSave wraps arbitrary content in a Save/Cancel modal; onSave runs -// only when the user saves. The caller shows the returned dialog. -func dialogWithSave(title string, content fyne.CanvasObject, onSave func()) dialog.Dialog { - d := dialog.NewCustomConfirm(title, "Save", "Cancel", content, func(ok bool) { +// only when the user saves. The caller shows the returned modal. +func dialogWithSave(title string, content fyne.CanvasObject, onSave func()) *modal { + m := newModalConfirm(title, "Save", "Cancel", content, func(ok bool) { if ok { onSave() } - }, win) - d.Resize(fyne.NewSize(440, 500)) - return d + }) + m.SetCardSize(fyne.NewSize(440, 0)) + return m } // showDetail presents read-only content with just a Close button. @@ -37,9 +93,9 @@ func showDetail(title string, content fyne.CanvasObject) { if win == nil { return } - d := dialog.NewCustom(title, "Close", content, win) - d.Resize(fyne.NewSize(440, 340)) - d.Show() + m := newModalWithClose(title, "Close", content) + m.SetCardSize(fyne.NewSize(440, 0)) + m.Show() } // showContextMenu pops up a desktop-style menu at the given screen position. @@ -56,29 +112,54 @@ func showActionConfirm(title, confirmLabel string, content fyne.CanvasObject, on if win == nil { return } - d := dialog.NewCustomConfirm(title, confirmLabel, "Cancel", content, func(ok bool) { + m := newModalConfirm(title, confirmLabel, "Cancel", content, func(ok bool) { if ok { onConfirm() } - }, win) - d.Resize(fyne.NewSize(460, 460)) - d.Show() + }) + m.SetCardSize(fyne.NewSize(460, 0)) + m.Show() +} + +// wrapLabel is a word-wrapped message body for info/error modals. +func wrapLabel(msg string) fyne.CanvasObject { + l := widget.NewLabel(msg) + l.Wrapping = fyne.TextWrapWord + return l } func showInfo(title, msg string) { if win == nil { return } - dialog.ShowInformation(title, msg, win) + m := newModalWithClose(title, "OK", wrapLabel(msg)) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() +} + +// showError presents err in a modal alert — the in-app stand-in for +// dialog.ShowError. +func showError(err error) { + if err == nil || win == nil { + return + } + m := newModalWithClose("Error", "OK", wrapLabel(err.Error())) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() } func confirmDelete(what string, onYes func()) { if win == nil { return } - dialog.ShowConfirm("Delete", "Delete "+what+"? This cannot be undone.", func(ok bool) { - if ok { - onYes() - } - }, win) + m := newModal("Delete", wrapLabel("Delete "+what+"? This cannot be undone.")) + danger := widget.NewButton("Delete", nil) + danger.Importance = widget.DangerImportance + danger.OnTapped = func() { + m.Hide() + onYes() + } + m.SetButtons(secondaryButton("Cancel", nil, func() { m.Hide() }), danger) + m.SetCardSize(fyne.NewSize(420, 0)) + m.Show() } diff --git a/internal/ui/view/import.go b/internal/ui/view/import.go index a093135..d764b62 100644 --- a/internal/ui/view/import.go +++ b/internal/ui/view/import.go @@ -48,7 +48,7 @@ func ImportCSV() { defer func() { _ = r.Close() }() data, e := io.ReadAll(r) if e != nil { - dialog.ShowError(e, win) + showError(e) return } records, e := core.ParseCSV(data) @@ -134,7 +134,7 @@ func showImportMapping(records [][]string) { ) updateMode(mode.Selected) - var d *dialog.CustomDialog + var d *modal preview := primaryButton("Preview…", nil, func() { spec := core.ImportSpec{ AccountID: idForName(store.MoneyAccounts(), account.Selected), @@ -163,8 +163,8 @@ func showImportMapping(records [][]string) { nil, nil, container.NewScroll(form), ) - d = dialog.NewCustomWithoutButtons("Import CSV", content, win) - d.Resize(fyne.NewSize(540, 620)) + d = newModal("Import CSV", content) + d.SetCardSize(fyne.NewSize(540, 620)) d.Show() } @@ -245,7 +245,7 @@ func showImportPreview(records [][]string, spec core.ImportSpec) { container.NewScroll(tbl.Build()), ) - var d *dialog.CustomDialog + var d *modal back := secondaryButton("Back", nil, func() { d.Hide(); showImportMapping(records) }) cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) doImport := primaryButton(fmt.Sprintf("Import %d", okN), nil, func() { @@ -258,7 +258,7 @@ func showImportPreview(records [][]string, spec core.ImportSpec) { d.Hide() n, err := store.AddTransactions(commit) if err != nil { - dialog.ShowError(err, win) + showError(err) return } showInfo("Import complete", fmt.Sprintf("Imported %d transaction(s) into %s.\nSkipped %d duplicate(s) and %d unparseable row(s).", @@ -269,8 +269,8 @@ func showImportPreview(records [][]string, spec core.ImportSpec) { container.NewHBox(layoutSpacer(), cancel, back, doImport)) content := container.NewBorder(nil, footer, nil, nil, body) - d = dialog.NewCustomWithoutButtons("Review import", content, win) - d.Resize(fyne.NewSize(760, 600)) + d = newModal("Review import", content) + d.SetCardSize(fyne.NewSize(760, 600)) d.Show() } diff --git a/internal/ui/view/recurring.go b/internal/ui/view/recurring.go index 36765d3..0d192ab 100644 --- a/internal/ui/view/recurring.go +++ b/internal/ui/view/recurring.go @@ -7,7 +7,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -17,39 +16,216 @@ import ( var errAmount = errors.New("enter an amount greater than zero") -// ScreenRecurring lists recurring templates and lets you add/edit/delete them and -// post what's due. +// ScreenRecurring is the forward-looking recurring view: what the schedule +// costs per month, what's coming in the next 30 days, and the templates +// themselves. Nothing here posts without confirmation. func ScreenRecurring() fyne.CanvasObject { - bar := appBar("Recurring", "Scheduled transactions — posted on your confirmation when due", + bar := appBar("Recurring", "Financy drafts each entry when due — you review and confirm before anything posts", primaryButton("Add Recurring", theme.ContentAddIcon(), func() { RecurringForm(nil) }), ) rs := store.Recurrings() if len(rs) == 0 { - body := panel(emptyState("No recurring transactions yet. Add rent, salary, subscriptions…")) - return container.NewBorder(bar, nil, nil, nil, container.NewPadded(body)) + return container.NewBorder(bar, nil, nil, nil, container.NewPadded(recurringWelcome())) } - rows := make([]fyne.CanvasObject, 0, len(rs)) - for _, r := range rs { - rows = append(rows, recurringRow(r)) - } - body := panel(container.NewVBox(rows...)) + sum := store.RecurringSummaryFor(todaySerial) return container.NewBorder(bar, nil, nil, nil, container.NewPadded(container.NewVBox( - dueBanner(), body, + dueBanner(sum), + recurringSummaryRow(sum), + spacerH(6), + upcomingPanel(), + spacerH(6), + templatesPanel(rs), ))) } +// recurringWelcome explains the feature when no templates exist yet. +func recurringWelcome() fyne.CanvasObject { + box := container.NewVBox( + txt("Put your bills on autopilot — with a veto", colText, 18, true), + spacerH(4), + txt("Add rent, salary and subscriptions once. Financy tracks the schedule,", colTextDim, 12, false), + txt("shows what's coming and what it all costs per month, and drafts each", colTextDim, 12, false), + txt("entry when it's due. Nothing posts without your confirmation.", colTextDim, 12, false), + spacerH(12), + container.NewHBox(primaryButton("Add Recurring", theme.ContentAddIcon(), func() { RecurringForm(nil) })), + ) + return container.NewCenter(panel(container.New(padCell(20, 28), box))) +} + // dueBanner shows a one-line nudge when something is due. -func dueBanner() fyne.CanvasObject { - n := len(store.PendingRecurring(todaySerial)) - if n == 0 { +func dueBanner(sum RecurringSummary) fyne.CanvasObject { + if sum.DueCount == 0 { return spacerH(0) } - msg := itoa(n) + " recurring entr" + plural(n, "y", "ies") + " due" - return container.NewBorder(nil, spacerH(6), nil, - secondaryButton("Review & Post", theme.MediaPlayIcon(), postDueNow), - container.NewHBox(txt("● ", colWarning, 13, true), txt(msg, colText, 12.5, true))) + msg := itoa(sum.DueCount) + " recurring entr" + plural(sum.DueCount, "y", "ies") + " due · " + fmtMoney(sum.DueTotal) + return container.NewVBox( + alertBanner(theme.WarningIcon(), msg, colWarning, + secondaryButton("Review & Post", theme.MediaPlayIcon(), postDueNow)), + spacerH(6), + ) +} + +// recurringSummaryRow is the commitment header: what the enabled templates add +// up to per month, normalized from each one's frequency. +func recurringSummaryRow(sum RecurringSummary) fyne.CanvasObject { + nExp, nInc := 0, 0 + for _, r := range store.Recurrings() { + if !r.Enabled { + continue + } + switch r.Kind { + case "Expense": + nExp++ + case "Income": + nInc++ + } + } + pausedSub := "all running" + if sum.Paused > 0 { + pausedSub = itoa(sum.Paused) + " paused" + } + return container.NewGridWithColumns(4, + statCard("MONTHLY BILLS", fmtMoney(sum.MonthlyExpense), colNegative, itoa(nExp)+" template"+plural(nExp, "", "s")), + statCard("MONTHLY INCOME", fmtMoney(sum.MonthlyIncome), colPositive, itoa(nInc)+" template"+plural(nInc, "", "s")), + statCard("NET PER MONTH", fmtMoney(sum.MonthlyNet), moneyColor(sum.MonthlyNet), "income − bills"), + statCard("ACTIVE", itoa(sum.Active), colText, pausedSub), + ) +} + +// maxBucketRows caps each timeline bucket so a dense schedule stays scannable. +const maxBucketRows = 8 + +// upcomingPanel is the 30-day timeline: overdue and due first, then this week, +// then later this month. +func upcomingPanel() fyne.CanvasObject { + occ := store.UpcomingRecurring(todaySerial, todaySerial+30) + + if len(occ) == 0 { + msg := "Nothing scheduled in the next 30 days." + if next := store.UpcomingRecurring(todaySerial, todaySerial+366); len(next) > 0 { + msg = "Nothing scheduled in the next 30 days — next up: " + + fmtSerialDate(next[0].Date) + " · " + orDash(next[0].Payee) + } + return panel(container.New(padCell(10, 12), container.NewVBox( + sectionTitle("Next 30 days"), spacerH(6), + txt(msg, colTextDim, 12, false)))) + } + + var due, week, later []Occurrence + for _, o := range occ { + switch { + case o.Due: + due = append(due, o) + case o.Date <= todaySerial+7: + week = append(week, o) + default: + later = append(later, o) + } + } + + rows := []fyne.CanvasObject{sectionTitle("Next 30 days"), spacerH(4)} + bucket := func(label string, labelCol color.Color, items []Occurrence, action fyne.CanvasObject) { + if len(items) == 0 { + return + } + head := container.NewBorder(nil, nil, txt(label, labelCol, 10.5, true), action) + rows = append(rows, spacerH(4), head, spacerH(2)) + for i, o := range items { + if i == maxBucketRows { + rows = append(rows, container.New(padCell(4, 12), + txt("+ "+itoa(len(items)-maxBucketRows)+" more", colTextDim, 11, false))) + break + } + rows = append(rows, upcomingRow(o)) + } + } + bucket("OVERDUE & DUE", colWarning, due, + secondaryButton("Review & Post", theme.MediaPlayIcon(), postDueNow)) + bucket("THIS WEEK", colTextDim, week, nil) + bucket("LATER THIS MONTH", colTextDim, later, nil) + return panel(container.New(padCell(10, 12), container.NewVBox(rows...))) +} + +// upcomingRow is one projected occurrence on the timeline. Tapping opens the +// template it comes from; right-click offers the template actions. +func upcomingRow(o Occurrence) fyne.CanvasObject { + amtCol := colNegative + switch o.Kind { + case "Income": + amtCol = colPositive + case "Transfer": + amtCol = colText + } + title := o.Payee + if title == "" { + title = o.Kind + } + state, stateCol := dueLabel(o.Date) + + left := container.NewHBox( + mono(fmtSerialDate(o.Date), colTextDim, 11.5, false), + spacerW(10), + container.NewVBox( + txt(title, colText, 13, true), + txt(o.Kind+" · "+orDash(nameOf(o.AcctB)), colTextDim, 10.5, false), + ), + ) + right := container.NewVBox( + alignRight(txt(amountLabel(o.Kind, o.Amount), amtCol, 13.5, true)), + alignRight(txt(state, stateCol, 10, o.Due)), + ) + inner := container.NewBorder(nil, nil, left, right) + + var fill color.Color = colSurface + if o.Due { + fill = withAlpha(colWarning, 0x14) // faint amber tint so due rows stand out + } + open := func() { + if r := store.RecurringByID(o.RecurringID); r != nil { + RecurringForm(r) + } + } + row := newTappableRow(container.New(padCell(7, 12), inner), fill, open) + row.SetBordered() + row.SetOnSecondary(func(pos fyne.Position) { + if r := store.RecurringByID(o.RecurringID); r != nil { + showContextMenu(pos, recurringMenu(*r)...) + } + }) + return row +} + +// dueLabel words an occurrence's distance from today ("today", "in 5 days", +// "3 days overdue"), amber once it's due. +func dueLabel(date int) (string, color.Color) { + d := date - todaySerial + switch { + case d < -1: + return itoa(-d) + " days overdue", colWarning + case d == -1: + return "1 day overdue", colWarning + case d == 0: + return "today", colWarning + case d == 1: + return "tomorrow", colTextDim + default: + return "in " + itoa(d) + " days", colTextDim + } +} + +// templatesPanel lists every template for managing the schedule itself. +func templatesPanel(rs []Recurring) fyne.CanvasObject { + rows := []fyne.CanvasObject{ + container.NewBorder(nil, nil, sectionTitle("All templates"), + txt(itoa(len(rs))+" template"+plural(len(rs), "", "s"), colTextDim, 11, false)), + spacerH(6), + } + for _, r := range rs { + rows = append(rows, recurringRow(r)) + } + return panel(container.New(padCell(10, 12), container.NewVBox(rows...))) } func recurringRow(r Recurring) fyne.CanvasObject { @@ -144,7 +320,7 @@ func postRecurringNow(r Recurring) { sel.widget, ) - var d *dialog.CustomDialog + var d *modal cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) confirm := primaryButton("Confirm", theme.ConfirmIcon(), func() { create := sel.isPostNew() @@ -162,8 +338,8 @@ func postRecurringNow(r Recurring) { content := container.NewVBox(body, spacerH(16), container.NewHBox(layout.NewSpacer(), cancel, confirm)) - d = dialog.NewCustomWithoutButtons("Post now (early)", content, win) - d.Resize(fyne.NewSize(520, 300)) + d = newModal("Post now (early)", content) + d.SetCardSize(fyne.NewSize(520, 300)) d.Show() } @@ -295,7 +471,7 @@ func showTxnPicker(accountID string, onPick func(*Transaction)) { search.SetPlaceHolder("Search payee…") listBox := container.NewVBox() - var d *dialog.CustomDialog + var d *modal render := func(q string) { listBox.Objects = nil ql := strings.ToLower(strings.TrimSpace(q)) @@ -337,8 +513,8 @@ func showTxnPicker(accountID string, onPick func(*Transaction)) { nil, nil, container.NewScroll(listBox), ) - d = dialog.NewCustomWithoutButtons("Link to a transaction", body, win) - d.Resize(fyne.NewSize(560, 520)) + d = newModal("Link to a transaction", body) + d.SetCardSize(fyne.NewSize(560, 520)) d.Show() } @@ -443,6 +619,41 @@ func CheckRecurringDue() { return } if items := store.PendingRecurring(todaySerial); len(items) > 0 { + lastDueSig = dueSignature(items) + showDuePrompt(items) + } +} + +// lastDueSig is the due set last shown (or deferred with Later), so periodic +// re-checks only prompt again when something new becomes due. +var lastDueSig string + +func dueSignature(items []DueItem) string { + parts := make([]string, len(items)) + for i, it := range items { + parts[i] = it.RecurringID + ":" + itoa(it.Date) + } + return strings.Join(parts, ",") +} + +// RecheckRecurringDue is the periodic due check (called from the app's minute +// ticker). It re-prompts only when the due set changed since the last prompt — +// so "Later" defers until something new becomes due — and never over an open +// dialog or menu. +func RecheckRecurringDue() { + if store == nil || win == nil { + return + } + if win.Canvas().Overlays().Top() != nil { + return // don't stomp whatever the user is doing + } + items := store.PendingRecurring(todaySerial) + if len(items) == 0 { + lastDueSig = "" + return + } + if sig := dueSignature(items); sig != lastDueSig { + lastDueSig = sig showDuePrompt(items) } } @@ -482,7 +693,7 @@ func showDuePrompt(items []DueItem) { container.NewScroll(container.NewVBox(rows...)), ) - var d *dialog.CustomDialog + var d *modal later := secondaryButton("Later", nil, func() { d.Hide() }) apply := primaryButton("Apply", theme.ConfirmIcon(), func() { var toPost []DueItem @@ -497,7 +708,7 @@ func showDuePrompt(items []DueItem) { d.Hide() n, err := store.PostRecurring(todaySerial, toPost) if err != nil { - dialog.ShowError(err, win) + showError(err) return } msg := "Posted " + itoa(n) + " new transaction" + plural(n, "", "s") + "." @@ -510,8 +721,8 @@ func showDuePrompt(items []DueItem) { content := container.NewBorder(nil, container.NewVBox(spacerH(8), container.NewHBox(layout.NewSpacer(), later, apply)), nil, nil, body) - d = dialog.NewCustomWithoutButtons("Recurring due", content, win) - d.Resize(fyne.NewSize(580, 500)) + d = newModal("Recurring due", content) + d.SetCardSize(fyne.NewSize(580, 500)) d.Show() } diff --git a/internal/ui/view/recurring_screen_test.go b/internal/ui/view/recurring_screen_test.go index 175e260..373efec 100644 --- a/internal/ui/view/recurring_screen_test.go +++ b/internal/ui/view/recurring_screen_test.go @@ -19,22 +19,23 @@ func addDemoRecurring(s *core.Store, due int) core.Recurring { return rs[len(rs)-1] } -// With no templates the recurring screen shows its empty state. The empty store -// (categories only) seeds no recurring templates. +// With no templates the recurring screen shows its explainer welcome. The +// empty store (categories only) seeds no recurring templates. func TestScreenRecurringEmptyState(t *testing.T) { _, w := emptyViewTest(t) screen := mount(w, ScreenRecurring()) - assertText(t, screen, "Recurring", "No recurring transactions yet") + assertText(t, screen, "Recurring", "autopilot", "Nothing posts without your confirmation") } // A template renders as a row with payee, frequency and amount; a past-due one -// surfaces the due banner. +// surfaces the due banner, the overdue bucket, and the commitment stats. func TestScreenRecurringWithDueItem(t *testing.T) { s, w := newViewTest(t) addDemoRecurring(s, todaySerial-1) // due yesterday screen := mount(w, ScreenRecurring()) - assertText(t, screen, "Rent", "Monthly", "due", "Review & Post") + assertText(t, screen, "Rent", "Monthly", "due", "Review & Post", + "MONTHLY BILLS", "MONTHLY INCOME", "NET PER MONTH", "OVERDUE & DUE", "All templates") } // A future-dated, enabled template shows its next-due date, not the due banner. @@ -46,6 +47,35 @@ func TestScreenRecurringFutureItem(t *testing.T) { assertText(t, screen, "Rent", "next ·") } +// The 30-day timeline buckets occurrences: overdue/due, this week, and later +// this month, each with the payee and a distance-from-today label. +func TestUpcomingPanelBuckets(t *testing.T) { + s, w := newViewTest(t) + addDemoRecurring(s, todaySerial-1) // overdue + s.AddRecurring(core.Recurring{Kind: "Income", AcctA: "checking", AcctB: "salary", + Amount: 450000, Payee: "Payroll", Freq: "Monthly", NextDue: todaySerial + 3, Enabled: true}) + s.AddRecurring(core.Recurring{Kind: "Expense", AcctA: "checking", AcctB: "housing", + Amount: 5000, Payee: "Cloud", Freq: "Monthly", NextDue: todaySerial + 20, Enabled: true}) + + screen := mount(w, ScreenRecurring()) + assertText(t, screen, "OVERDUE & DUE", "THIS WEEK", "LATER THIS MONTH", + "Rent", "Payroll", "Cloud", "1 day overdue", "in 3 days", "in 20 days") +} + +// The commitment stats normalize each frequency to a monthly figure. +func TestRecurringSummaryStats(t *testing.T) { + s, w := newViewTest(t) + for _, r := range s.Recurrings() { // drop demo templates so the total is ours + s.DeleteRecurring(r.ID) + } + s.AddRecurring(core.Recurring{Kind: "Expense", AcctA: "checking", AcctB: "housing", + Amount: 120000, Payee: "Insurance", Freq: "Yearly", NextDue: todaySerial + 40, Enabled: true}) + + screen := mount(w, ScreenRecurring()) + // 120000/12 = 10000 minor units → $100.00 monthly bills. + assertText(t, screen, "MONTHLY BILLS", "100.00", "ACTIVE") +} + // Adding a template through RecurringForm persists it to the store. func TestRecurringFormAdd(t *testing.T) { s, w := newViewTest(t) @@ -104,6 +134,51 @@ func TestRecurringMenuItems(t *testing.T) { } } +// RecheckRecurringDue prompts when something is due, stays quiet while the due +// set is unchanged (Later means later), skips when a dialog is already open, +// and prompts again once a new occurrence becomes due. +func TestRecheckRecurringDue(t *testing.T) { + s, w := newViewTest(t) + lastDueSig = "" + t.Cleanup(func() { lastDueSig = "" }) + + // Nothing due → no prompt, signature stays clear. + RecheckRecurringDue() + if dialogContent(w) != nil { + t.Fatal("prompted with nothing due") + } + + addDemoRecurring(s, todaySerial-1) + RecheckRecurringDue() + d := dialogContent(w) + if d == nil { + t.Fatal("expected a due prompt") + } + + // A dialog is open → the next tick must not stack another prompt. + RecheckRecurringDue() + if !tapButton(dialogContent(w), "Later") { + t.Fatal("no Later button on due prompt") + } + if dialogContent(w) != nil { + t.Fatal("Later did not dismiss the prompt") + } + + // Unchanged due set → deferred, no re-prompt. + RecheckRecurringDue() + if dialogContent(w) != nil { + t.Fatal("re-prompted for an unchanged due set") + } + + // A second template becomes due → the set changed, prompt again. + s.AddRecurring(core.Recurring{Kind: "Expense", AcctA: "checking", AcctB: "housing", + Amount: 5000, Payee: "Cloud", Freq: "Monthly", NextDue: todaySerial, Enabled: true}) + RecheckRecurringDue() + if dialogContent(w) == nil { + t.Fatal("expected a prompt after a new occurrence became due") + } +} + // amountLabel signs the amount per kind; plural picks the right word. func TestAmountLabelAndPlural(t *testing.T) { _, _ = newViewTest(t) diff --git a/internal/ui/view/transactions.go b/internal/ui/view/transactions.go index b97ecb3..33d6383 100644 --- a/internal/ui/view/transactions.go +++ b/internal/ui/view/transactions.go @@ -9,7 +9,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -157,9 +156,12 @@ func ScreenTransactions() fyne.CanvasObject { render() }) - filters := panel(container.NewVBox( - container.NewHBox(labeledControl("Month", monthSel), labeledControl("Type", typeSel), labeledControl("Account", acctSel)), - container.NewBorder(nil, nil, nil, clearBtn, labeledControl("Search", search)), + // One slim row: the selects' values describe themselves ("All months", …), + // so no caption row — the search field takes the remaining width. + filters := panel(container.NewBorder(nil, nil, + container.NewHBox(monthSel, typeSel, acctSel, spacerW(4)), + clearBtn, + container.New(padCell(0, 4), search), )) rows := filteredTransactions() @@ -175,9 +177,9 @@ func ScreenTransactions() fyne.CanvasObject { } summary := container.NewGridWithColumns(4, statCard("SHOWING", itoa(len(rows)), colText, "of "+itoa(len(store.Transactions()))+" entries"), - statCard("INCOME (filtered)", fmtMoney(inc), colPositive, ""), - statCard("EXPENSES (filtered)", fmtMoney(exp), colNegative, ""), - statCard("NET (filtered)", fmtMoney(inc-exp), moneyColor(inc-exp), ""), + statCard("INCOME", fmtMoney(inc), colPositive, "in current view"), + statCard("EXPENSES", fmtMoney(exp), colNegative, "in current view"), + statCard("NET", fmtMoney(inc-exp), moneyColor(inc-exp), "income − expenses"), ) items := []fyne.CanvasObject{filters, spacerH(4)} @@ -186,15 +188,16 @@ func ScreenTransactions() fyne.CanvasObject { } else { items = append(items, summary, spacerH(4)) } - items = append(items, panel(container.NewVBox(sectionTitle("Journal"), spacerH(4), txnLedger(rows)))) + items = append(items, txnLedger(rows)) body := container.NewVBox(items...) return container.NewBorder(bar, nil, nil, nil, container.NewPadded(body)) } -// txnLedger renders transactions as clean rows grouped under date headers. +// txnLedger renders the journal as one card per day: a date header with the +// day's net, then that day's transactions separated by hairlines. func txnLedger(rows []Transaction) fyne.CanvasObject { if len(rows) == 0 { - return container.New(padCell(20, 8), emptyState("No transactions match the current filters")) + return panel(container.New(padCell(20, 8), emptyState("No transactions match the current filters"))) } // Net income−expense per day, for the date headers. @@ -210,35 +213,42 @@ func txnLedger(rows []Transaction) fyne.CanvasObject { } box := container.NewVBox() + var day *fyne.Container + flush := func() { + if day != nil { + box.Add(panel(container.New(padCell(2, 2), day))) + box.Add(spacerH(2)) + } + } curDate := -1 - for i, t := range rows { + for _, t := range rows { if t.Date != curDate { - if i > 0 { - box.Add(spacerH(8)) - } - box.Add(dateHeader(t.Date, dayNet[t.Date])) + flush() + day = container.NewVBox(dateHeader(t.Date, dayNet[t.Date]), divider()) curDate = t.Date + } else { + day.Add(divider()) } - box.Add(txnRow(t)) - box.Add(divider()) + day.Add(txnRow(t)) } + flush() return box } +// dateHeader is a day card's title row: the date on the left, the day's net +// (when it has one) on the right. func dateHeader(date, net int) fyne.CanvasObject { - bg := canvas.NewRectangle(colSurfaceHi) - bg.CornerRadius = radiusSm - netCol := colTextDim - netStr := "Net " + fmtMoney(net) - if net > 0 { - netCol = colPositive - } else if net < 0 { - netCol = colNegative + right := spacerW(0) + if net != 0 { + netCol := colPositive + if net < 0 { + netCol = colNegative + } + right = alignRight(mono("Net "+fmtMoney(net), netCol, 11.5, true)) } row := container.NewBorder(nil, nil, - txt(relDate(date), colText, 11.5, true), - alignRight(txt(netStr, netCol, 11, true))) - return container.NewStack(bg, container.New(padCell(4, 8), row)) + txt(relDate(date), colText, 12, true), right) + return container.New(padCell(6, 10), row) } func relDate(serial int) string { @@ -403,17 +413,23 @@ func bulkRecategorize() { // typeChip is the small colored square that flags the transaction kind. func typeChip(kind string) fyne.CanvasObject { col := kindColor(kind) - glyph := map[string]string{"Income": "+", "Expense": "−", "Transfer": "⇄", "Opening": "•", "Split": "≡"}[kind] + glyph := map[string]string{"Income": "+", "Expense": "−", "Transfer": "→", "Opening": "•", "Split": "≡"}[kind] if glyph == "" { glyph = "•" } - bg := canvas.NewRectangle(withAlpha(col, 0x1f)) - bg.StrokeColor = withAlpha(col, 0x70) - bg.StrokeWidth = 1 - bg.CornerRadius = radiusSm - g := txt(glyph, col, 16, true) + bg := canvas.NewRectangle(withAlpha(col, 0x22)) + bg.CornerRadius = 14 + g := txt(glyph, col, 14, true) g.Alignment = fyne.TextAlignCenter - return container.NewGridWrap(fyne.NewSize(32, 32), container.NewStack(bg, container.NewCenter(g))) + // Arrows sit above the em-box middle in the bundled font while the other + // glyphs center cleanly; nudge just the arrow onto the circle's true + // center (offset measured from rendered pixels). + inner := fyne.CanvasObject(g) + if glyph == "→" { + inner = container.New(layout.NewCustomPaddedLayout(7, 0, 1, 0), g) + } + chip := container.NewGridWrap(fyne.NewSize(28, 28), container.NewStack(bg, container.NewCenter(inner))) + return container.NewCenter(chip) } func txnMenuItems(t Transaction) []*fyne.MenuItem { @@ -443,10 +459,6 @@ func duplicateTxn(t Transaction) { store.AddTransaction(cp) } -func labeledControl(label string, w fyne.CanvasObject) fyne.CanvasObject { - return container.NewVBox(txt(label, colTextDim, 10, true), w) -} - func kindColor(k string) color.Color { switch k { case "Income": @@ -510,7 +522,7 @@ func filteredTransactions() []Transaction { func TransactionForm(id, prefillMoney string) { var existing *Transaction var v txnView - var formDialog dialog.Dialog + var formDialog *modal if id != "" { existing = store.TxnByID(id) if existing != nil { @@ -637,12 +649,12 @@ func TransactionForm(id, prefillMoney string) { // Editing keeps the plain Save/Cancel form. Adding uses a custom footer with a // "Save & Add another" action that keeps the dialog open for rapid entry. if existing != nil { - formDialog = dialog.NewForm(title, "Save", "Cancel", items, func(ok bool) { + formDialog = newModalForm(title, "Save", "Cancel", items, func(ok bool) { if ok { commit() } - }, win) - formDialog.Resize(fyne.NewSize(460, 420)) + }) + formDialog.SetCardSize(fyne.NewSize(460, 0)) formDialog.Show() return } @@ -663,7 +675,7 @@ func TransactionForm(id, prefillMoney string) { win.Canvas().Focus(amount) } - var d *dialog.CustomDialog + var d *modal cancel := secondaryButton("Cancel", nil, func() { d.Hide() }) saveAnother := secondaryButton("Save & Add another", theme.ContentAddIcon(), func() { if commit() { @@ -677,9 +689,9 @@ func TransactionForm(id, prefillMoney string) { }) content := container.NewVBox(formGrid, spacerH(8), container.NewHBox(layout.NewSpacer(), cancel, saveAnother, save)) - d = dialog.NewCustomWithoutButtons(title, content, win) + d = newModal(title, content) formDialog = d - d.Resize(fyne.NewSize(480, 460)) + d.SetCardSize(fyne.NewSize(480, 460)) d.Show() } @@ -764,7 +776,7 @@ func payInstallmentForm() { form = widget.NewForm(payDebtItems(debts[0], debtSel, instRow, amtLabelRow, amtEntryRow, date)...) debtSel.SetSelected(debtNames[0]) - dlg := dialog.NewCustomConfirm("Pay debt", "Save", "Cancel", form, func(ok bool) { + dlg := newModalConfirm("Pay debt", "Save", "Cancel", form, func(ok bool) { if !ok { return } @@ -782,8 +794,8 @@ func payInstallmentForm() { if id := instID[instSel.Selected]; id != "" { store.PayInstallment(id, serial) } - }, win) - dlg.Resize(fyne.NewSize(460, 340)) + }) + dlg.SetCardSize(fyne.NewSize(460, 340)) dlg.Show() } diff --git a/internal/ui/view/transactions_test.go b/internal/ui/view/transactions_test.go index 2cca5b1..f66fec1 100644 --- a/internal/ui/view/transactions_test.go +++ b/internal/ui/view/transactions_test.go @@ -68,8 +68,7 @@ func TestScreenTransactionsRenders(t *testing.T) { assertText(t, screen, "Transactions", "Add Transaction", "Import CSV", "Select", - "SHOWING", "INCOME (filtered)", "EXPENSES (filtered)", "NET (filtered)", - "Journal", + "SHOWING", "INCOME", "EXPENSES", "NET", "in current view", ) // The "showing N of M entries" count reflects the store. assertText(t, screen, "of "+itoa(len(s.Transactions()))+" entries") diff --git a/internal/ui/view/uitest_test.go b/internal/ui/view/uitest_test.go index 7de5153..7598278 100644 --- a/internal/ui/view/uitest_test.go +++ b/internal/ui/view/uitest_test.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/v2/widget" "github.com/raihanstark/financy/internal/core" + "github.com/raihanstark/financy/internal/ui/component" "github.com/raihanstark/financy/internal/ui/style" ) @@ -30,12 +31,11 @@ func newViewTest(t *testing.T) (*core.Store, fyne.Window) { w.Resize(fyne.NewSize(1280, 860)) prevStore, prevWin, prevRender, prevNav := store, win, render, nav - var navTarget string + gotNav = "" store = s win = w render = func() {} - nav = func(uid string) { navTarget = uid } - _ = navTarget + nav = func(uid string) { gotNav = uid } t.Cleanup(func() { w.Close() @@ -45,6 +45,9 @@ func newViewTest(t *testing.T) (*core.Store, fyne.Window) { return s, w } +// gotNav records the last uid passed to nav by the screen under test. +var gotNav string + // emptyViewTest is like newViewTest but the store has no money accounts and no // transactions (only the seed categories), for exercising empty-state paths. func emptyViewTest(t *testing.T) (*core.Store, fyne.Window) { @@ -183,6 +186,42 @@ func assertNoText(t *testing.T, obj fyne.CanvasObject, unwanted ...string) { } } +// tapRowWithText finds the first TappableRow whose subtree contains want and +// taps it, reporting whether one was found. +func tapRowWithText(obj fyne.CanvasObject, want string) bool { + var found *component.TappableRow + seen := map[fyne.CanvasObject]bool{} + var walk func(o fyne.CanvasObject) + walk = func(o fyne.CanvasObject) { + if o == nil || seen[o] || found != nil { + return + } + seen[o] = true + if r, ok := o.(*component.TappableRow); ok { + if strings.Contains(strings.Join(collectText(r), "\n"), want) { + found = r + return + } + } + switch v := o.(type) { + case *fyne.Container: + for _, c := range v.Objects { + walk(c) + } + case fyne.Widget: + for _, c := range childObjects(v) { + walk(c) + } + } + } + walk(obj) + if found == nil { + return false + } + test.Tap(found) + return true +} + // findButtons returns every *widget.Button in a tree. func findButtons(obj fyne.CanvasObject) []*widget.Button { var out []*widget.Button diff --git a/internal/ui/view/view.go b/internal/ui/view/view.go index 61e9d17..3cacb36 100644 --- a/internal/ui/view/view.go +++ b/internal/ui/view/view.go @@ -42,6 +42,8 @@ type ( CategorySlice = core.CategorySlice Recurring = core.Recurring DueItem = core.DueItem + Occurrence = core.Occurrence + RecurringSummary = core.RecurringSummary Debt = core.Debt Installment = core.Installment DebtStatus = core.DebtStatus @@ -115,6 +117,7 @@ type ( tableSpec = component.TableSpec cell = component.Cell columnsLayout = component.ColumnsLayout + modal = component.Modal ) var ( @@ -169,6 +172,10 @@ var ( func init() { SyncPalette() } +// SyncToday refreshes the local copy of core.TodaySerial after a date rollover +// (the app can run across midnight), so due tints and time buckets stay honest. +func SyncToday() { todaySerial = core.TodaySerial } + // SyncPalette refreshes the local palette aliases from the active style palette. func SyncPalette() { colSurface = style.Surface @@ -183,7 +190,3 @@ func SyncPalette() { colWarning = style.Warning } -const ( - radius = style.Radius - radiusSm = style.RadiusSm -) diff --git a/website/content/guide/recurring.md b/website/content/guide/recurring.md index ed0146d..39421a5 100644 --- a/website/content/guide/recurring.md +++ b/website/content/guide/recurring.md @@ -9,6 +9,30 @@ transaction for you to confirm**. They never post silently. ![Recurring screen](../../img/recurring-screen.png) +## What's coming + +The Recurring screen leads with the forward view, not the template list: + +- **Commitment stats** — what your enabled templates add up to per month: + **Monthly bills**, **Monthly income**, and the **net per month**. Every + frequency is normalized to a monthly figure (weekly ×52⁄12, biweekly ×26⁄12, + quarterly ÷3, yearly ÷12); transfers are excluded since they only move money + between your own accounts. +- **Next 30 days** — a timeline of every projected occurrence, bucketed into + **Overdue & due** (amber, with an inline **Review & Post**), **This week**, + and **Later this month**. Each row shows the date, payee, category, amount, + and how far away it is ("today", "in 5 days", "3 days overdue"). Click a row + to edit its template; right-click for the usual actions. + +The **Accounts** overview shows the same information at a glance: an +**Upcoming** card with anything due plus the next 14 days of occurrences, +linking straight to the Recurring screen. + +While the app is open, Financy re-checks the schedule about once a minute — +crossing midnight or catching up a due entry doesn't require reopening the +file. The review prompt only reappears when something *new* becomes due, so +choosing **Later** actually defers it. + ## Add a template On the **Recurring** screen, click **Add Recurring** and fill in the same fields as @@ -32,8 +56,8 @@ occurrence won't also prompt you later. ## Posting what's due -When you open a document, if anything is due Financy shows a prompt — or click -**Post Due Now** any time. For **each** due occurrence you choose, from a dropdown, +When you open a document — or while it sits open and something becomes due — +Financy shows a prompt; you can also click **Review & Post** any time. For **each** due occurrence you choose, from a dropdown, either to **post a new transaction** or to **link it to the existing transaction that already paid it**: diff --git a/website/static/img/accounts.png b/website/static/img/accounts.png index 845ed25..638174e 100644 Binary files a/website/static/img/accounts.png and b/website/static/img/accounts.png differ diff --git a/website/static/img/analytics.png b/website/static/img/analytics.png index b0f2ca2..28aba3a 100644 Binary files a/website/static/img/analytics.png and b/website/static/img/analytics.png differ diff --git a/website/static/img/budget-assign.png b/website/static/img/budget-assign.png index 2280a0c..49d1be6 100644 Binary files a/website/static/img/budget-assign.png and b/website/static/img/budget-assign.png differ diff --git a/website/static/img/budget.png b/website/static/img/budget.png index 47b4af9..961712f 100644 Binary files a/website/static/img/budget.png and b/website/static/img/budget.png differ diff --git a/website/static/img/categories.png b/website/static/img/categories.png index beb3010..c87591c 100644 Binary files a/website/static/img/categories.png and b/website/static/img/categories.png differ diff --git a/website/static/img/reconcile-dialog.png b/website/static/img/reconcile-dialog.png index 4db2c49..f4059c1 100644 Binary files a/website/static/img/reconcile-dialog.png and b/website/static/img/reconcile-dialog.png differ diff --git a/website/static/img/reconcile-result.png b/website/static/img/reconcile-result.png index a6f1c53..4ecde28 100644 Binary files a/website/static/img/reconcile-result.png and b/website/static/img/reconcile-result.png differ diff --git a/website/static/img/recurring-due.png b/website/static/img/recurring-due.png index ab01c15..047f776 100644 Binary files a/website/static/img/recurring-due.png and b/website/static/img/recurring-due.png differ diff --git a/website/static/img/recurring-screen.png b/website/static/img/recurring-screen.png index 488f693..bbaddef 100644 Binary files a/website/static/img/recurring-screen.png and b/website/static/img/recurring-screen.png differ diff --git a/website/static/img/transactions.png b/website/static/img/transactions.png index b3f3840..f8d3c79 100644 Binary files a/website/static/img/transactions.png and b/website/static/img/transactions.png differ