Files
Clario/Clario/ViewModels/TransactionsViewModel.cs
Nouredeen06 61ff949c19
Some checks failed
Build Linux / build (push) Failing after 24s
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
2026-04-05 23:08:34 +03:00

412 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using Clario.Data;
using Clario.Messages;
using Clario.Models;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
// ReSharper disable PossibleMultipleEnumeration
namespace Clario.ViewModels;
public partial class TransactionsViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private ObservableCollection<Category> _categories = new();
[ObservableProperty] private ObservableCollection<Account> _accounts = new();
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))]
private List<Transaction> _filteredTransactions = new();
public int FilteredTransactionCount => _filteredTransactions.Count;
private int _pageSize = 25;
[ObservableProperty] private int _pageSizeIndex;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(TotalPages))] [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))]
private int _currentPage = 1;
[ObservableProperty] private string _paginationSummaryText;
[ObservableProperty] private ObservableCollection<Transaction> _pagedTransactions = new();
[ObservableProperty] private ObservableCollection<string> _sortOptions = new()
{
"Date — Newest first",
"Date — Oldest first",
"Amount — High to low",
"Amount — Low to high",
"Category A → Z"
};
[ObservableProperty] private ObservableCollection<string> _DateRangeOptions = new()
{
"All Time",
"Today",
"This Week",
"This Month",
"Last Month",
"This Quarter",
"This Year",
"Custom Range"
};
public List<int> PageNumbers { get; set; }
[ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new();
public int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
public bool HasNoTransactions => FilteredTransactions.Count == 0;
public bool HasNextPage => CurrentPage < TotalPages;
public bool HasPreviousPage => CurrentPage > 1;
[ObservableProperty] private double _totalExpenses;
[ObservableProperty] private double _totalIncome;
[ObservableProperty] private int _expensesCount;
[ObservableProperty] private int _incomeCount;
[ObservableProperty] private string _dateRangeLabel = "";
public string PrimaryCurrencySymbol =>
CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private Category _selectedCategory;
[ObservableProperty] private Account _selectedAccount;
[ObservableProperty] private string _selectedSortOption;
[ObservableProperty] private string _selectedDateRangeOption;
[ObservableProperty] private List<DateTime>? _selectedDates = new()
{
new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1),
new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month))
};
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense), nameof(FilterTypeTransfer))]
private string _transactionType = "all";
public TransactionsViewModel()
{
AppData.Transactions.CollectionChanged += (_, _) =>
{
InitializeCategories();
InitializeAccounts();
LoadPage(1);
};
WeakReferenceMessenger.Default.Register<RatesRefreshed>(this, (_, _) => LoadPage(CurrentPage));
Initialize();
}
partial void OnPageSizeIndexChanged(int value)
{
_pageSize = value switch
{
0 => 25,
1 => 50,
2 => 100,
_ => 25
};
LoadPage(1);
OnPropertyChanged(nameof(HasNextPage));
OnPropertyChanged(nameof(HasPreviousPage));
}
partial void OnCurrentPageChanged(int value)
{
LoadPage(value);
OnPropertyChanged(nameof(HasNextPage));
OnPropertyChanged(nameof(HasPreviousPage));
}
[RelayCommand]
private void LoadPageStr(string page)
{
LoadPage(int.Parse(page));
}
[RelayCommand]
private void LoadPage(int page)
{
ApplyFilters();
if (CurrentPage != page) CurrentPage = page;
var items = FilteredTransactions.Skip((page - 1) * _pageSize)
.Take(_pageSize);
OnPropertyChanged(nameof(HasNoTransactions));
PagedTransactions.Clear();
foreach (var item in items)
PagedTransactions.Add(item);
PaginationSummaryText =
$"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, FilteredTransactions.Count))} of {FilteredTransactions.Count} transactions";
PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList();
var numbers = GetSurrounding(PageNumbers, page);
VisiblePageNumbers.Clear();
foreach (var number in numbers)
VisiblePageNumbers.Add(number);
WeakReferenceMessenger.Default.Send(new TransactionsScrollToTop());
GroupTransactions();
}
[RelayCommand]
private void ApplyFilters()
{
var filtered = AppData.Transactions.Where(x =>
x.Type != "transfer_in" &&
(x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
var culture = new CultureInfo("en-US");
switch (SelectedDateRangeOption)
{
case "All Time":
DateRangeLabel = "ALL TIME";
break;
case "Today":
filtered = filtered.Where(x => x.Date == DateTime.Now.Date);
DateRangeLabel = DateTime.Now.ToString("MMM d, yyyy", culture).ToUpper();
break;
case "This Week":
var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek);
var endOfWeek = startOfWeek.AddDays(6);
filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek);
DateRangeLabel = "THIS WEEK";
break;
case "This Month":
filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month);
DateRangeLabel = DateTime.Now.ToString("MMMM yyyy", culture).ToUpper();
break;
case "Last Month":
var lastMonth = DateTime.Now.AddMonths(-1);
filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year);
DateRangeLabel = lastMonth.ToString("MMMM yyyy", culture).ToUpper();
break;
case "This Quarter":
var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3);
var endOfQuarter = startOfQuarter.AddMonths(3);
filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter);
DateRangeLabel = $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}";
break;
case "This Year":
filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year);
DateRangeLabel = DateTime.Now.Year.ToString();
break;
case "Custom Range":
if (SelectedDates is not null && SelectedDates.Count > 0)
{
var ordered = SelectedDates
.Select(d => d.Date)
.Distinct()
.OrderBy(d => d)
.ToList();
var start = ordered.First();
var end = ordered.Last();
if (SelectedDates.Count == 1)
{
filtered = filtered.Where(x => x.Date.Date == start);
DateRangeLabel = start.ToString("MMM dd, yyyy", culture).ToUpper();
}
else
{
filtered = filtered.Where(x => x.Date.Date >= start && x.Date.Date <= end);
DateRangeLabel = $"{start.ToString("MMM dd", culture)} - {end.ToString("MMM dd, yyyy", culture)}".ToUpper();
}
}
break;
}
// 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));
if (SelectedCategory.Name != "All Categories")
filtered = filtered.Where(x => x.CategoryId == SelectedCategory.Id);
if (SelectedAccount.Name != "All Accounts")
filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id);
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)
{
case "Date — Newest first":
filtered = filtered.OrderByDescending(x => x.Date);
break;
case "Date — Oldest first":
filtered = filtered.OrderBy(x => x.Date);
break;
case "Amount — High to low":
filtered = filtered.OrderByDescending(x => x.Amount);
break;
case "Amount — Low to high":
filtered = filtered.OrderBy(x => x.Amount);
break;
case "Category A → Z":
filtered = filtered.OrderBy(x => x.Category?.Name);
break;
}
FilteredTransactions = filtered.ToList();
}
[RelayCommand]
private void ResetFilters()
{
SearchText = "";
SelectedCategory = Categories.First();
SelectedAccount = Accounts.First();
TransactionType = "all";
SelectedSortOption = SortOptions.First();
SelectedDateRangeOption = DateRangeOptions.First();
LoadPage(1);
}
[RelayCommand]
private void SetTransactionType(string type)
{
TransactionType = type;
}
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()
{
if (CurrentPage < TotalPages) CurrentPage++;
}
[RelayCommand(CanExecute = nameof(HasPreviousPage))]
private void PreviousPage()
{
if (CurrentPage > 1) CurrentPage--;
}
private void GroupTransactions()
{
var ToRemove = PagedTransactions.Where(x => x.GroupHeader).ToList();
foreach (var item in ToRemove)
{
PagedTransactions.Remove(item);
}
var dates = PagedTransactions
.Where(x => !x.GroupHeader)
.Select(x => x.Date.Date) // strip time
.Distinct()
.ToList();
foreach (var date in dates)
{
var index = PagedTransactions.IndexOf(PagedTransactions.First(x => x.Date.Date == date && !x.GroupHeader));
string label;
var culture = new CultureInfo("en-US");
if (date.Date == DateTime.Now.Date) label = "Today - " + date.ToString("MMM dd", culture);
else if (date.Date == DateTime.Now.AddDays(-1).Date) label = "Yesterday - " + date.ToString("MMM dd", culture);
else label = date.ToString("MMM dd, yyyy", culture);
var header = new Transaction { Description = label, Date = date, GroupHeader = true };
PagedTransactions.Insert(index, header);
}
}
public void Initialize()
{
try
{
InitializeCategories();
InitializeAccounts();
CalculateMonthlyFinancials();
CurrentPage = 1;
OnPropertyChanged(nameof(TotalPages));
ResetFilters();
}
catch (Exception e)
{
DebugLogger.Log(e);
throw;
}
}
private void InitializeCategories()
{
Categories.Clear();
Categories.Insert(0, new Category() { Name = "All Categories" });
foreach (var appDataCategory in AppData.Categories)
{
Categories.Add(appDataCategory);
}
SelectedCategory = Categories.First();
}
private void InitializeAccounts()
{
Accounts.Clear();
Accounts.Insert(0, new Account() { Name = "All Accounts" });
foreach (var appDataAccount in AppData.Accounts)
{
Accounts.Add(appDataAccount);
}
SelectedAccount = Accounts.First();
}
private void CalculateMonthlyFinancials()
{
TotalExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.ConvertedAmount));
TotalIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.ConvertedAmount));
ExpensesCount = AppData.Transactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month);
IncomeCount = AppData.Transactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month);
}
public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5)
{
var index = list.IndexOf(item);
if (index == -1) return new List<T>();
var half = count / 2;
var start = Math.Max(0, index - half);
var end = Math.Min(list.Count, start + count);
// shift start back if end hit the boundary
start = Math.Max(0, end - count);
return list.GetRange(start, end - start);
}
[RelayCommand]
private void CreateTransaction()
{
((MainViewModel)parentViewModel).OpenAddTransaction();
}
[RelayCommand]
private void EditTransaction(Transaction transaction)
{
((MainViewModel)parentViewModel).OpenEditTransaction(transaction);
}
}