From 61ff949c19cf01de12477084453935145c86765f Mon Sep 17 00:00:00 2001 From: Nouredeen06 Date: Sun, 5 Apr 2026 23:08:34 +0300 Subject: [PATCH] Add analytics page, auth error handling, and period navigation fix Features Analytics Page: Full-featured analytics dashboard with KPI cards, cash flow trend chart, net worth progression, spending patterns by day-of-week, top spending categories, and income sources breakdown. Includes PDF export via QuestPDF for selected periods. Implemented on both desktop and mobile (simplified). Auth Error Handling: Map Supabase GotrueException errors to AuthError enum with user-friendly messages for login and signup. Display errors in sign-in and sign-up panels. Dynamic Transaction/Account Counts: Replace hardcoded "46 transactions" and "4 accounts" text with FilteredTransactionCount and ActiveAccountCount properties bound to actual data. Fixes Budget Period Navigation: Fix year-aware date comparison in CanGoToPreviousPeriod and CanGoToNextPeriod. Previously only compared months, preventing navigation before January of current year. Changes AnalyticsViewModel: Period selector, KPI calculations, chart data builders (cash flow, net worth, day-of-week, top categories, income sources), PDF export PdfExportService: QuestPDF report generation with print-optimized styling AuthViewModel: Error display with GotrueException mapping BudgetViewModel: Year-aware period navigation TransactionsViewModel: FilteredTransactionCount property AccountsViewModel: ActiveAccountCount property MainViewModel: Analytics navigation and AnalyticsViewModel integration Views: Analytics button wired, error messages displayed, count bindings updated --- Clario.Android/Clario.Android.csproj | 1 + Clario.Browser/Clario.Browser.csproj | 1 + Clario.Desktop/Clario.Desktop.csproj | 1 + Clario.iOS/Clario.iOS.csproj | 1 + Clario/Assets/Icons/bike.svg | 16 + Clario/Assets/Icons/book-open.svg | 14 + Clario/Assets/Icons/bus.svg | 19 + Clario/Assets/Icons/camera.svg | 14 + Clario/Assets/Icons/coffee.svg | 16 + Clario/Assets/Icons/dumbbell.svg | 17 + Clario/Assets/Icons/film.svg | 20 + Clario/Assets/Icons/gift.svg | 16 + Clario/Assets/Icons/graduation-cap.svg | 15 + Clario/Assets/Icons/headphones.svg | 13 + Clario/Assets/Icons/leaf.svg | 14 + Clario/Assets/Icons/music.svg | 15 + Clario/Assets/Icons/package.svg | 16 + Clario/Assets/Icons/pizza.svg | 17 + Clario/Assets/Icons/plane.svg | 13 + Clario/Assets/Icons/scissors.svg | 17 + Clario/Assets/Icons/shirt.svg | 13 + Clario/Assets/Icons/smartphone.svg | 14 + Clario/Assets/Icons/stethoscope.svg | 17 + Clario/Assets/Icons/train-front.svg | 18 + Clario/Assets/Icons/tv.svg | 14 + Clario/Assets/Icons/wine.svg | 16 + Clario/Assets/Icons/wrench.svg | 13 + Clario/Clario.csproj | 55 +- Clario/Data/GeneralDataRepo.cs | 193 ++++++- Clario/Enums/AuthError.cs | 14 + .../MobileViews/AccountFormViewMobile.axaml | 408 ++++++++++++++ .../AccountFormViewMobile.axaml.cs | 11 + Clario/MobileViews/AccountsViewMobile.axaml | 134 +++-- .../MobileViews/AccountsViewMobile.axaml.cs | 4 +- Clario/MobileViews/AnalyticsViewMobile.axaml | 161 ++++++ .../MobileViews/AnalyticsViewMobile.axaml.cs | 11 + .../ArchiveAccountDialogViewMobile.axaml | 123 +++++ .../ArchiveAccountDialogViewMobile.axaml.cs | 11 + .../ArchivedAccountsDialogViewMobile.axaml | 138 +++++ .../ArchivedAccountsDialogViewMobile.axaml.cs | 11 + Clario/MobileViews/AuthViewMobile.axaml | 387 +++++++++++++ Clario/MobileViews/AuthViewMobile.axaml.cs | 11 + Clario/MobileViews/BudgetFormViewMobile.axaml | 393 ++++++++++++++ .../MobileViews/BudgetFormViewMobile.axaml.cs | 11 + Clario/MobileViews/BudgetViewMobile.axaml | 52 +- .../MobileViews/CategoryFormViewMobile.axaml | 320 +++++++++++ .../CategoryFormViewMobile.axaml.cs | 11 + Clario/MobileViews/DashboardViewMobile.axaml | 512 ++++++++++-------- .../DeleteAccountDialogViewMobile.axaml | 454 ++++++++++++++++ .../DeleteAccountDialogViewMobile.axaml.cs | 11 + Clario/MobileViews/MainViewMobile.axaml | 13 +- .../SetSavingsGoalDialogViewMobile.axaml | 150 +++++ .../SetSavingsGoalDialogViewMobile.axaml.cs | 11 + Clario/MobileViews/SettingsViewMobile.axaml | 486 +++++++++++++++++ .../MobileViews/SettingsViewMobile.axaml.cs | 11 + .../TransactionFormViewMobile.axaml | 239 +++++++- .../MobileViews/TransactionsViewMobile.axaml | 161 ++++-- Clario/Models/CategorySpendRow.cs | 12 + Clario/Models/Transaction.cs | 5 + Clario/Services/PdfExportService.cs | 300 ++++++++++ Clario/ViewModels/AccountsViewModel.cs | 87 ++- Clario/ViewModels/AnalyticsViewModel.cs | 436 +++++++++++++++ Clario/ViewModels/AuthViewModel.cs | 56 ++ Clario/ViewModels/BudgetViewModel.cs | 6 +- Clario/ViewModels/CategoryFormViewModel.cs | 192 +++++++ Clario/ViewModels/DashboardViewModel.cs | 16 +- Clario/ViewModels/MainViewModel.cs | 96 +++- Clario/ViewModels/TransactionFormViewModel.cs | 150 ++++- Clario/ViewModels/TransactionsViewModel.cs | 25 +- Clario/Views/AccountsView.axaml | 52 +- Clario/Views/AnalyticsView.axaml | 276 ++++++++++ Clario/Views/AnalyticsView.axaml.cs | 26 + Clario/Views/ArchiveAccountDialogView.axaml | 120 ++++ .../Views/ArchiveAccountDialogView.axaml.cs | 11 + Clario/Views/ArchivedAccountsDialogView.axaml | 133 +++++ .../Views/ArchivedAccountsDialogView.axaml.cs | 11 + Clario/Views/AuthView.axaml | 10 +- Clario/Views/BudgetView.axaml | 4 +- Clario/Views/CategoryFormView.axaml | 325 +++++++++++ Clario/Views/CategoryFormView.axaml.cs | 11 + Clario/Views/DashboardView.axaml | 11 +- Clario/Views/MainView.axaml | 7 +- Clario/Views/TransactionFormView.axaml | 143 ++++- Clario/Views/TransactionsView.axaml | 141 +++-- Directory.Packages.props | 1 + 85 files changed, 6988 insertions(+), 543 deletions(-) create mode 100644 Clario/Assets/Icons/bike.svg create mode 100644 Clario/Assets/Icons/book-open.svg create mode 100644 Clario/Assets/Icons/bus.svg create mode 100644 Clario/Assets/Icons/camera.svg create mode 100644 Clario/Assets/Icons/coffee.svg create mode 100644 Clario/Assets/Icons/dumbbell.svg create mode 100644 Clario/Assets/Icons/film.svg create mode 100644 Clario/Assets/Icons/gift.svg create mode 100644 Clario/Assets/Icons/graduation-cap.svg create mode 100644 Clario/Assets/Icons/headphones.svg create mode 100644 Clario/Assets/Icons/leaf.svg create mode 100644 Clario/Assets/Icons/music.svg create mode 100644 Clario/Assets/Icons/package.svg create mode 100644 Clario/Assets/Icons/pizza.svg create mode 100644 Clario/Assets/Icons/plane.svg create mode 100644 Clario/Assets/Icons/scissors.svg create mode 100644 Clario/Assets/Icons/shirt.svg create mode 100644 Clario/Assets/Icons/smartphone.svg create mode 100644 Clario/Assets/Icons/stethoscope.svg create mode 100644 Clario/Assets/Icons/train-front.svg create mode 100644 Clario/Assets/Icons/tv.svg create mode 100644 Clario/Assets/Icons/wine.svg create mode 100644 Clario/Assets/Icons/wrench.svg create mode 100644 Clario/Enums/AuthError.cs create mode 100644 Clario/MobileViews/AccountFormViewMobile.axaml create mode 100644 Clario/MobileViews/AccountFormViewMobile.axaml.cs create mode 100644 Clario/MobileViews/AnalyticsViewMobile.axaml create mode 100644 Clario/MobileViews/AnalyticsViewMobile.axaml.cs create mode 100644 Clario/MobileViews/ArchiveAccountDialogViewMobile.axaml create mode 100644 Clario/MobileViews/ArchiveAccountDialogViewMobile.axaml.cs create mode 100644 Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml create mode 100644 Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml.cs create mode 100644 Clario/MobileViews/AuthViewMobile.axaml create mode 100644 Clario/MobileViews/AuthViewMobile.axaml.cs create mode 100644 Clario/MobileViews/BudgetFormViewMobile.axaml create mode 100644 Clario/MobileViews/BudgetFormViewMobile.axaml.cs create mode 100644 Clario/MobileViews/CategoryFormViewMobile.axaml create mode 100644 Clario/MobileViews/CategoryFormViewMobile.axaml.cs create mode 100644 Clario/MobileViews/DeleteAccountDialogViewMobile.axaml create mode 100644 Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs create mode 100644 Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml create mode 100644 Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs create mode 100644 Clario/MobileViews/SettingsViewMobile.axaml create mode 100644 Clario/MobileViews/SettingsViewMobile.axaml.cs create mode 100644 Clario/Models/CategorySpendRow.cs create mode 100644 Clario/Services/PdfExportService.cs create mode 100644 Clario/ViewModels/AnalyticsViewModel.cs create mode 100644 Clario/ViewModels/CategoryFormViewModel.cs create mode 100644 Clario/Views/AnalyticsView.axaml create mode 100644 Clario/Views/AnalyticsView.axaml.cs create mode 100644 Clario/Views/ArchiveAccountDialogView.axaml create mode 100644 Clario/Views/ArchiveAccountDialogView.axaml.cs create mode 100644 Clario/Views/ArchivedAccountsDialogView.axaml create mode 100644 Clario/Views/ArchivedAccountsDialogView.axaml.cs create mode 100644 Clario/Views/CategoryFormView.axaml create mode 100644 Clario/Views/CategoryFormView.axaml.cs diff --git a/Clario.Android/Clario.Android.csproj b/Clario.Android/Clario.Android.csproj index a97e0a7..76c6176 100644 --- a/Clario.Android/Clario.Android.csproj +++ b/Clario.Android/Clario.Android.csproj @@ -20,6 +20,7 @@ + diff --git a/Clario.Browser/Clario.Browser.csproj b/Clario.Browser/Clario.Browser.csproj index 49cb37a..7279f1f 100644 --- a/Clario.Browser/Clario.Browser.csproj +++ b/Clario.Browser/Clario.Browser.csproj @@ -13,6 +13,7 @@ + diff --git a/Clario.Desktop/Clario.Desktop.csproj b/Clario.Desktop/Clario.Desktop.csproj index 41f803f..7b010b8 100644 --- a/Clario.Desktop/Clario.Desktop.csproj +++ b/Clario.Desktop/Clario.Desktop.csproj @@ -22,6 +22,7 @@ + diff --git a/Clario.iOS/Clario.iOS.csproj b/Clario.iOS/Clario.iOS.csproj index bf8b569..92cc263 100644 --- a/Clario.iOS/Clario.iOS.csproj +++ b/Clario.iOS/Clario.iOS.csproj @@ -13,6 +13,7 @@ + diff --git a/Clario/Assets/Icons/bike.svg b/Clario/Assets/Icons/bike.svg new file mode 100644 index 0000000..403139a --- /dev/null +++ b/Clario/Assets/Icons/bike.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/Clario/Assets/Icons/book-open.svg b/Clario/Assets/Icons/book-open.svg new file mode 100644 index 0000000..6906352 --- /dev/null +++ b/Clario/Assets/Icons/book-open.svg @@ -0,0 +1,14 @@ + + + + diff --git a/Clario/Assets/Icons/bus.svg b/Clario/Assets/Icons/bus.svg new file mode 100644 index 0000000..9fdef2d --- /dev/null +++ b/Clario/Assets/Icons/bus.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/Clario/Assets/Icons/camera.svg b/Clario/Assets/Icons/camera.svg new file mode 100644 index 0000000..1e82bf4 --- /dev/null +++ b/Clario/Assets/Icons/camera.svg @@ -0,0 +1,14 @@ + + + + diff --git a/Clario/Assets/Icons/coffee.svg b/Clario/Assets/Icons/coffee.svg new file mode 100644 index 0000000..52d4da3 --- /dev/null +++ b/Clario/Assets/Icons/coffee.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/Clario/Assets/Icons/dumbbell.svg b/Clario/Assets/Icons/dumbbell.svg new file mode 100644 index 0000000..4aa46a2 --- /dev/null +++ b/Clario/Assets/Icons/dumbbell.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Clario/Assets/Icons/film.svg b/Clario/Assets/Icons/film.svg new file mode 100644 index 0000000..fae7568 --- /dev/null +++ b/Clario/Assets/Icons/film.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/Clario/Assets/Icons/gift.svg b/Clario/Assets/Icons/gift.svg new file mode 100644 index 0000000..c33b33f --- /dev/null +++ b/Clario/Assets/Icons/gift.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/Clario/Assets/Icons/graduation-cap.svg b/Clario/Assets/Icons/graduation-cap.svg new file mode 100644 index 0000000..1c0009b --- /dev/null +++ b/Clario/Assets/Icons/graduation-cap.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/Clario/Assets/Icons/headphones.svg b/Clario/Assets/Icons/headphones.svg new file mode 100644 index 0000000..4cee5b5 --- /dev/null +++ b/Clario/Assets/Icons/headphones.svg @@ -0,0 +1,13 @@ + + + diff --git a/Clario/Assets/Icons/leaf.svg b/Clario/Assets/Icons/leaf.svg new file mode 100644 index 0000000..f322ef0 --- /dev/null +++ b/Clario/Assets/Icons/leaf.svg @@ -0,0 +1,14 @@ + + + + diff --git a/Clario/Assets/Icons/music.svg b/Clario/Assets/Icons/music.svg new file mode 100644 index 0000000..dd9faac --- /dev/null +++ b/Clario/Assets/Icons/music.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/Clario/Assets/Icons/package.svg b/Clario/Assets/Icons/package.svg new file mode 100644 index 0000000..308b1f4 --- /dev/null +++ b/Clario/Assets/Icons/package.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/Clario/Assets/Icons/pizza.svg b/Clario/Assets/Icons/pizza.svg new file mode 100644 index 0000000..796e5cc --- /dev/null +++ b/Clario/Assets/Icons/pizza.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Clario/Assets/Icons/plane.svg b/Clario/Assets/Icons/plane.svg new file mode 100644 index 0000000..6f843bf --- /dev/null +++ b/Clario/Assets/Icons/plane.svg @@ -0,0 +1,13 @@ + + + diff --git a/Clario/Assets/Icons/scissors.svg b/Clario/Assets/Icons/scissors.svg new file mode 100644 index 0000000..f87bf20 --- /dev/null +++ b/Clario/Assets/Icons/scissors.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Clario/Assets/Icons/shirt.svg b/Clario/Assets/Icons/shirt.svg new file mode 100644 index 0000000..90bba75 --- /dev/null +++ b/Clario/Assets/Icons/shirt.svg @@ -0,0 +1,13 @@ + + + diff --git a/Clario/Assets/Icons/smartphone.svg b/Clario/Assets/Icons/smartphone.svg new file mode 100644 index 0000000..fc84017 --- /dev/null +++ b/Clario/Assets/Icons/smartphone.svg @@ -0,0 +1,14 @@ + + + + diff --git a/Clario/Assets/Icons/stethoscope.svg b/Clario/Assets/Icons/stethoscope.svg new file mode 100644 index 0000000..1d26e38 --- /dev/null +++ b/Clario/Assets/Icons/stethoscope.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Clario/Assets/Icons/train-front.svg b/Clario/Assets/Icons/train-front.svg new file mode 100644 index 0000000..d340f46 --- /dev/null +++ b/Clario/Assets/Icons/train-front.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/Clario/Assets/Icons/tv.svg b/Clario/Assets/Icons/tv.svg new file mode 100644 index 0000000..96140a5 --- /dev/null +++ b/Clario/Assets/Icons/tv.svg @@ -0,0 +1,14 @@ + + + + diff --git a/Clario/Assets/Icons/wine.svg b/Clario/Assets/Icons/wine.svg new file mode 100644 index 0000000..fbddb1d --- /dev/null +++ b/Clario/Assets/Icons/wine.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/Clario/Assets/Icons/wrench.svg b/Clario/Assets/Icons/wrench.svg new file mode 100644 index 0000000..3624343 --- /dev/null +++ b/Clario/Assets/Icons/wrench.svg @@ -0,0 +1,13 @@ + + + diff --git a/Clario/Clario.csproj b/Clario/Clario.csproj index 8413ec0..2eb9283 100644 --- a/Clario/Clario.csproj +++ b/Clario/Clario.csproj @@ -1,6 +1,6 @@  - net8.0 + net8.0;net8.0-android enable latest true @@ -13,8 +13,8 @@ - - + + @@ -23,31 +23,38 @@ All - - - - - - - - - - + + + + + + + + + + + - - MobileMainView.axaml - Code - - - AccountFormView.axaml - + + MobileMainView.axaml + Code + + + AccountFormView.axaml + + + CategoryFormView.axaml + + + AnalyticsView.axaml + - - - Always - + + + Always + diff --git a/Clario/Data/GeneralDataRepo.cs b/Clario/Data/GeneralDataRepo.cs index a6882ab..80e482a 100644 --- a/Clario/Data/GeneralDataRepo.cs +++ b/Clario/Data/GeneralDataRepo.cs @@ -129,14 +129,178 @@ public partial class GeneralDataRepo : ObservableObject } } + public async Task InsertTransfer(Guid fromAccountId, Guid toAccountId, decimal amount, DateTime date, string? note) + { + try + { + var userId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id!); + var pairId = Guid.NewGuid(); + + var fromCurrency = Accounts.FirstOrDefault(a => a.Id == fromAccountId)?.Currency ?? ""; + var toCurrency = Accounts.FirstOrDefault(a => a.Id == toAccountId)?.Currency ?? ""; + var toAmount = ConvertAmount(amount, fromCurrency, toCurrency); + + var outTx = new Transaction + { + Id = Guid.NewGuid(), + UserId = userId, + AccountId = fromAccountId, + Type = "transfer_out", + Amount = amount, + Description = "Transfer", + Note = note?.Trim(), + Date = date, + TransferPairId = pairId, + }; + var inTx = new Transaction + { + Id = Guid.NewGuid(), + UserId = userId, + AccountId = toAccountId, + Type = "transfer_in", + Amount = toAmount, + Description = "Transfer", + Note = note?.Trim(), + Date = date, + TransferPairId = pairId, + }; + + var outResult = await SupabaseService.Client.From().Insert(outTx); + var inResult = await SupabaseService.Client.From().Insert(inTx); + + if (outResult.Models.Count >= 1) + { + var enriched = LinkTransactionCategories(outResult.Models[0]); + LinkTransactionAccounts(enriched); + Transactions.Add(enriched); + } + if (inResult.Models.Count >= 1) + { + var enriched = LinkTransactionCategories(inResult.Models[0]); + LinkTransactionAccounts(enriched); + Transactions.Add(enriched); + } + // Re-enrich both so AccountDisplayText can reference the counterpart (from/to) + LinkTransactionAccounts(); + } + catch (Exception e) + { + DebugLogger.Log(e); + throw; + } + } + + public async Task UpdateTransfer(Guid transferPairId, Guid fromAccountId, Guid toAccountId, decimal amount, DateTime date, string? note) + { + try + { + var pair = Transactions.Where(t => t.TransferPairId == transferPairId).ToList(); + var fromCurrency = Accounts.FirstOrDefault(a => a.Id == fromAccountId)?.Currency ?? ""; + var toCurrency = Accounts.FirstOrDefault(a => a.Id == toAccountId)?.Currency ?? ""; + var toAmount = ConvertAmount(amount, fromCurrency, toCurrency); + + foreach (var tx in pair) + { + tx.AccountId = tx.Type == "transfer_out" ? fromAccountId : toAccountId; + tx.Amount = tx.Type == "transfer_in" ? toAmount : amount; + tx.Date = date; + tx.Note = note?.Trim(); + var result = await SupabaseService.Client.From().Update(tx); + if (result.Model is null) continue; + var index = Transactions.IndexOf(tx); + if (index != -1) + { + var enriched = LinkTransactionCategories(result.Model); + LinkTransactionAccounts(enriched); + Transactions[index] = enriched; + } + } + LinkTransactionAccounts(); + } + catch (Exception e) + { + DebugLogger.Log(e); + throw; + } + } + + public async Task DeleteTransfer(Guid transferPairId) + { + try + { + await SupabaseService.Client.From() + .Where(x => x.TransferPairId == transferPairId) + .Delete(); + var pair = Transactions.Where(t => t.TransferPairId == transferPairId).ToList(); + foreach (var tx in pair) + Transactions.Remove(tx); + } + catch (Exception e) + { + DebugLogger.Log(e); + throw; + } + } + public async Task> FetchCategories(bool forceRefresh = false) { if (Categories.Count != 0 && !forceRefresh) return Categories.ToList(); + var categories = await SupabaseService.Client.From().Get(); Categories = new ObservableCollection(categories.Models); return categories.Models; } + public async Task InsertCategory(Category category) + { + try + { + var result = await SupabaseService.Client.From() + .Insert(category, new QueryOptions() { Returning = QueryOptions.ReturnType.Representation }); + if (result.Model is null) return null; + Categories.Add(result.Model); + return result.Model; + } + catch (Exception e) + { + DebugLogger.Log(e); + return null; + } + } + + public async Task UpdateCategory(Category category) + { + try + { + var result = await SupabaseService.Client.From().Update(category); + if (result.Model is null) return; + var item = Categories.FirstOrDefault(x => x.Id == result.Model.Id); + if (item is null) return; + var index = Categories.IndexOf(item); + if (index != -1) Categories[index] = result.Model; + } + catch (Exception e) + { + DebugLogger.Log(e); + } + } + + public async Task DeleteCategory(Guid id) + { + try + { + await SupabaseService.Client.From().Where(x => x.Id == id).Delete(); + var item = Categories.FirstOrDefault(x => x.Id == id); + if (item is null) return; + Categories.Remove(item); + } + catch (Exception e) + { + DebugLogger.Log(e); + throw; + } + } + public async Task> FetchAccounts(bool forceRefresh = false) { if (Accounts.Count != 0 && !forceRefresh) return Accounts.ToList(); @@ -294,7 +458,7 @@ public partial class GeneralDataRepo : ObservableObject var balance = accountResult.OpeningBalance + transactionsResult.Sum(t => - t.Type == "income" ? t.Amount : -t.Amount); + t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount); accountResult.CurrentBalance = balance; await SupabaseService.Client @@ -399,6 +563,20 @@ public partial class GeneralDataRepo : ObservableObject WeakReferenceMessenger.Default.Send(new RatesRefreshed()); } + /// Converts from to + /// using the current live rates. + /// Falls back to unchanged when currencies match or rates are missing. + private static decimal ConvertAmount(decimal amount, string fromCurrency, string toCurrency) + { + if (string.IsNullOrEmpty(fromCurrency) || string.IsNullOrEmpty(toCurrency)) return amount; + if (fromCurrency.Equals(toCurrency, StringComparison.OrdinalIgnoreCase)) return amount; + if (!CurrencyService.LiveRates.TryGetValue(fromCurrency, out var fromRate)) return amount; + if (!CurrencyService.LiveRates.TryGetValue(toCurrency, out var toRate) || toRate == 0) return amount; + // fromRate = 1 fromCurrency in primary; toRate = 1 toCurrency in primary + // amount * fromRate / toRate = amount converted to toCurrency + return Math.Round(amount * fromRate / toRate, 6); + } + public void LinkTransactionCategories() { foreach (var transaction in Transactions) @@ -441,6 +619,19 @@ public partial class GeneralDataRepo : ObservableObject tx.OriginalAmountFormatted = tx.IsMultiCurrency ? $"{CurrencyService.GetSymbol(accountCurrency)}{tx.Amount:N2}" : string.Empty; + + if (tx.IsTransfer && tx.TransferPairId.HasValue) + { + var counterpart = Transactions.FirstOrDefault(t => t.TransferPairId == tx.TransferPairId && t.Id != tx.Id); + var counterpartAccount = counterpart is not null ? Accounts.FirstOrDefault(a => a.Id == counterpart.AccountId) : null; + var fromName = tx.IsTransferOut ? (account?.Name ?? "?") : (counterpartAccount?.Name ?? "?"); + var toName = tx.IsTransferOut ? (counterpartAccount?.Name ?? "?") : (account?.Name ?? "?"); + tx.AccountDisplayText = $"{fromName} → {toName}"; + } + else + { + tx.AccountDisplayText = account?.Name ?? ""; + } } public async Task UpdateSavingsGoal(decimal? goal) diff --git a/Clario/Enums/AuthError.cs b/Clario/Enums/AuthError.cs new file mode 100644 index 0000000..636f62b --- /dev/null +++ b/Clario/Enums/AuthError.cs @@ -0,0 +1,14 @@ +namespace Clario.Enums; + +public enum AuthError +{ + InvalidCredentials, + EmailAlreadyExists, + EmailNotConfirmed, + WeakPassword, + InvalidEmail, + SignupDisabled, + RateLimited, + SessionExpired, + Unknown +} \ No newline at end of file diff --git a/Clario/MobileViews/AccountFormViewMobile.axaml b/Clario/MobileViews/AccountFormViewMobile.axaml new file mode 100644 index 0000000..780d2a1 --- /dev/null +++ b/Clario/MobileViews/AccountFormViewMobile.axaml @@ -0,0 +1,408 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/AccountFormViewMobile.axaml.cs b/Clario/MobileViews/AccountFormViewMobile.axaml.cs new file mode 100644 index 0000000..c477662 --- /dev/null +++ b/Clario/MobileViews/AccountFormViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class AccountFormViewMobile : UserControl +{ + public AccountFormViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/AccountsViewMobile.axaml b/Clario/MobileViews/AccountsViewMobile.axaml index cdd95e1..5193166 100644 --- a/Clario/MobileViews/AccountsViewMobile.axaml +++ b/Clario/MobileViews/AccountsViewMobile.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:model="clr-namespace:Clario.Models" + xmlns:mobileViews="clr-namespace:Clario.MobileViews" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Clario.MobileViews.AccountsViewMobile" x:DataType="vm:AccountsViewModel" @@ -30,14 +31,24 @@ FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" /> - + + + + @@ -70,11 +81,17 @@ Foreground="{DynamicResource TextPrimary}" VerticalAlignment="Center" /> + VerticalAlignment="Center"> + + + + + + + @@ -119,10 +136,23 @@ - + + + + + + - - @@ -239,20 +269,32 @@ FontSize="12" Foreground="{DynamicResource TextMuted}" /> - - + + + + + @@ -269,7 +311,7 @@ Padding="16,14"> - @@ -329,7 +371,7 @@ - - - + + + + + + diff --git a/Clario/MobileViews/ArchiveAccountDialogViewMobile.axaml.cs b/Clario/MobileViews/ArchiveAccountDialogViewMobile.axaml.cs new file mode 100644 index 0000000..508a0ce --- /dev/null +++ b/Clario/MobileViews/ArchiveAccountDialogViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class ArchiveAccountDialogViewMobile : UserControl +{ + public ArchiveAccountDialogViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml b/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml new file mode 100644 index 0000000..6b48c28 --- /dev/null +++ b/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml.cs b/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml.cs new file mode 100644 index 0000000..304bc46 --- /dev/null +++ b/Clario/MobileViews/ArchivedAccountsDialogViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class ArchivedAccountsDialogViewMobile : UserControl +{ + public ArchivedAccountsDialogViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/AuthViewMobile.axaml b/Clario/MobileViews/AuthViewMobile.axaml new file mode 100644 index 0000000..380a271 --- /dev/null +++ b/Clario/MobileViews/AuthViewMobile.axaml @@ -0,0 +1,387 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Clario/MobileViews/AuthViewMobile.axaml.cs b/Clario/MobileViews/AuthViewMobile.axaml.cs new file mode 100644 index 0000000..f40067c --- /dev/null +++ b/Clario/MobileViews/AuthViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class AuthViewMobile : UserControl +{ + public AuthViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/BudgetFormViewMobile.axaml b/Clario/MobileViews/BudgetFormViewMobile.axaml new file mode 100644 index 0000000..4f31182 --- /dev/null +++ b/Clario/MobileViews/BudgetFormViewMobile.axaml @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/BudgetFormViewMobile.axaml.cs b/Clario/MobileViews/BudgetFormViewMobile.axaml.cs new file mode 100644 index 0000000..50ff77d --- /dev/null +++ b/Clario/MobileViews/BudgetFormViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class BudgetFormViewMobile : UserControl +{ + public BudgetFormViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/BudgetViewMobile.axaml b/Clario/MobileViews/BudgetViewMobile.axaml index b827caf..9358c88 100644 --- a/Clario/MobileViews/BudgetViewMobile.axaml +++ b/Clario/MobileViews/BudgetViewMobile.axaml @@ -8,6 +8,7 @@ mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800" x:Class="Clario.MobileViews.BudgetViewMobile" x:DataType="vm:BudgetViewModel" + x:Name="budgetRoot" Classes="mobile"> @@ -63,7 +64,8 @@ @@ -419,7 +435,8 @@ @@ -427,8 +444,15 @@ - + + + + + + + + diff --git a/Clario/MobileViews/CategoryFormViewMobile.axaml b/Clario/MobileViews/CategoryFormViewMobile.axaml new file mode 100644 index 0000000..c1497a4 --- /dev/null +++ b/Clario/MobileViews/CategoryFormViewMobile.axaml @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/CategoryFormViewMobile.axaml.cs b/Clario/MobileViews/CategoryFormViewMobile.axaml.cs new file mode 100644 index 0000000..09dae62 --- /dev/null +++ b/Clario/MobileViews/CategoryFormViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class CategoryFormViewMobile : UserControl +{ + public CategoryFormViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/DashboardViewMobile.axaml b/Clario/MobileViews/DashboardViewMobile.axaml index 6de6328..20528e4 100644 --- a/Clario/MobileViews/DashboardViewMobile.axaml +++ b/Clario/MobileViews/DashboardViewMobile.axaml @@ -1,4 +1,4 @@ - + x:Class="Clario.MobileViews.DashboardViewMobile" + x:Name="DashboardRoot" + Classes="mobile"> - + - + - + + + + @@ -61,22 +69,22 @@ - - + - + + + + + + + + @@ -95,22 +103,22 @@ - - + - + + + + + + + + @@ -129,13 +137,10 @@ - - + @@ -170,75 +174,100 @@ - + + Padding="8,5" FontSize="12" /> - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -252,81 +281,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs new file mode 100644 index 0000000..0c35ce5 --- /dev/null +++ b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class DeleteAccountDialogViewMobile : UserControl +{ + public DeleteAccountDialogViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/MainViewMobile.axaml b/Clario/MobileViews/MainViewMobile.axaml index 1221ea6..3d5911c 100644 --- a/Clario/MobileViews/MainViewMobile.axaml +++ b/Clario/MobileViews/MainViewMobile.axaml @@ -3,7 +3,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:Clario.ViewModels" - xmlns:views="clr-namespace:Clario.Views" xmlns:mobileViews="clr-namespace:Clario.MobileViews" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Clario.MobileViews.MainViewMobile" @@ -19,6 +18,18 @@ DataContext="{Binding TransactionFormViewModel}" IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}"> + + + + diff --git a/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml new file mode 100644 index 0000000..f4525d4 --- /dev/null +++ b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs new file mode 100644 index 0000000..c72167f --- /dev/null +++ b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class SetSavingsGoalDialogViewMobile : UserControl +{ + public SetSavingsGoalDialogViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/SettingsViewMobile.axaml b/Clario/MobileViews/SettingsViewMobile.axaml new file mode 100644 index 0000000..f0c9220 --- /dev/null +++ b/Clario/MobileViews/SettingsViewMobile.axaml @@ -0,0 +1,486 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/SettingsViewMobile.axaml.cs b/Clario/MobileViews/SettingsViewMobile.axaml.cs new file mode 100644 index 0000000..4a44746 --- /dev/null +++ b/Clario/MobileViews/SettingsViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class SettingsViewMobile : UserControl +{ + public SettingsViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/TransactionFormViewMobile.axaml b/Clario/MobileViews/TransactionFormViewMobile.axaml index d4e59f8..58f2b61 100644 --- a/Clario/MobileViews/TransactionFormViewMobile.axaml +++ b/Clario/MobileViews/TransactionFormViewMobile.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:cc="clr-namespace:Clario.CustomControls" + xmlns:behaviors="clr-namespace:Clario.Behaviors" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800" x:Class="Clario.MobileViews.TransactionFormViewMobile" x:DataType="vm:TransactionFormViewModel" @@ -53,8 +54,8 @@ + + @@ -144,7 +165,7 @@ Height="64"> + + + - - + + + Margin="0,0,0,16" + IsVisible="{Binding !IsTransfer}" /> - - + + @@ -260,6 +287,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Width="340"> diff --git a/Clario/MobileViews/TransactionsViewMobile.axaml b/Clario/MobileViews/TransactionsViewMobile.axaml index 17f0b95..eee4e95 100644 --- a/Clario/MobileViews/TransactionsViewMobile.axaml +++ b/Clario/MobileViews/TransactionsViewMobile.axaml @@ -7,6 +7,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Clario.MobileViews.TransactionsViewMobile" x:DataType="vm:TransactionsViewModel" + x:Name="transactionsRoot" Classes="mobile"> @@ -37,8 +38,7 @@ BorderThickness="1" CornerRadius="16" Padding="20" - Width="300" - BoxShadow="0 8 32 0 #3C000000"> + Width="300"> - + + @@ -282,69 +294,98 @@ Classes="label" /> - - - + + diff --git a/Clario/Models/CategorySpendRow.cs b/Clario/Models/CategorySpendRow.cs new file mode 100644 index 0000000..01b4c7c --- /dev/null +++ b/Clario/Models/CategorySpendRow.cs @@ -0,0 +1,12 @@ +namespace Clario.Models; + +public class CategorySpendRow +{ + public string Name { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string Color { get; set; } = "#7B9CFF"; + public decimal Amount { get; set; } + public double Percentage { get; set; } + public string AmountFormatted { get; set; } = string.Empty; + public string PercentageFormatted => $"{Percentage:F1}%"; +} diff --git a/Clario/Models/Transaction.cs b/Clario/Models/Transaction.cs index 998dc01..2d3587e 100644 --- a/Clario/Models/Transaction.cs +++ b/Clario/Models/Transaction.cs @@ -35,10 +35,13 @@ public class Transaction : BaseModel [Column("exchange_rate")] public decimal? ExchangeRate { get; set; } + [Column("transfer_pair_id")] public Guid? TransferPairId { get; set; } + // Set during enrichment by GeneralDataRepo.LinkTransactionAccounts [JsonIgnore] public string AccountCurrency { get; set; } = ""; [JsonIgnore] public string PrimaryAmountFormatted { get; set; } = ""; [JsonIgnore] public string OriginalAmountFormatted { get; set; } = ""; + [JsonIgnore] public string AccountDisplayText { get; set; } = ""; [JsonIgnore] public decimal ConvertedAmount => !string.IsNullOrEmpty(AccountCurrency) && CurrencyService.LiveRates.TryGetValue(AccountCurrency, out var liveRate) @@ -48,5 +51,7 @@ public class Transaction : BaseModel [JsonIgnore] public string PrimaryAmountSignFormatted => Type == "expense" ? $"-{PrimaryAmountFormatted}" : $"+{PrimaryAmountFormatted}"; + [JsonIgnore] public bool IsTransfer => Type is "transfer_in" or "transfer_out"; + [JsonIgnore] public bool IsTransferOut => Type == "transfer_out"; [JsonIgnore] public bool GroupHeader { get; set; } = false; } \ No newline at end of file diff --git a/Clario/Services/PdfExportService.cs b/Clario/Services/PdfExportService.cs new file mode 100644 index 0000000..7e05b04 --- /dev/null +++ b/Clario/Services/PdfExportService.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform; +using Avalonia.Platform.Storage; +using Clario.Data; +using Clario.Models; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Clario.Services; + +public static class PdfExportService +{ + // ── Print palette (readable on white paper) ─────────── + private const string TextPrimary = "#111827"; + private const string TextSecondary = "#374151"; + private const string TextMuted = "#6B7280"; + private const string Border = "#E5E7EB"; + private const string HeaderBg = "#F3F4F6"; + private const string IncomeColor = "#15803D"; + private const string ExpenseColor = "#B91C1C"; + private const string BlueColor = "#1D4ED8"; + private const string AccentBar = "#1D4ED8"; // header rule + + static PdfExportService() + { + QuestPDF.Settings.License = LicenseType.Community; + } + + public static async Task ExportAsync( + GeneralDataRepo data, + DateTime start, + DateTime end, + string periodLabel, + List topCategories) + { + var culture = new CultureInfo("en-US"); + var sym = CurrencyService.GetSymbol(data.PrimaryAccount?.Currency ?? data.Profile?.Currency ?? "USD"); + var userName = data.Profile?.DisplayName ?? "User"; + + var periodTxs = data.Transactions + .Where(t => !t.IsTransfer && t.Date.Date >= start.Date && t.Date.Date <= end.Date) + .OrderByDescending(t => t.Date) + .ToList(); + + var totalIncome = periodTxs.Where(t => t.Type == "income").Sum(t => t.ConvertedAmount); + var totalExpenses = periodTxs.Where(t => t.Type == "expense").Sum(t => t.ConvertedAmount); + var net = totalIncome - totalExpenses; + var savingsRate = totalIncome > 0 ? net / totalIncome * 100 : 0; + var subtitle = $"{start.ToString("MMM d, yyyy", culture)} – {end.ToString("MMM d, yyyy", culture)}"; + var generatedAt = DateTime.Now.ToString("MMM d, yyyy 'at' h:mm tt", culture); + + // Load logo on the calling (UI) thread before entering Task.Run + byte[] logoBytes; + using (var assetStream = AssetLoader.Open(new Uri("avares://Clario/Assets/Logo/logo-combined-primary-transparent-384x128.png"))) + using (var ms = new MemoryStream()) + { + await assetStream.CopyToAsync(ms); + logoBytes = ms.ToArray(); + } + + byte[] pdfBytes = []; + + await Task.Run(() => + { + pdfBytes = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.MarginHorizontal(1.8f, Unit.Centimetre); + page.MarginVertical(1.5f, Unit.Centimetre); + page.DefaultTextStyle(s => s.FontSize(10).FontColor(TextPrimary).FontFamily("Arial")); + + // ── Header ──────────────────────────────────── + page.Header().Column(col => + { + col.Item().Row(row => + { + row.RelativeItem().Column(left => + { + left.Item().Height(32).Image(logoBytes).FitHeight(); + left.Item().PaddingTop(4).Text($"Financial Report — {periodLabel}") + .FontSize(12).SemiBold().FontColor(TextSecondary); + left.Item().Text(subtitle).FontSize(9).FontColor(TextMuted); + }); + row.ConstantItem(140).AlignRight().Column(right => + { + right.Item().AlignRight().Text(userName) + .FontSize(10).SemiBold().FontColor(TextPrimary); + right.Item().AlignRight().Text($"Generated {generatedAt}") + .FontSize(8).FontColor(TextMuted); + }); + }); + col.Item().PaddingTop(10).LineHorizontal(2).LineColor(AccentBar); + }); + + // ── Content ─────────────────────────────────── + page.Content().PaddingTop(18).Column(col => + { + // ─ KPI cards ───────────────────────────── + col.Item().Text("Summary").FontSize(11).SemiBold().FontColor(TextPrimary); + col.Item().PaddingTop(6).Table(table => + { + table.ColumnsDefinition(c => + { + c.RelativeColumn(); + c.RelativeColumn(); + c.RelativeColumn(); + c.RelativeColumn(); + }); + + void KpiCell(string label, string value, string valueColor) + { + table.Cell() + .Border(1).BorderColor(Border) + .Background(HeaderBg) + .Padding(12) + .Column(c => + { + c.Item().Text(label).FontSize(8).FontColor(TextMuted); + c.Item().PaddingTop(4).Text(value) + .FontSize(14).Bold().FontColor(valueColor); + }); + } + + KpiCell("TOTAL INCOME", $"{sym}{totalIncome:N2}", IncomeColor); + KpiCell("TOTAL EXPENSES", $"{sym}{totalExpenses:N2}", ExpenseColor); + KpiCell("NET SAVINGS", $"{(net >= 0 ? "+" : "")}{sym}{net:N2}", + net >= 0 ? IncomeColor : ExpenseColor); + KpiCell("SAVINGS RATE", totalIncome > 0 ? $"{savingsRate:F1}%" : "—", BlueColor); + }); + + col.Item().Height(20); + + // ─ Top categories ───────────────────────── + if (topCategories.Count > 0) + { + col.Item().Text("Top Spending Categories") + .FontSize(11).SemiBold().FontColor(TextPrimary); + col.Item().PaddingTop(6).Table(table => + { + table.ColumnsDefinition(c => + { + c.RelativeColumn(4); + c.RelativeColumn(2); + c.RelativeColumn(1); + }); + + // Header row — CATEGORY stays left, rest centered + void TH(string text, bool leftAlign = false) + { + var cell = table.Cell().Background(HeaderBg) + .PaddingHorizontal(8).PaddingVertical(7) + .BorderBottom(1).BorderColor(Border); + if (leftAlign) + cell.Text(text).FontSize(8).SemiBold().FontColor(TextMuted); + else + cell.AlignCenter().Text(text).FontSize(8).SemiBold().FontColor(TextMuted); + } + + TH("CATEGORY", leftAlign: true); TH("AMOUNT"); TH("SHARE"); + + foreach (var cat in topCategories) + { + table.Cell().PaddingHorizontal(8).PaddingVertical(7) + .BorderBottom(1).BorderColor(Border) + .Text(cat.Name).FontSize(10).FontColor(TextPrimary); + + table.Cell().PaddingHorizontal(8).PaddingVertical(7) + .BorderBottom(1).BorderColor(Border) + .Text(cat.AmountFormatted).FontSize(10).FontColor(TextSecondary); + + table.Cell().PaddingHorizontal(8).PaddingVertical(7) + .BorderBottom(1).BorderColor(Border) + .AlignRight() + .Text(cat.PercentageFormatted).FontSize(10).FontColor(TextMuted); + } + }); + + col.Item().Height(20); + } + + // ─ Transactions ─────────────────────────── + col.Item().Text($"Transactions ({periodTxs.Count})") + .FontSize(11).SemiBold().FontColor(TextPrimary); + col.Item().PaddingTop(6).Table(table => + { + table.ColumnsDefinition(c => + { + c.ConstantColumn(60); // date + c.RelativeColumn(3); // description + c.RelativeColumn(2); // category + c.RelativeColumn(2); // account + c.RelativeColumn(2); // amount + }); + + // Header — DESCRIPTION stays left, all others centered + void TH(string text, bool leftAlign = false) + { + var cell = table.Cell().Background(HeaderBg) + .PaddingHorizontal(6).PaddingVertical(7) + .BorderBottom(2).BorderColor(Border); + if (leftAlign) + cell.Text(text).FontSize(8).SemiBold().FontColor(TextMuted); + else + cell.AlignCenter().Text(text).FontSize(8).SemiBold().FontColor(TextMuted); + } + + TH("DATE"); TH("DESCRIPTION", leftAlign: true); TH("CATEGORY"); TH("ACCOUNT"); TH("AMOUNT"); + + foreach (var tx in periodTxs) + { + var amountStr = tx.Type == "income" + ? $"+{sym}{tx.ConvertedAmount:N2}" + : $"-{sym}{tx.ConvertedAmount:N2}"; + var amountColor = tx.Type == "income" ? IncomeColor : ExpenseColor; + + void TD(string text, string color = TextPrimary, bool rightAlign = false) + { + var cell = table.Cell() + .PaddingHorizontal(6).PaddingVertical(6) + .BorderBottom(1).BorderColor(Border); + if (rightAlign) + cell.AlignRight().Text(text).FontSize(9).FontColor(color); + else + cell.Text(text).FontSize(9).FontColor(color); + } + + TD(tx.Date.ToString("MMM d, yy", culture), TextMuted); + TD(tx.Description); + TD(tx.Category?.Name ?? "—", TextSecondary); + TD(tx.AccountDisplayText, TextSecondary); + TD(amountStr, amountColor, rightAlign: true); + } + }); + }); + + // ── Footer ──────────────────────────────────── + page.Footer().PaddingTop(6).BorderTop(1).BorderColor(Border).Row(row => + { + row.RelativeItem().Text("Generated by Clario — Your personal finance tracker") + .FontSize(8).FontColor(TextMuted); + row.ConstantItem(60).AlignRight().Text(t => + { + t.AlignRight(); + t.Span("Page ").FontSize(8).FontColor(TextMuted); + t.CurrentPageNumber().FontSize(8).FontColor(TextMuted); + t.Span(" of ").FontSize(8).FontColor(TextMuted); + t.TotalPages().FontSize(8).FontColor(TextMuted); + }); + }); + }); + }).GeneratePdf(); + }); + + return await SavePdfAsync(pdfBytes, $"Clario_Report_{DateTime.Now:yyyy-MM-dd}.pdf"); + } + + private static async Task SavePdfAsync(byte[] pdfBytes, string suggestedName) + { + var topLevel = GetTopLevel(); + if (topLevel is null) + { + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), suggestedName); + await File.WriteAllBytesAsync(path, pdfBytes); + return path; + } + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save PDF Report", + SuggestedFileName = suggestedName, + FileTypeChoices = [new FilePickerFileType("PDF Document") { Patterns = ["*.pdf"] }] + }); + + if (file is null) return null; + + await using var stream = await file.OpenWriteAsync(); + await stream.WriteAsync(pdfBytes); + return file.Path.LocalPath; + } + + private static TopLevel? GetTopLevel() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + return TopLevel.GetTopLevel(desktop.MainWindow); + if (Application.Current?.ApplicationLifetime is ISingleViewApplicationLifetime single) + return TopLevel.GetTopLevel(single.MainView as Visual); + return null; + } +} diff --git a/Clario/ViewModels/AccountsViewModel.cs b/Clario/ViewModels/AccountsViewModel.cs index 79823fd..3fc9507 100644 --- a/Clario/ViewModels/AccountsViewModel.cs +++ b/Clario/ViewModels/AccountsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Threading.Tasks; using Clario.Data; using Clario.Models; using Clario.Services; @@ -21,37 +22,56 @@ public partial class AccountsViewModel : ViewModelBase public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"); [ObservableProperty] private Account? _selectedAccount; [ObservableProperty] private bool _isAccountDeletionConfirmationVisible; - public bool CanDeleteAccount => VisibleAccounts.Count > 1; + public bool CanDeleteAccount => VisibleAccounts.Count(x => !x.GroupHeader) > 1; + public int ActiveAccountCount => VisibleAccounts.Count(x => !x.GroupHeader); [ObservableProperty] private bool _isDeleteDialogVisible; [ObservableProperty] private DeleteAccountDialogViewModel _deleteDialog = new(); + [ObservableProperty] private bool _isArchiveDialogVisible; + [ObservableProperty] private Account? _accountToArchive; + + [ObservableProperty] private bool _isArchivedListVisible; + [ObservableProperty] private List _archivedAccounts = new(); + public bool HasArchivedAccounts => ArchivedAccounts.Count > 0; + public AccountsViewModel() { AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); }; + AppData.Transactions.CollectionChanged += (_, _) => { Initialize(); }; Initialize(); } public void Initialize() { + var prevSelectedId = SelectedAccount?.Id; FetchAndProcessAccountInfo(); GroupAccounts(); - SelectedAccount = VisibleAccounts.FirstOrDefault(x => !x.GroupHeader); + ArchivedAccounts = AppData.Accounts.Where(a => a.IsArchived).ToList(); + OnPropertyChanged(nameof(HasArchivedAccounts)); + OnPropertyChanged(nameof(ActiveAccountCount)); + OnPropertyChanged(nameof(CanDeleteAccount)); + // Set to null first so PropertyChanged fires even when re-selecting the same account, + // ensuring the detail panel re-reads all computed properties (balance, income, etc.) + SelectedAccount = null; + SelectedAccount = (prevSelectedId.HasValue + ? VisibleAccounts.FirstOrDefault(a => a.Id == prevSelectedId && !a.GroupHeader) + : null) ?? VisibleAccounts.FirstOrDefault(x => !x.GroupHeader); } private void FetchAndProcessAccountInfo() { TotalBalance = 0; var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"; - foreach (var account in AppData.Accounts) + foreach (var account in AppData.Accounts.Where(a => !a.IsArchived)) { var accountTransactions = AppData.Transactions.Where(t => t.AccountId == account.Id).ToList(); account.TransactionsCount = accountTransactions.Count; - account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount); - account.TotalIncomeThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type == "income").Sum(t => t.Amount); - account.TotalExpenseThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type == "expense").Sum(t => t.Amount); - account.IncomeTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type == "income"); - account.ExpenseTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type == "expense"); + account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount); + account.TotalIncomeThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type is "income" or "transfer_in").Sum(t => t.Amount); + account.TotalExpenseThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type is "expense" or "transfer_out").Sum(t => t.Amount); + account.IncomeTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type is "income" or "transfer_in"); + account.ExpenseTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type is "expense" or "transfer_out"); account.RecentTransactions = accountTransactions.OrderByDescending(t => t.Date).Take(3).ToList(); var lastMonthBalance = accountTransactions.Where(t => t.Date.Month == DateTime.Now.AddMonths(-1).Month && t.Type == "income") .Sum(t => t.Type == "income" ? t.Amount : -t.Amount); @@ -59,7 +79,7 @@ public partial class AccountsViewModel : ViewModelBase if (account.Currency.Equals(primaryCurrency, StringComparison.OrdinalIgnoreCase)) TotalBalance += account.CurrentBalance; else - TotalBalance += accountTransactions.Sum(t => t.Type == "income" ? t.ConvertedAmount : -t.ConvertedAmount); + TotalBalance += accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount); } } @@ -91,7 +111,7 @@ public partial class AccountsViewModel : ViewModelBase foreach (var type in accountTypes) { var accountsOfType = AppData.Accounts - .Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase)) + .Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase) && !a.IsArchived) .OrderByDescending(a => a.IsPrimary) .ThenBy(a => a.CreatedAt) .ToList(); @@ -109,6 +129,53 @@ public partial class AccountsViewModel : ViewModelBase OnPropertyChanged(nameof(CanDeleteAccount)); } + [RelayCommand] + private void RequestArchiveAccount(Account account) + { + AccountToArchive = account; + IsArchiveDialogVisible = true; + } + + [RelayCommand] + private void CancelArchive() + { + IsArchiveDialogVisible = false; + AccountToArchive = null; + } + + [RelayCommand] + private async Task ConfirmArchive() + { + if (AccountToArchive is null) return; + AccountToArchive.IsArchived = true; + await AppData.UpdateAccount(AccountToArchive); + IsArchiveDialogVisible = false; + AccountToArchive = null; + Initialize(); + } + + [RelayCommand] + private void ShowArchivedList() + { + IsArchivedListVisible = true; + } + + [RelayCommand] + private void CloseArchivedList() + { + IsArchivedListVisible = false; + } + + [RelayCommand] + private async Task UnarchiveAccount(Account account) + { + account.IsArchived = false; + await AppData.UpdateAccount(account); + Initialize(); + if (!HasArchivedAccounts) + IsArchivedListVisible = false; + } + [RelayCommand] private void RequestDeleteAccount(Account account) { diff --git a/Clario/ViewModels/AnalyticsViewModel.cs b/Clario/ViewModels/AnalyticsViewModel.cs new file mode 100644 index 0000000..02824af --- /dev/null +++ b/Clario/ViewModels/AnalyticsViewModel.cs @@ -0,0 +1,436 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Clario.Data; +using Clario.Models; +using Clario.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LiveChartsCore; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using SKColor = SkiaSharp.SKColor; + +namespace Clario.ViewModels; + +public partial class AnalyticsViewModel : ViewModelBase +{ + public required ViewModelBase parentViewModel; + public GeneralDataRepo AppData => DataRepo.General; + + // ── Period ─────────────────────────────────────────── + public List PeriodOptions { get; } = new() + { + "Last 30 Days", "Last 3 Months", "Last 6 Months", "Last 12 Months", "This Year" + }; + + [ObservableProperty] private string _selectedPeriod = "Last 6 Months"; + + partial void OnSelectedPeriodChanged(string value) => Initialize(); + + // ── KPI cards ──────────────────────────────────────── + [ObservableProperty] private string _totalIncomeFormatted = "—"; + [ObservableProperty] private string _totalExpensesFormatted = "—"; + [ObservableProperty] private string _netSavingsFormatted = "—"; + [ObservableProperty] private string _savingsRateFormatted = "—"; + [ObservableProperty] private bool _netSavingsPositive = true; + + public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"); + + // ── Cash Flow chart ────────────────────────────────── + [ObservableProperty] private ISeries[] _cashFlowSeries = []; + [ObservableProperty] private Axis[] _cashFlowXAxes = []; + [ObservableProperty] private Axis[] _cashFlowYAxes = []; + + // ── Net Worth chart ────────────────────────────────── + [ObservableProperty] private ISeries[] _netWorthSeries = []; + [ObservableProperty] private Axis[] _netWorthXAxes = []; + [ObservableProperty] private Axis[] _netWorthYAxes = []; + + // ── Day-of-week chart ──────────────────────────────── + [ObservableProperty] private ISeries[] _dayOfWeekSeries = []; + [ObservableProperty] private Axis[] _dayOfWeekXAxes = []; + + // ── Top categories ─────────────────────────────────── + [ObservableProperty] private ObservableCollection _topCategories = new(); + [ObservableProperty] private bool _hasTopCategories; + + // ── Income sources donut ───────────────────────────── + [ObservableProperty] private ISeries[] _incomeSourcesSeries = []; + [ObservableProperty] private bool _hasIncomeSources; + + // ── State ──────────────────────────────────────────── + [ObservableProperty] private bool _isExporting; + [ObservableProperty] private string? _exportStatusMessage; + + // ───────────────────────────────────────────────────── + + public AnalyticsViewModel() + { + AppData.Transactions.CollectionChanged += (_, _) => Initialize(); + AppData.Accounts.CollectionChanged += (_, _) => Initialize(); + Initialize(); + } + + public void Initialize() + { + try + { + var (start, end) = GetDateRange(); + var periodTxs = AppData.Transactions + .Where(t => !t.IsTransfer && t.Date.Date >= start.Date && t.Date.Date <= end.Date) + .ToList(); + + var expenses = periodTxs.Where(t => t.Type == "expense").ToList(); + var income = periodTxs.Where(t => t.Type == "income").ToList(); + + ComputeKpis(income, expenses); + BuildCashFlowChart(start, end); + BuildNetWorthChart(start, end); + BuildDayOfWeekChart(expenses, start, end); + BuildTopCategories(expenses); + BuildIncomeSourcesChart(income); + OnPropertyChanged(nameof(PrimarySymbol)); + } + catch (Exception e) + { + DebugLogger.Log(e); + } + } + + // ── Date range ──────────────────────────────────────── + + private (DateTime start, DateTime end) GetDateRange() + { + var now = DateTime.Now; + return SelectedPeriod switch + { + "Last 30 Days" => (now.AddDays(-30), now), + "Last 3 Months" => (now.AddMonths(-3), now), + "Last 6 Months" => (now.AddMonths(-6), now), + "Last 12 Months" => (now.AddMonths(-12), now), + "This Year" => (new DateTime(now.Year, 1, 1), now), + _ => (now.AddMonths(-6), now) + }; + } + + private static List<(DateTime monthStart, DateTime monthEnd, string label)> GetMonthBuckets(DateTime start, DateTime end) + { + var buckets = new List<(DateTime, DateTime, string)>(); + var current = new DateTime(start.Year, start.Month, 1); + var endMonth = new DateTime(end.Year, end.Month, 1); + var culture = new CultureInfo("en-US"); + while (current <= endMonth) + { + var next = current.AddMonths(1); + buckets.Add((current, next.AddSeconds(-1), current.ToString("MMM ''yy", culture))); + current = next; + } + return buckets; + } + + // ── Section 1: KPIs ─────────────────────────────────── + + private void ComputeKpis(List income, List expenses) + { + var sym = PrimarySymbol; + var totalIncome = income.Sum(t => t.ConvertedAmount); + var totalExpenses = expenses.Sum(t => t.ConvertedAmount); + var net = totalIncome - totalExpenses; + + TotalIncomeFormatted = $"{sym}{totalIncome:N2}"; + TotalExpensesFormatted = $"{sym}{totalExpenses:N2}"; + NetSavingsPositive = net >= 0; + NetSavingsFormatted = $"{(net >= 0 ? "+" : "")}{sym}{net:N2}"; + SavingsRateFormatted = totalIncome > 0 + ? $"{Math.Max(0, (net / totalIncome) * 100):F1}%" + : "—"; + } + + // ── Section 2: Cash Flow ────────────────────────────── + + private void BuildCashFlowChart(DateTime start, DateTime end) + { + var buckets = GetMonthBuckets(start, end); + var incomeVals = new double[buckets.Count]; + var expenseVals = new double[buckets.Count]; + + for (var i = 0; i < buckets.Count; i++) + { + var (mStart, mEnd, _) = buckets[i]; + incomeVals[i] = (double)AppData.Transactions + .Where(t => t.Type == "income" && t.Date.Date >= mStart && t.Date.Date <= mEnd) + .Sum(t => t.ConvertedAmount); + expenseVals[i] = (double)AppData.Transactions + .Where(t => t.Type == "expense" && t.Date.Date >= mStart && t.Date.Date <= mEnd) + .Sum(t => t.ConvertedAmount); + } + + var labels = buckets.Select(b => b.label).ToArray(); + + CashFlowSeries = + [ + new LineSeries + { + Name = "Income", + Values = incomeVals, + Stroke = new SolidColorPaint(SKColor.Parse("#2ECC8A"), 2), + Fill = null, + GeometryFill = new SolidColorPaint(SKColor.Parse("#2ECC8A")), + GeometryStroke = null, + GeometrySize = 5, + LineSmoothness = 0.5 + }, + new LineSeries + { + Name = "Expenses", + Values = expenseVals, + Stroke = new SolidColorPaint(SKColor.Parse("#FF5E5E"), 2), + Fill = null, + GeometryFill = new SolidColorPaint(SKColor.Parse("#FF5E5E")), + GeometryStroke = null, + GeometrySize = 5, + LineSmoothness = 0.5 + } + ]; + + CashFlowXAxes = + [ + new Axis + { + Labels = labels, + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), + TicksPaint = null, + TextSize = 11 + } + ]; + + var sym = PrimarySymbol; + CashFlowYAxes = + [ + new Axis + { + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), + TicksPaint = null, + TextSize = 10, + Labeler = v => $"{sym}{v:N0}" + } + ]; + } + + // ── Section 3: Net Worth ────────────────────────────── + + private void BuildNetWorthChart(DateTime start, DateTime end) + { + // Start from 12 months before start to show history, but respect the selected range + var buckets = GetMonthBuckets(start, end); + var netWorthVals = new double[buckets.Count]; + + for (var i = 0; i < buckets.Count; i++) + { + var (_, mEnd, _) = buckets[i]; + double nw = 0; + foreach (var account in AppData.Accounts.Where(a => !a.IsArchived)) + { + var txUpTo = AppData.Transactions.Where(t => t.AccountId == account.Id && t.Date.Date <= mEnd.Date); + nw += (double)(account.OpeningBalance + + txUpTo.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount)); + } + netWorthVals[i] = nw; + } + + var labels = buckets.Select(b => b.label).ToArray(); + + NetWorthSeries = + [ + new LineSeries + { + Name = "Net Worth", + Values = netWorthVals, + Stroke = new SolidColorPaint(SKColor.Parse("#7B9CFF"), 2), + Fill = new SolidColorPaint(SKColor.Parse("#7B9CFF").WithAlpha(25)), + GeometryFill = new SolidColorPaint(SKColor.Parse("#7B9CFF")), + GeometryStroke = null, + GeometrySize = 5, + LineSmoothness = 0.5 + } + ]; + + NetWorthXAxes = + [ + new Axis + { + Labels = labels, + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), + TicksPaint = null, + TextSize = 11 + } + ]; + + var sym = PrimarySymbol; + NetWorthYAxes = + [ + new Axis + { + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), + TicksPaint = null, + TextSize = 10, + Labeler = v => $"{sym}{v:N0}" + } + ]; + } + + // ── Section 4: Day of Week ──────────────────────────── + + private void BuildDayOfWeekChart(List expenses, DateTime start, DateTime end) + { + // DayOfWeek: Sunday=0, Monday=1 ... Saturday=6 + // We display Mon–Sun (index 0–6 in our array) + var dayLabels = new[] { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" }; + var totals = new double[7]; + var counts = new int[7]; + + // Count occurrences of each weekday in the period + var d = start.Date; + while (d <= end.Date) + { + var idx = ((int)d.DayOfWeek + 6) % 7; // Mon=0..Sun=6 + counts[idx]++; + d = d.AddDays(1); + } + + foreach (var tx in expenses) + { + var idx = ((int)tx.Date.DayOfWeek + 6) % 7; + totals[idx] += (double)tx.ConvertedAmount; + } + + var averages = totals.Select((total, i) => counts[i] > 0 ? total / counts[i] : 0).ToArray(); + + DayOfWeekSeries = + [ + new ColumnSeries + { + Name = "Avg Daily Spend", + Values = averages, + Fill = new SolidColorPaint(SKColor.Parse("#9B7BFF")), + MaxBarWidth = 40, + Padding = 3 + } + ]; + + DayOfWeekXAxes = + [ + new Axis + { + Labels = dayLabels, + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + SeparatorsPaint = null, + TicksPaint = null, + TextSize = 11 + } + ]; + } + + // ── Section 5: Top Categories ───────────────────────── + + private void BuildTopCategories(List expenses) + { + var sym = PrimarySymbol; + var totalSpend = expenses.Sum(t => t.ConvertedAmount); + if (totalSpend == 0) + { + TopCategories = new ObservableCollection(); + HasTopCategories = false; + return; + } + + var grouped = expenses + .Where(t => t.Category is not null) + .GroupBy(t => t.Category!) + .Select(g => new CategorySpendRow + { + Name = g.Key.Name, + Icon = g.Key.Icon, + Color = g.Key.Color, + Amount = g.Sum(t => t.ConvertedAmount), + Percentage = (double)(g.Sum(t => t.ConvertedAmount) / totalSpend * 100), + AmountFormatted = $"{sym}{g.Sum(t => t.ConvertedAmount):N2}" + }) + .OrderByDescending(r => r.Amount) + .Take(8) + .ToList(); + + TopCategories = new ObservableCollection(grouped); + HasTopCategories = grouped.Count > 0; + } + + // ── Section 6: Income Sources ───────────────────────── + + private void BuildIncomeSourcesChart(List income) + { + var grouped = income + .Where(t => t.Category is not null) + .GroupBy(t => t.Category!) + .Select(g => (category: g.Key, total: g.Sum(t => t.ConvertedAmount))) + .OrderByDescending(x => x.total) + .ToList(); + + if (grouped.Count < 2) + { + IncomeSourcesSeries = []; + HasIncomeSources = false; + return; + } + + var sym = PrimarySymbol; + IncomeSourcesSeries = grouped.Select(x => (ISeries)new PieSeries + { + Name = x.category.Name, + Values = new[] { (double)x.total }, + Fill = new SolidColorPaint(SKColor.Parse(x.category.Color)), + InnerRadius = 20, + ToolTipLabelFormatter = p => $"{sym}{p.Coordinate.PrimaryValue:N2}" + }).ToArray(); + + HasIncomeSources = true; + } + + // ── PDF Export ──────────────────────────────────────── + + [RelayCommand] + private async Task ExportPdf() + { + if (IsExporting) return; + IsExporting = true; + ExportStatusMessage = null; + try + { + var (start, end) = GetDateRange(); + var path = await PdfExportService.ExportAsync( + AppData, + start, + end, + SelectedPeriod, + TopCategories.ToList()); + + ExportStatusMessage = path is not null ? "PDF saved successfully." : null; + } + catch (Exception e) + { + DebugLogger.Log(e); + ExportStatusMessage = "Export failed. Please try again."; + } + finally + { + IsExporting = false; + } + } +} diff --git a/Clario/ViewModels/AuthViewModel.cs b/Clario/ViewModels/AuthViewModel.cs index be89a07..579c2f5 100644 --- a/Clario/ViewModels/AuthViewModel.cs +++ b/Clario/ViewModels/AuthViewModel.cs @@ -6,11 +6,13 @@ using System.Text.Json; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +using Clario.Enums; using Clario.Models; using Clario.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; namespace Clario.ViewModels; @@ -36,6 +38,11 @@ public partial class AuthViewModel : ViewModelBase [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))] private string _operation = "login"; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] + private string? _errorMessage; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + public AuthViewModel() { DebugLogger.Log("auth vm loaded"); @@ -64,11 +71,13 @@ public partial class AuthViewModel : ViewModelBase private void SetOperation(string operation) { Operation = operation; + ErrorMessage = null; } [RelayCommand(CanExecute = nameof(canSignin))] private async Task ConfirmLogin() { + ErrorMessage = null; try { await SupabaseService.Client.Auth.SignIn(_email, _password); @@ -84,15 +93,22 @@ public partial class AuthViewModel : ViewModelBase singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); } } + catch (GotrueException e) + { + DebugLogger.Log(e); + ErrorMessage = GetLoginErrorMessage(e.Reason); + } catch (Exception e) { DebugLogger.Log(e); + ErrorMessage = GetErrorMessage(AuthError.Unknown); } } [RelayCommand(CanExecute = nameof(canCreateAccount))] private async Task ConfirmCreateAccount() { + ErrorMessage = null; try { var session = await SupabaseService.Client.Auth.SignUp( @@ -120,12 +136,52 @@ public partial class AuthViewModel : ViewModelBase singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); } } + catch (GotrueException e) + { + DebugLogger.Log(e); + ErrorMessage = GetSignupErrorMessage(e.Reason); + } catch (Exception e) { DebugLogger.Log(e); + ErrorMessage = GetErrorMessage(AuthError.Unknown); } } + private static string GetLoginErrorMessage(FailureHint.Reason reason) => reason switch + { + FailureHint.Reason.UserBadLogin => GetErrorMessage(AuthError.InvalidCredentials), + FailureHint.Reason.UserBadPassword => GetErrorMessage(AuthError.InvalidCredentials), + FailureHint.Reason.UserEmailNotConfirmed => GetErrorMessage(AuthError.EmailNotConfirmed), + FailureHint.Reason.UserTooManyRequests => GetErrorMessage(AuthError.RateLimited), + FailureHint.Reason.UserBadEmailAddress => GetErrorMessage(AuthError.InvalidEmail), + FailureHint.Reason.Offline => GetErrorMessage(AuthError.Unknown), + _ => GetErrorMessage(AuthError.Unknown), + }; + + private static string GetSignupErrorMessage(FailureHint.Reason reason) => reason switch + { + FailureHint.Reason.UserAlreadyRegistered => GetErrorMessage(AuthError.EmailAlreadyExists), + FailureHint.Reason.UserBadPassword => GetErrorMessage(AuthError.WeakPassword), + FailureHint.Reason.UserBadEmailAddress => GetErrorMessage(AuthError.InvalidEmail), + FailureHint.Reason.UserTooManyRequests => GetErrorMessage(AuthError.RateLimited), + FailureHint.Reason.Offline => GetErrorMessage(AuthError.Unknown), + _ => GetErrorMessage(AuthError.Unknown), + }; + + private static string GetErrorMessage(AuthError error) => error switch + { + AuthError.InvalidCredentials => "Invalid email or password.", + AuthError.EmailAlreadyExists => "An account with this email already exists.", + AuthError.EmailNotConfirmed => "Please confirm your email before signing in.", + AuthError.WeakPassword => "Password must be at least 6 characters.", + AuthError.InvalidEmail => "Please enter a valid email address.", + AuthError.SignupDisabled => "Sign-ups are currently disabled.", + AuthError.RateLimited => "Too many attempts. Please wait and try again.", + AuthError.SessionExpired => "Your session has expired. Please sign in again.", + _ => "Something went wrong. Please try again.", + }; + public bool isSignin => Operation == "login"; public bool isCreateAccount => Operation == "signup"; diff --git a/Clario/ViewModels/BudgetViewModel.cs b/Clario/ViewModels/BudgetViewModel.cs index e17726d..34ce8fd 100644 --- a/Clario/ViewModels/BudgetViewModel.cs +++ b/Clario/ViewModels/BudgetViewModel.cs @@ -28,8 +28,8 @@ public partial class BudgetViewModel : ViewModelBase [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextPeriodCommand), nameof(PreviousPeriodCommand))] private DateTime _currentPeriod = DateTime.Now.Date; - public bool CanGoToNextPeriod => CurrentPeriod.Month < DateTime.Now.Month; - public bool CanGoToPreviousPeriod => AppData.Transactions.Any() && CurrentPeriod.Month > AppData.Transactions.Min(x => x.Date.Month); + public bool CanGoToNextPeriod => CurrentPeriod.Year < DateTime.Now.Year || (CurrentPeriod.Year == DateTime.Now.Year && CurrentPeriod.Month < DateTime.Now.Month); + public bool CanGoToPreviousPeriod => AppData.Transactions.Any() && new DateTime(CurrentPeriod.Year, CurrentPeriod.Month, 1) > new DateTime(AppData.Transactions.Min(x => x.Date).Year, AppData.Transactions.Min(x => x.Date).Month, 1); public string CurrentPeriodFormatted => CurrentPeriod.ToString("MMMM yyyy"); [ObservableProperty] private ISeries[] _spendingBreakdownChartSeries = []; @@ -40,7 +40,7 @@ public partial class BudgetViewModel : ViewModelBase public string SpentPercentageFormatted => (TotalSpent / TotalBudgeted).ToString("P0") + " of total budget."; public decimal TotalLeft => Math.Clamp(Math.Round(TotalBudgeted - TotalSpent), 0, decimal.MaxValue); - private string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"); + public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"); public string TotalLeftFormatted => $"{PrimarySymbol}{TotalLeft:N0} left"; public bool HasSavingsGoal => AppData.Profile?.SavingsGoal is > 0; diff --git a/Clario/ViewModels/CategoryFormViewModel.cs b/Clario/ViewModels/CategoryFormViewModel.cs new file mode 100644 index 0000000..87e2d50 --- /dev/null +++ b/Clario/ViewModels/CategoryFormViewModel.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Clario.Data; +using Clario.Models; +using Clario.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Clario.ViewModels; + +public partial class CategoryFormViewModel : ViewModelBase +{ + public required ViewModelBase parentViewModel; + + // ── Mode ──────────────────────────────────────────────── + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel), nameof(CanDelete))] + private bool _isEditMode = false; + + public string FormTitle => IsEditMode ? "Edit Category" : "New Category"; + public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; + public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Category"; + + // ── Fields ────────────────────────────────────────────── + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] + private string _name = ""; + + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome))] + private string _type = "expense"; + + [ObservableProperty] private string _selectedIcon = "utensils"; + + [ObservableProperty] private string _selectedColor = "#7B9CFF"; + + // ── Icon options ───────────────────────────────────────── + public List CategoryIcons { get; } = new() + { + // Food & Dining + "utensils", "hamburger", "coffee", "pizza", "wine", + // Shopping + "shopping-cart", "shopping-bag", "package", "gift", "shirt", + // Transport + "car", "bus", "train-front", "bike", "plane", + // Home & Utilities + "house", "zap", "wifi", "plug-2", "wrench", + // Health & Fitness + "heart-pulse", "pill", "dumbbell", "scissors", "stethoscope", + // Entertainment + "gamepad-2", "film", "music", "tv", "headphones", + // Finance + "banknote", "credit-card", "piggy-bank", "wallet", "hand-coins", + "trending-up", "trending-down", "landmark", "circle-dollar-sign", "gem", + // Work & Education + "briefcase", "graduation-cap", "book-open", "target", "mail", + // Personal & Lifestyle + "heart", "moon", "sun", "leaf", "camera", + // Bills & Subscriptions + "receipt", "receipt-text", "smartphone", "volume-2", "refresh-cw", + }; + + // ── Validation ────────────────────────────────────────── + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] + private string? _errorMessage; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + public bool IsExpense => Type == "expense"; + public bool IsIncome => Type == "income"; + public bool IsValid => !string.IsNullOrWhiteSpace(Name); + public bool CanDelete => IsEditMode && DataRepo.General.Categories.Count > 4; + + // ── Delete confirm sub-modal ──────────────────────────── + [ObservableProperty] private bool _showDeleteConfirm = false; + + // ── Callbacks ─────────────────────────────────────────── + public Action? OnSaved; + public Action? OnCancelled; + public Action? OnDeleted; + + // ── Edit mode: original category ──────────────────────── + private Guid? _editingId; + + // ── Commands ──────────────────────────────────────────── + + [RelayCommand] + private void SetType(string type) => Type = type; + + [RelayCommand] + private void SetIcon(string icon) => SelectedIcon = icon; + + [RelayCommand] + private async Task Save() + { + ErrorMessage = null; + + if (string.IsNullOrWhiteSpace(Name)) + { + ErrorMessage = "Name is required."; + return; + } + + try + { + if (IsEditMode && _editingId.HasValue) + { + var updated = new Category + { + Id = _editingId.Value, + UserId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id), + Name = Name.Trim(), + Type = Type, + Icon = SelectedIcon, + Color = SelectedColor, + }; + await DataRepo.General.UpdateCategory(updated); + } + else + { + var category = new Category + { + Id = Guid.NewGuid(), + UserId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id!), + Name = Name.Trim(), + Type = Type, + Icon = SelectedIcon, + Color = SelectedColor, + }; + await DataRepo.General.InsertCategory(category); + } + + OnSaved?.Invoke(); + } + catch (Exception ex) + { + ErrorMessage = "Something went wrong. Please try again."; + DebugLogger.Log(ex); + } + } + + [RelayCommand] + private void Cancel() => OnCancelled?.Invoke(); + + [RelayCommand] + private void RequestDelete() => ShowDeleteConfirm = true; + + [RelayCommand] + private void CancelDelete() => ShowDeleteConfirm = false; + + [RelayCommand] + private async Task ConfirmDelete() + { + if (!IsEditMode || !_editingId.HasValue) return; + + try + { + await DataRepo.General.DeleteCategory(_editingId.Value); + OnDeleted?.Invoke(); + } + catch (Exception ex) + { + ErrorMessage = "Failed to delete category."; + DebugLogger.Log(ex); + } + } + + // ── Public setup methods ───────────────────────────────── + + public void SetupForAdd() + { + ShowDeleteConfirm = false; + IsEditMode = false; + _editingId = null; + Name = ""; + Type = "expense"; + SelectedIcon = "utensils"; + SelectedColor = "#7B9CFF"; + ErrorMessage = null; + } + + public void SetupForEdit(Category category) + { + ShowDeleteConfirm = false; + IsEditMode = true; + _editingId = category.Id; + Name = category.Name; + Type = category.Type; + SelectedIcon = category.Icon; + SelectedColor = category.Color; + ErrorMessage = null; + OnPropertyChanged(nameof(CanDelete)); + } +} diff --git a/Clario/ViewModels/DashboardViewModel.cs b/Clario/ViewModels/DashboardViewModel.cs index 57c83e7..1cdc6c7 100644 --- a/Clario/ViewModels/DashboardViewModel.cs +++ b/Clario/ViewModels/DashboardViewModel.cs @@ -178,6 +178,12 @@ public partial class DashboardViewModel : ViewModelBase ((MainViewModel)parentViewModel).OpenAddTransaction(); } + [RelayCommand] + private void NavigateToSettings() + { + ((MainViewModel)parentViewModel).GoToSettingsCommand.Execute(null); + } + private void UpdateSpendingByCategoryChart(ChartTimePeriod period = ChartTimePeriod.ThisMonth) { var tempList = new List(); @@ -241,7 +247,7 @@ public partial class DashboardViewModel : ViewModelBase private void UpdateRecentTransactions() { - RecentTransactions = new ObservableCollection(AppData.Transactions.OrderByDescending(x => x.Date).Take(5)); + RecentTransactions = new ObservableCollection(AppData.Transactions.Where(x => !x.IsTransfer).OrderByDescending(x => x.Date).Take(5)); OnPropertyChanged(nameof(HasTransactionData)); } @@ -249,17 +255,17 @@ public partial class DashboardViewModel : ViewModelBase { TotalNetworth = 0; var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"; - foreach (var account in AppData.Accounts) + foreach (var account in AppData.Accounts.Where(a => !a.IsArchived)) { var accountTransactions = AppData.Transactions.Where(t => t.AccountId == account.Id).ToList(); - account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount); + account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount); if (account.Currency.Equals(primaryCurrency, StringComparison.OrdinalIgnoreCase)) TotalNetworth += account.CurrentBalance; else - TotalNetworth += accountTransactions.Sum(t => t.Type == "income" ? t.ConvertedAmount : -t.ConvertedAmount); + TotalNetworth += accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount); } - AccountsSummaryData = new ObservableCollection(AppData.Accounts.OrderBy(x => x.CreatedAt)); + AccountsSummaryData = new ObservableCollection(AppData.Accounts.Where(a => !a.IsArchived).OrderBy(x => x.CreatedAt)); OnPropertyChanged(nameof(AccountsSubtitle)); } diff --git a/Clario/ViewModels/MainViewModel.cs b/Clario/ViewModels/MainViewModel.cs index d7b20e0..2e9bdb8 100644 --- a/Clario/ViewModels/MainViewModel.cs +++ b/Clario/ViewModels/MainViewModel.cs @@ -21,6 +21,7 @@ public partial class MainViewModel : ViewModelBase public TransactionsViewModel _transactionsViewModel = null!; private AccountsViewModel _accountsViewModel = null!; private BudgetViewModel _budgetViewModel = null!; + private AnalyticsViewModel _analyticsViewModel = null!; GeneralDataRepo AppData => DataRepo.General; [ObservableProperty] private Profile? _profile; @@ -28,6 +29,7 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] private TransactionFormViewModel _transactionFormViewModel = null!; [ObservableProperty] private AccountFormViewModel _accountFormViewModel = null!; [ObservableProperty] private BudgetFormViewModel _budgetFormViewModel = null!; + [ObservableProperty] private CategoryFormViewModel _categoryFormViewModel = null!; [ObservableProperty] private SettingsViewModel _settingsViewModel = null!; [ObservableProperty] private SetSavingsGoalDialogViewModel _setSavingsGoalDialogViewModel = null!; @@ -35,11 +37,12 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] private bool _isTransactionFormVisible; [ObservableProperty] private bool _isAccountFormVisible; [ObservableProperty] private bool _isBudgetFormVisible; + [ObservableProperty] private bool _isCategoryFormVisible; [ObservableProperty] private bool _isSavingsGoalDialogVisible; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnSettings))] + [NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnAnalytics), nameof(isOnSettings))] private ViewModelBase? _currentView; [ObservableProperty] private bool _isDarkTheme; @@ -103,6 +106,11 @@ public partial class MainViewModel : ViewModelBase parentViewModel = this }; DebugLogger.Log("initialized BudgetViewModel"); + _analyticsViewModel = new AnalyticsViewModel() + { + parentViewModel = this + }; + DebugLogger.Log("initialized AnalyticsViewModel"); SettingsViewModel = new SettingsViewModel() { parentViewModel = this @@ -112,6 +120,8 @@ public partial class MainViewModel : ViewModelBase { parentViewModel = this }; + TransactionFormViewModel.OnOpenCategoryForm = OpenAddCategoryFromTransactionForm; + TransactionFormViewModel.OnOpenEditCategoryForm = OpenEditCategoryFromTransactionForm; DebugLogger.Log("initialized TransactionFormViewModel"); AccountFormViewModel = new AccountFormViewModel() { @@ -123,6 +133,11 @@ public partial class MainViewModel : ViewModelBase parentViewModel = this }; DebugLogger.Log("initialized BudgetFormViewModel"); + CategoryFormViewModel = new CategoryFormViewModel() + { + parentViewModel = this + }; + DebugLogger.Log("initialized CategoryFormViewModel"); SetSavingsGoalDialogViewModel = new SetSavingsGoalDialogViewModel(); DebugLogger.Log("initialized SetSavingsGoalDialogViewModel"); @@ -268,6 +283,78 @@ public partial class MainViewModel : ViewModelBase IsBudgetFormVisible = false; } + private void OpenEditCategoryFromTransactionForm(Category category) + { + CategoryFormViewModel.SetupForEdit(category); + CategoryFormViewModel.OnSaved = () => + { + TransactionFormViewModel.Categories = AppData.Categories; + // Keep the selected category in sync after edit + var updated = AppData.Categories.FirstOrDefault(c => c.Id == category.Id); + if (updated is not null) TransactionFormViewModel.SelectedCategory = updated; + CloseCategoryForm(); + }; + CategoryFormViewModel.OnCancelled = CloseCategoryForm; + CategoryFormViewModel.OnDeleted = () => + { + TransactionFormViewModel.Categories = AppData.Categories; + TransactionFormViewModel.SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Type == TransactionFormViewModel.Type); + CloseCategoryForm(); + }; + IsCategoryFormVisible = true; + } + + // Called by the plus button inside TransactionFormView + private void OpenAddCategoryFromTransactionForm() + { + CategoryFormViewModel.SetupForAdd(); + CategoryFormViewModel.OnSaved = () => + { + // Refresh the category list in the transaction form after adding + TransactionFormViewModel.Categories = AppData.Categories; + CloseCategoryForm(); + }; + CategoryFormViewModel.OnCancelled = CloseCategoryForm; + CategoryFormViewModel.OnDeleted = () => + { + TransactionFormViewModel.Categories = AppData.Categories; + CloseCategoryForm(); + }; + IsCategoryFormVisible = true; + } + + [RelayCommand] + public void OpenAddCategory() + { + if (IsDimmed) return; + CategoryFormViewModel.SetupForAdd(); + CategoryFormViewModel.OnSaved = CloseCategoryForm; + CategoryFormViewModel.OnCancelled = CloseCategoryForm; + CategoryFormViewModel.OnDeleted = CloseCategoryForm; + IsCategoryFormVisible = true; + IsDimmed = true; + } + + [RelayCommand] + public void OpenEditCategory(Category category) + { + if (IsDimmed) return; + CategoryFormViewModel.SetupForEdit(category); + CategoryFormViewModel.OnSaved = CloseCategoryForm; + CategoryFormViewModel.OnCancelled = CloseCategoryForm; + CategoryFormViewModel.OnDeleted = CloseCategoryForm; + IsCategoryFormVisible = true; + IsDimmed = true; + } + + private void CloseCategoryForm() + { + IsCategoryFormVisible = false; + // Only clear the dim if no other modal is open + if (!IsTransactionFormVisible) + IsDimmed = false; + } + [RelayCommand] public void OpenEditSavingsGoal() { @@ -316,6 +403,12 @@ public partial class MainViewModel : ViewModelBase CurrentView = _budgetViewModel; } + [RelayCommand] + private void GoToAnalytics() + { + CurrentView = _analyticsViewModel; + } + [RelayCommand] private void GoToSettings() { @@ -343,5 +436,6 @@ public partial class MainViewModel : ViewModelBase public bool isOnTransactions => CurrentView is TransactionsViewModel; public bool isOnAccounts => CurrentView is AccountsViewModel; public bool isOnBudget => CurrentView is BudgetViewModel; + public bool isOnAnalytics => CurrentView is AnalyticsViewModel; public bool isOnSettings => CurrentView is SettingsViewModel; } \ No newline at end of file diff --git a/Clario/ViewModels/TransactionFormViewModel.cs b/Clario/ViewModels/TransactionFormViewModel.cs index 6b8f509..45678c4 100644 --- a/Clario/ViewModels/TransactionFormViewModel.cs +++ b/Clario/ViewModels/TransactionFormViewModel.cs @@ -21,12 +21,13 @@ public partial class TransactionFormViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))] private bool _isEditMode = false; - public string FormTitle => IsEditMode ? "Edit Transaction" : "New Transaction"; + public string FormTitle => IsEditMode ? (IsTransfer ? "Edit Transfer" : "Edit Transaction") : (IsTransfer ? "New Transfer" : "New Transaction"); public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; - public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Transaction"; + public string SaveButtonLabel => IsEditMode ? "Save Changes" : (IsTransfer ? "Save Transfer" : "Save Transaction"); // ── Fields ────────────────────────────────────────────── - [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome), nameof(IsValid))] + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome), nameof(IsTransfer), nameof(IsValid), nameof(FormTitle), nameof(SaveButtonLabel))] private string _type = "expense"; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] @@ -38,6 +39,7 @@ public partial class TransactionFormViewModel : ViewModelBase [ObservableProperty] private string? _note; [ObservableProperty] private List _dates = [DateTime.Now]; [ObservableProperty] private DateTime? _selectedDate; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrencySymbol))] private string _currency = "USD"; @@ -56,6 +58,9 @@ public partial class TransactionFormViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] private Account? _selectedAccount; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] + private Account? _selectedToAccount; + [ObservableProperty] private ObservableCollection _categories = new(); [ObservableProperty] private ObservableCollection _accounts = new(); @@ -66,24 +71,28 @@ public partial class TransactionFormViewModel : ViewModelBase public bool HasError => !string.IsNullOrEmpty(ErrorMessage); public bool IsExpense => Type == "expense"; public bool IsIncome => Type == "income"; + public bool IsTransfer => Type == "transfer"; public bool IsValid => decimal.TryParse(Amount, out var amt) && amt > 0 && - !string.IsNullOrWhiteSpace(Description) && - SelectedCategory is not null && - SelectedAccount is not null && - Dates is not null; + Dates is not null && + (IsTransfer + ? SelectedAccount is not null && SelectedToAccount is not null && SelectedAccount.Id != SelectedToAccount.Id + : !string.IsNullOrWhiteSpace(Description) && SelectedCategory is not null && SelectedAccount is not null); // ── Callbacks ─────────────────────────────────────────── public Action? OnSaved; public Action? OnCancelled; public Action? OnDeleted; + public Action? OnOpenCategoryForm; + public Action? OnOpenEditCategoryForm; [ObservableProperty] private bool _showDeleteConfirm = false; // ── Edit mode: original transaction ───────────────────── private Transaction? _editingTransaction; private Guid? _editingId; + private Guid? _transferPairId; private decimal _editingOriginalAmount; private Guid? _editingOriginalCategoryId; @@ -101,9 +110,10 @@ public partial class TransactionFormViewModel : ViewModelBase public bool HasBudgetApproachingWarning => HasBudgetWarning && !BudgetWarningIsOverBudget; // ── Commands ──────────────────────────────────────────── - + partial void OnSelectedCategoryChanged(Category? value) { + if (value is null) return; if (value.Type != Type) Type = value.Type; CheckBudgetImpact(); } @@ -120,6 +130,12 @@ public partial class TransactionFormViewModel : ViewModelBase partial void OnTypeChanged(string value) { + if (value == "transfer") + { + CheckBudgetImpact(); + return; + } + if (value == SelectedCategory?.Type) return; SelectedCategory = _categories.FirstOrDefault(c => c.Type == value); CheckBudgetImpact(); @@ -127,6 +143,12 @@ public partial class TransactionFormViewModel : ViewModelBase partial void OnSelectedAccountChanged(Account? value) { + if (IsTransfer) + { + Currency = value?.Currency ?? "USD"; + return; + } + var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"; var accountCurrency = value?.Currency ?? primaryCurrency; Currency = accountCurrency; @@ -136,6 +158,7 @@ public partial class TransactionFormViewModel : ViewModelBase IsFetchingRate = true; ExchangeRate = ""; } + ShowExchangeRateField = needsRate; OnPropertyChanged(nameof(ExchangeRateLabel)); if (needsRate) @@ -164,6 +187,7 @@ public partial class TransactionFormViewModel : ViewModelBase BudgetWarningMessage = null; BudgetWarningIsOverBudget = false; + if (IsTransfer) return; if (Type != "expense") return; if (SelectedCategory is null) return; Debug.WriteLine(SelectedCategory.Name); @@ -214,6 +238,16 @@ public partial class TransactionFormViewModel : ViewModelBase } } + [RelayCommand] + private void OpenCategoryForm() => OnOpenCategoryForm?.Invoke(); + + [RelayCommand] + private void OpenEditCategoryForm() + { + if (SelectedCategory is not null) + OnOpenEditCategoryForm?.Invoke(SelectedCategory); + } + [RelayCommand] private void SetType(string type) { @@ -237,6 +271,37 @@ public partial class TransactionFormViewModel : ViewModelBase return; } + if (IsTransfer) + { + if (SelectedAccount is null || SelectedToAccount is null) + { + ErrorMessage = "Please select both accounts."; + return; + } + + if (SelectedAccount.Id == SelectedToAccount.Id) + { + ErrorMessage = "From and To accounts must be different."; + return; + } + + try + { + if (IsEditMode && _transferPairId.HasValue) + await DataRepo.General.UpdateTransfer(_transferPairId.Value, SelectedAccount.Id, SelectedToAccount.Id, amt, Dates.FirstOrDefault(), Note); + else + await DataRepo.General.InsertTransfer(SelectedAccount.Id, SelectedToAccount.Id, amt, Dates.FirstOrDefault(), Note); + OnSaved?.Invoke(); + } + catch (Exception ex) + { + ErrorMessage = "Something went wrong. Please try again."; + DebugLogger.Log(ex); + } + + return; + } + if (string.IsNullOrWhiteSpace(Description)) { ErrorMessage = "Description is required."; @@ -314,11 +379,14 @@ public partial class TransactionFormViewModel : ViewModelBase [RelayCommand] private async Task ConfirmDelete() { - if (!IsEditMode || !_editingId.HasValue) return; + if (!IsEditMode) return; try { - await DataRepo.General.DeleteTransaction(_editingId.Value); + if (IsTransfer && _transferPairId.HasValue) + await DataRepo.General.DeleteTransfer(_transferPairId.Value); + else if (_editingId.HasValue) + await DataRepo.General.DeleteTransaction(_editingId.Value); OnDeleted?.Invoke(); } catch (Exception ex) @@ -354,11 +422,12 @@ public partial class TransactionFormViewModel : ViewModelBase ShowDeleteConfirm = false; IsEditMode = false; _editingId = null; + _transferPairId = null; _editingOriginalAmount = 0; _editingOriginalCategoryId = null; Categories = AppData.Categories; var sortedAccounts = new ObservableCollection( - AppData.Accounts.OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt)); + AppData.Accounts.Where(a => !a.IsArchived).OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt)); Accounts = sortedAccounts; Type = "expense"; Amount = ""; @@ -368,6 +437,7 @@ public partial class TransactionFormViewModel : ViewModelBase ErrorMessage = null; SelectedCategory = AppData.Categories.Count > 0 ? AppData.Categories[0] : null; SelectedAccount = sortedAccounts.Count > 0 ? sortedAccounts[0] : null; + SelectedToAccount = sortedAccounts.Count > 1 ? sortedAccounts[1] : null; ShowExchangeRateField = false; ExchangeRate = ""; IsFetchingRate = false; @@ -377,8 +447,7 @@ public partial class TransactionFormViewModel : ViewModelBase } /// Call this to open the form for editing an existing transaction. - public void SetupForEdit( - Transaction transaction) + public void SetupForEdit(Transaction transaction) { ShowDeleteConfirm = false; IsEditMode = true; @@ -386,30 +455,55 @@ public partial class TransactionFormViewModel : ViewModelBase _editingOriginalAmount = transaction.Amount; _editingOriginalCategoryId = transaction.CategoryId; Categories = AppData.Categories; - Accounts = new ObservableCollection( - AppData.Accounts.OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt)); - Type = transaction.Type; + var sortedAccounts = new ObservableCollection( + AppData.Accounts.Where(a => !a.IsArchived).OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt)); + Accounts = sortedAccounts; Amount = transaction.Amount.ToString("0.00"); - Description = transaction.Description; Note = transaction.Note; Dates = [transaction.Date]; ErrorMessage = null; - SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId) - ?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null); - SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId) - ?? (AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null); - if (transaction.ExchangeRate.HasValue) + ResultTransaction = transaction; + + if (transaction.IsTransfer && transaction.TransferPairId.HasValue) { - ShowExchangeRateField = true; - ExchangeRate = transaction.ExchangeRate.Value.ToString("0.##########"); + _transferPairId = transaction.TransferPairId; + Type = "transfer"; + // Find the counterpart to determine from/to + var counterpart = AppData.Transactions.FirstOrDefault(t => t.TransferPairId == transaction.TransferPairId && t.Id != transaction.Id); + var outTx = transaction.IsTransferOut ? transaction : counterpart; + var inTx = transaction.IsTransferOut ? counterpart : transaction; + SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == outTx?.AccountId) ?? sortedAccounts.FirstOrDefault(); + SelectedToAccount = AppData.Accounts.FirstOrDefault(a => a.Id == inTx?.AccountId) ?? sortedAccounts.Skip(1).FirstOrDefault(); + Description = "Transfer"; + SelectedCategory = null; + ShowExchangeRateField = false; + ExchangeRate = ""; + IsFetchingRate = false; } else { - ShowExchangeRateField = false; - ExchangeRate = ""; + _transferPairId = null; + Type = transaction.Type; + Description = transaction.Description; + SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId) + ?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null); + SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId) + ?? (sortedAccounts.Count > 0 ? sortedAccounts[0] : null); + SelectedToAccount = sortedAccounts.Count > 1 ? sortedAccounts[1] : null; + if (transaction.ExchangeRate.HasValue) + { + ShowExchangeRateField = true; + ExchangeRate = transaction.ExchangeRate.Value.ToString("0.##########"); + } + else + { + ShowExchangeRateField = false; + ExchangeRate = ""; + } + + IsFetchingRate = false; } - IsFetchingRate = false; - ResultTransaction = transaction; + CheckBudgetImpact(); } } \ No newline at end of file diff --git a/Clario/ViewModels/TransactionsViewModel.cs b/Clario/ViewModels/TransactionsViewModel.cs index 1b4b80e..0dc8656 100644 --- a/Clario/ViewModels/TransactionsViewModel.cs +++ b/Clario/ViewModels/TransactionsViewModel.cs @@ -23,7 +23,10 @@ public partial class TransactionsViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _categories = new(); [ObservableProperty] private ObservableCollection _accounts = new(); - [ObservableProperty] private List _filteredTransactions = new(); + [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))] + private List _filteredTransactions = new(); + + public int FilteredTransactionCount => _filteredTransactions.Count; private int _pageSize = 25; [ObservableProperty] private int _pageSizeIndex; @@ -84,7 +87,7 @@ public partial class TransactionsViewModel : ViewModelBase new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month)) }; - [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense))] + [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense), nameof(FilterTypeTransfer))] private string _transactionType = "all"; @@ -157,8 +160,9 @@ public partial class TransactionsViewModel : ViewModelBase private void ApplyFilters() { var filtered = AppData.Transactions.Where(x => - x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase) - || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + x.Type != "transfer_in" && + (x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase))); var culture = new CultureInfo("en-US"); @@ -224,7 +228,7 @@ public partial class TransactionsViewModel : ViewModelBase } - // Calculate totals based on date-filtered transactions (converted to primary currency) + // Calculate totals based on date-filtered transactions (transfers excluded) TotalExpenses = filtered.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.ConvertedAmount)); TotalIncome = filtered.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.ConvertedAmount)); @@ -234,8 +238,12 @@ public partial class TransactionsViewModel : ViewModelBase if (SelectedAccount.Name != "All Accounts") filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id); - if (TransactionType != "all") - filtered = filtered.Where(x => x.Type == TransactionType); + if (TransactionType == "income") + filtered = filtered.Where(x => x.Type == "income"); + else if (TransactionType == "expense") + filtered = filtered.Where(x => x.Type == "expense"); + else if (TransactionType == "transfer") + filtered = filtered.Where(x => x.IsTransfer); switch (SelectedSortOption) { @@ -281,6 +289,7 @@ public partial class TransactionsViewModel : ViewModelBase public bool FilterTypeAll => TransactionType == "all"; public bool FilterTypeIncome => TransactionType == "income"; public bool FilterTypeExpense => TransactionType == "expense"; + public bool FilterTypeTransfer => TransactionType == "transfer"; [RelayCommand(CanExecute = nameof(HasNextPage))] private void NextPage() @@ -344,6 +353,7 @@ public partial class TransactionsViewModel : ViewModelBase private void InitializeCategories() { + Categories.Clear(); Categories.Insert(0, new Category() { Name = "All Categories" }); foreach (var appDataCategory in AppData.Categories) { @@ -355,6 +365,7 @@ public partial class TransactionsViewModel : ViewModelBase private void InitializeAccounts() { + Accounts.Clear(); Accounts.Insert(0, new Account() { Name = "All Accounts" }); foreach (var appDataAccount in AppData.Accounts) { diff --git a/Clario/Views/AccountsView.axaml b/Clario/Views/AccountsView.axaml index 2ccec08..3bc84e9 100644 --- a/Clario/Views/AccountsView.axaml +++ b/Clario/Views/AccountsView.axaml @@ -14,12 +14,20 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/AnalyticsView.axaml.cs b/Clario/Views/AnalyticsView.axaml.cs new file mode 100644 index 0000000..55cc911 --- /dev/null +++ b/Clario/Views/AnalyticsView.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Clario.Views; + +public partial class AnalyticsView : UserControl +{ + public AnalyticsView() + { + InitializeComponent(); + this.AddHandler(PointerWheelChangedEvent, WindowScrollHandler, RoutingStrategies.Tunnel); + } + + private void WindowScrollHandler(object? sender, PointerWheelEventArgs e) + { + var offset = mainScrollviewer.Offset; + mainScrollviewer.Offset = new Vector( + offset.X, + offset.Y - e.Delta.Y * mainScrollviewer.SmallChange.Height * 3 + ); + + e.Handled = true; + } +} \ No newline at end of file diff --git a/Clario/Views/ArchiveAccountDialogView.axaml b/Clario/Views/ArchiveAccountDialogView.axaml new file mode 100644 index 0000000..2bbe3c3 --- /dev/null +++ b/Clario/Views/ArchiveAccountDialogView.axaml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/ArchiveAccountDialogView.axaml.cs b/Clario/Views/ArchiveAccountDialogView.axaml.cs new file mode 100644 index 0000000..010a192 --- /dev/null +++ b/Clario/Views/ArchiveAccountDialogView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.Views; + +public partial class ArchiveAccountDialogView : UserControl +{ + public ArchiveAccountDialogView() + { + InitializeComponent(); + } +} diff --git a/Clario/Views/ArchivedAccountsDialogView.axaml b/Clario/Views/ArchivedAccountsDialogView.axaml new file mode 100644 index 0000000..7f01e8a --- /dev/null +++ b/Clario/Views/ArchivedAccountsDialogView.axaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/ArchivedAccountsDialogView.axaml.cs b/Clario/Views/ArchivedAccountsDialogView.axaml.cs new file mode 100644 index 0000000..59a1f57 --- /dev/null +++ b/Clario/Views/ArchivedAccountsDialogView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.Views; + +public partial class ArchivedAccountsDialogView : UserControl +{ + public ArchivedAccountsDialogView() + { + InitializeComponent(); + } +} diff --git a/Clario/Views/AuthView.axaml b/Clario/Views/AuthView.axaml index 7d51100..f484a4e 100644 --- a/Clario/Views/AuthView.axaml +++ b/Clario/Views/AuthView.axaml @@ -191,19 +191,18 @@ - + IsVisible="{Binding HasError}"> - @@ -392,19 +391,18 @@ - + IsVisible="{Binding HasError}"> - diff --git a/Clario/Views/BudgetView.axaml b/Clario/Views/BudgetView.axaml index cf7bd22..038eb56 100644 --- a/Clario/Views/BudgetView.axaml +++ b/Clario/Views/BudgetView.axaml @@ -267,7 +267,7 @@ - + @@ -279,7 +279,7 @@ - + diff --git a/Clario/Views/CategoryFormView.axaml b/Clario/Views/CategoryFormView.axaml new file mode 100644 index 0000000..681849a --- /dev/null +++ b/Clario/Views/CategoryFormView.axaml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/CategoryFormView.axaml.cs b/Clario/Views/CategoryFormView.axaml.cs new file mode 100644 index 0000000..e996091 --- /dev/null +++ b/Clario/Views/CategoryFormView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.Views; + +public partial class CategoryFormView : UserControl +{ + public CategoryFormView() + { + InitializeComponent(); + } +} diff --git a/Clario/Views/DashboardView.axaml b/Clario/Views/DashboardView.axaml index e6e7de9..e5363ff 100644 --- a/Clario/Views/DashboardView.axaml +++ b/Clario/Views/DashboardView.axaml @@ -25,14 +25,6 @@ Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" /> - - - - - - - + + @@ -155,25 +174,28 @@ - - + + + Margin="0,0,0,16" + IsVisible="{Binding !IsTransfer}" /> - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Padding="4,0,4,0"> - + HorizontalScrollBarVisibility="Disabled" + Padding="16,0,16,0"> + + + @@ -324,8 +329,7 @@ ColumnDefinitions="*,Auto" Margin="28,28,28,0"> - - - - - - - - + + + + + + - - - - + + + + + + + + - - - - + + + + - + Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}" /> + Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" /> - + + + + + + + + - - + HorizontalAlignment="Center" /> +