diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8ba2556..72060e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,11 @@ "allow": [ "WebFetch(domain:raw.githubusercontent.com)", "Bash(chmod +x \"/c/Users/Nouredeen/.claude/scripts/context-bar.sh\")", - "Bash(dotnet build:*)" + "Bash(dotnet build:*)", + "WebFetch(domain:git.nouredeen.dev)", + "WebFetch(domain:supabase.com)", + "Bash(grep:*)", + "Bash(cmd:*)" ] }, "spinnerTipsEnabled": true diff --git a/.gitignore b/.gitignore index 45aba5e..10d7358 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ obj/ ./Clario/CLAUDE_CONTEXT.md publish/ *.tar.gz -Clario/devsettings.json \ No newline at end of file +Clario/devsettings.json +.env \ No newline at end of file diff --git a/Clario.Android/MainActivity.cs b/Clario.Android/MainActivity.cs index 79920ec..7918666 100644 --- a/Clario.Android/MainActivity.cs +++ b/Clario.Android/MainActivity.cs @@ -1,21 +1,50 @@ -using Android.App; +using System; +using Android.App; +using Android.Content; using Android.Content.PM; +using Android.OS; using Avalonia; using Avalonia.Android; +using Clario; namespace Clario.Android; [Activity( - Label = "Clario.Android", + Label = "Clario", Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, + LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] +[IntentFilter( + new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "clario", + DataHost = "auth")] public class MainActivity : AvaloniaMainActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + // Capture deep link before Avalonia initializes + var uri = Intent?.DataString; + if (uri?.StartsWith("clario://", StringComparison.OrdinalIgnoreCase) == true) + App.PendingDeepLink = uri; + + base.OnCreate(savedInstanceState); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + // Called when app is already running (SingleTop) and link is opened again + var uri = intent?.DataString; + if (uri?.StartsWith("clario://", StringComparison.OrdinalIgnoreCase) == true) + _ = App.HandleDeepLink(uri); + } + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) { return base.CustomizeAppBuilder(builder) .WithInterFont(); } -} \ No newline at end of file +} diff --git a/Clario.Desktop/Clario.Desktop.parcel b/Clario.Desktop/Clario.Desktop.parcel index 74d2da5..407cc82 100644 --- a/Clario.Desktop/Clario.Desktop.parcel +++ b/Clario.Desktop/Clario.Desktop.parcel @@ -2,7 +2,7 @@ "GeneralSettings": { "NetProjectPath": "Clario.Desktop.csproj", "ApplicationName": "Clario", - "Version": "0.4.0", + "Version": "0.6.0", "PackageName": { "$type": "msbuild", "property": "AssemblyName" @@ -13,11 +13,11 @@ } }, "LinuxSettings": { - "AppIcon": "../Clario/Assets/Logo.png", + "AppIcon": "../Clario/Assets/AppIcons/logo-icon-primary-transparent.ico", "CreateBinSymlink": "True" }, "Win32Settings": { - "InstallerIcon": "../Clario/Assets/Clario-Logo.svg", + "InstallerIcon": "../Clario/Assets/AppIcons/logo-icon-primary-transparent.ico", "Company": "Clario", "IncludeUninstaller": "True" }, diff --git a/Clario.Desktop/Program.cs b/Clario.Desktop/Program.cs index d1e1a8f..bfc9ad6 100644 --- a/Clario.Desktop/Program.cs +++ b/Clario.Desktop/Program.cs @@ -1,19 +1,25 @@ -using System; +using System; +using System.Linq; using Avalonia; +using Clario; namespace Clario.Desktop; sealed class Program { - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] public static void Main(string[] args) { - - BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + // Capture deep link passed as command-line arg by Windows protocol handler + var deepLink = args.FirstOrDefault(a => + a.StartsWith("clario://", StringComparison.OrdinalIgnoreCase)); + if (deepLink != null) + App.PendingDeepLink = deepLink; + + // Register clario:// URL scheme on Windows (idempotent) + RegisterUrlScheme(); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } // Avalonia configuration, don't remove; also used by visual designer. @@ -22,4 +28,23 @@ sealed class Program .UsePlatformDetect() .WithInterFont() .LogToTrace(); -} \ No newline at end of file + + private static void RegisterUrlScheme() + { + if (!OperatingSystem.IsWindows()) return; + try + { + var exe = Environment.ProcessPath + ?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (exe is null) return; + + using var key = Microsoft.Win32.Registry.CurrentUser + .CreateSubKey(@"SOFTWARE\Classes\clario"); + key.SetValue("", "URL:Clario Protocol"); + key.SetValue("URL Protocol", ""); + using var cmd = key.CreateSubKey(@"shell\open\command"); + cmd.SetValue("", $"\"{exe}\" \"%1\""); + } + catch { /* ignore — no registry write access in sandboxed environments */ } + } +} diff --git a/Clario/App.axaml b/Clario/App.axaml index 6a262f8..bf7cd0c 100644 --- a/Clario/App.axaml +++ b/Clario/App.axaml @@ -36,5 +36,15 @@ + + + + + + \ No newline at end of file diff --git a/Clario/App.axaml.cs b/Clario/App.axaml.cs index 86df414..4b8cc12 100644 --- a/Clario/App.axaml.cs +++ b/Clario/App.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core.Plugins; @@ -17,6 +18,47 @@ public partial class App : Application { public static bool IsMobile { get; private set; } + /// Set before OnFrameworkInitializationCompleted runs (from Program.cs or MainActivity). + public static string? PendingDeepLink { get; set; } + + /// Called from MainActivity.OnNewIntent when app is already running. + public static async Task HandleDeepLink(string deepLink) + { + var (accessToken, refreshToken, type) = ParseDeepLinkFragment(deepLink); + if (type != "recovery" || accessToken is null) return; + + try { await SupabaseService.Client.Auth.SetSession(accessToken, refreshToken); } catch { } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + var vm = new ResetPasswordViewModel(); + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow!.DataContext = vm; + else if (Current?.ApplicationLifetime is ISingleViewApplicationLifetime sv) + sv.MainView!.DataContext = vm; + }); + } + + private static (string? accessToken, string? refreshToken, string? type) ParseDeepLinkFragment(string url) + { + var hash = url.IndexOf('#'); + if (hash < 0) return default; + string? at = null, rt = null, type = null; + foreach (var part in url[(hash + 1)..].Split('&')) + { + var eq = part.IndexOf('='); + if (eq < 0) continue; + var val = Uri.UnescapeDataString(part[(eq + 1)..]); + switch (part[..eq]) + { + case "access_token": at = val; break; + case "refresh_token": rt = val; break; + case "type": type = val; break; + } + } + return (at, rt, type); + } + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -73,19 +115,32 @@ public partial class App : Application ThemeService.SwitchToTheme(profile.Theme); } - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + // Check for deep link from password reset email + ViewModelBase targetViewModel; + if (PendingDeepLink is { } deepLink && deepLink.Contains("type=recovery")) { - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); - - desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); + var (accessToken, refreshToken, _) = ParseDeepLinkFragment(deepLink); + if (accessToken is not null) + { + try { await SupabaseService.Client.Auth.SetSession(accessToken, refreshToken); } catch { } + } + PendingDeepLink = null; + targetViewModel = new ResetPasswordViewModel(); + } + else + { + targetViewModel = user is not null ? new MainViewModel() : new AuthViewModel(); } + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + DisableAvaloniaDataAnnotationValidation(); + desktop.MainWindow!.DataContext = targetViewModel; + } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) { DebugLogger.Log("ANDROID PATH HIT"); - singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); + singleViewPlatform.MainView!.DataContext = targetViewModel; } } diff --git a/Clario/Clario.csproj b/Clario/Clario.csproj index 2eb9283..6f22e5a 100644 --- a/Clario/Clario.csproj +++ b/Clario/Clario.csproj @@ -50,6 +50,9 @@ AnalyticsView.axaml + + DashboardSkeletonView.axaml + @@ -57,4 +60,11 @@ Always + + true + clario.keystore + clario + env:ANDROID_SIGNING_PASSWORD + env:ANDROID_SIGNING_PASSWORD + diff --git a/Clario/CustomControls/DateRangePicker.axaml b/Clario/CustomControls/DateRangePicker.axaml index 6d9d96a..e5d017b 100644 --- a/Clario/CustomControls/DateRangePicker.axaml +++ b/Clario/CustomControls/DateRangePicker.axaml @@ -9,7 +9,7 @@ the internal template parts at ControlTheme priority, making external /template/ style selectors unreliable for CalendarButton. Replacing the entire ControlTheme is the only reliable approach. - --> + --> @@ -69,7 +69,7 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/DashboardSkeletonViewMobile.axaml.cs b/Clario/MobileViews/DashboardSkeletonViewMobile.axaml.cs new file mode 100644 index 0000000..5974cc2 --- /dev/null +++ b/Clario/MobileViews/DashboardSkeletonViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class DashboardSkeletonViewMobile : UserControl +{ + public DashboardSkeletonViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/DashboardViewMobile.axaml b/Clario/MobileViews/DashboardViewMobile.axaml index 20528e4..6607c75 100644 --- a/Clario/MobileViews/DashboardViewMobile.axaml +++ b/Clario/MobileViews/DashboardViewMobile.axaml @@ -16,7 +16,7 @@ - + - + - + @@ -164,7 +164,7 @@ - + - + - + - - - - + + + + + + + + Height="32" Width="32" HorizontalAlignment="Center" /> - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/MoreViewMobile.axaml.cs b/Clario/MobileViews/MoreViewMobile.axaml.cs new file mode 100644 index 0000000..a06bff4 --- /dev/null +++ b/Clario/MobileViews/MoreViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class MoreViewMobile : UserControl +{ + public MoreViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/ResetPasswordViewMobile.axaml b/Clario/MobileViews/ResetPasswordViewMobile.axaml new file mode 100644 index 0000000..97bc3b2 --- /dev/null +++ b/Clario/MobileViews/ResetPasswordViewMobile.axaml @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/MobileViews/ResetPasswordViewMobile.axaml.cs b/Clario/MobileViews/ResetPasswordViewMobile.axaml.cs new file mode 100644 index 0000000..f70c374 --- /dev/null +++ b/Clario/MobileViews/ResetPasswordViewMobile.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.MobileViews; + +public partial class ResetPasswordViewMobile : UserControl +{ + public ResetPasswordViewMobile() + { + InitializeComponent(); + } +} diff --git a/Clario/MobileViews/SettingsViewMobile.axaml b/Clario/MobileViews/SettingsViewMobile.axaml index f0c9220..84c2893 100644 --- a/Clario/MobileViews/SettingsViewMobile.axaml +++ b/Clario/MobileViews/SettingsViewMobile.axaml @@ -14,7 +14,7 @@ - + @@ -26,13 +26,13 @@ - + - + - + --> - + --> - + @@ -323,7 +323,7 @@ - + @@ -445,9 +445,9 @@ - + --> - + @@ -75,7 +75,7 @@ - + - + - + - + @@ -287,7 +287,7 @@ - + @@ -367,7 +367,7 @@ - + - + - + - + - + - + - + - @@ -630,7 +630,7 @@ diff --git a/Clario/MobileViews/TransactionsViewMobile.axaml b/Clario/MobileViews/TransactionsViewMobile.axaml index eee4e95..0f13569 100644 --- a/Clario/MobileViews/TransactionsViewMobile.axaml +++ b/Clario/MobileViews/TransactionsViewMobile.axaml @@ -9,10 +9,13 @@ x:DataType="vm:TransactionsViewModel" x:Name="transactionsRoot" Classes="mobile"> + + + - + @@ -169,7 +172,8 @@ Padding="0,10" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - Command="{Binding ApplyFiltersCommand}"> + Command="{Binding LoadPageStrCommand}" + CommandParameter="1"> - + @@ -271,13 +275,13 @@ - + - + @@ -391,6 +395,22 @@ + + + + Resolves a named date range option into concrete start/end dates and a display label. +public static class DateRangeService +{ + private static readonly CultureInfo Culture = new("en-US"); + + /// The named range key (e.g. "This Month", "Custom Range"). + /// Required when option is "Custom Range". + /// Null Start/End means "All Time" (no filter). Label is already uppercased for display. + public static (DateTime? Start, DateTime? End, string Label) Resolve(string option, IList? customDates = null) + { + var now = DateTime.Now; + return option switch + { + "Today" => (now.Date, now.Date, now.ToString("MMM d, yyyy", Culture).ToUpper()), + "This Week" => ResolveThisWeek(now), + "This Month" => ResolveThisMonth(now), + "Last Month" => ResolveLastMonth(now), + "This Quarter" => ResolveThisQuarter(now), + "This Year" => (new DateTime(now.Year, 1, 1), new DateTime(now.Year, 12, 31), now.Year.ToString()), + "Custom Range" => ResolveCustomRange(customDates, now), + _ => (null, null, "ALL TIME") + }; + } + + private static (DateTime?, DateTime?, string) ResolveThisWeek(DateTime now) + { + var start = now.Date.AddDays(-(int)now.DayOfWeek); + return (start, start.AddDays(6), "THIS WEEK"); + } + + private static (DateTime?, DateTime?, string) ResolveThisMonth(DateTime now) + { + var start = new DateTime(now.Year, now.Month, 1); + return (start, start.AddMonths(1).AddDays(-1), now.ToString("MMMM yyyy", Culture).ToUpper()); + } + + private static (DateTime?, DateTime?, string) ResolveLastMonth(DateTime now) + { + var lm = now.AddMonths(-1); + var start = new DateTime(lm.Year, lm.Month, 1); + return (start, start.AddMonths(1).AddDays(-1), lm.ToString("MMMM yyyy", Culture).ToUpper()); + } + + private static (DateTime?, DateTime?, string) ResolveThisQuarter(DateTime now) + { + var quarterMonth = now.Month - ((now.Month - 1) % 3); + var start = new DateTime(now.Year, quarterMonth, 1); + var end = start.AddMonths(3).AddDays(-1); + return (start, end, $"Q{(now.Month - 1) / 3 + 1} {now.Year}"); + } + + private static (DateTime?, DateTime?, string) ResolveCustomRange(IList? dates, DateTime now) + { + if (dates is null || dates.Count == 0) + return (now.Date, now.Date, now.ToString("MMM d, yyyy", Culture).ToUpper()); + + var ordered = dates.Select(d => d.Date).Distinct().OrderBy(d => d).ToList(); + var start = ordered.First(); + var end = ordered.Last(); + + var label = ordered.Count == 1 + ? start.ToString("MMM dd, yyyy", Culture).ToUpper() + : $"{start.ToString("MMM dd", Culture)} - {end.ToString("MMM dd, yyyy", Culture)}".ToUpper(); + + return (start, end, label); + } + + /// Formats a date as "Today - MMM dd", "Yesterday - MMM dd", or "MMM dd, yyyy". + public static string FormatGroupHeader(DateTime date) + { + var now = DateTime.Now.Date; + if (date.Date == now) return "Today — " + date.ToString("MMM dd", Culture); + if (date.Date == now.AddDays(-1)) return "Yesterday — " + date.ToString("MMM dd", Culture); + return date.ToString("MMM dd, yyyy", Culture); + } +} diff --git a/Clario/Services/PdfExportService.cs b/Clario/Services/PdfExportService.cs index 7e05b04..92b0c1e 100644 --- a/Clario/Services/PdfExportService.cs +++ b/Clario/Services/PdfExportService.cs @@ -19,7 +19,7 @@ namespace Clario.Services; public static class PdfExportService { - // ── Print palette (readable on white paper) ─────────── + // Print palette (readable on white paper) private const string TextPrimary = "#111827"; private const string TextSecondary = "#374151"; private const string TextMuted = "#6B7280"; @@ -80,7 +80,7 @@ public static class PdfExportService page.MarginVertical(1.5f, Unit.Centimetre); page.DefaultTextStyle(s => s.FontSize(10).FontColor(TextPrimary).FontFamily("Arial")); - // ── Header ──────────────────────────────────── + // Header page.Header().Column(col => { col.Item().Row(row => @@ -103,10 +103,10 @@ public static class PdfExportService col.Item().PaddingTop(10).LineHorizontal(2).LineColor(AccentBar); }); - // ── Content ─────────────────────────────────── + // Content page.Content().PaddingTop(18).Column(col => { - // ─ KPI cards ───────────────────────────── + // KPI cards col.Item().Text("Summary").FontSize(11).SemiBold().FontColor(TextPrimary); col.Item().PaddingTop(6).Table(table => { @@ -141,7 +141,7 @@ public static class PdfExportService col.Item().Height(20); - // ─ Top categories ───────────────────────── + // Top categories if (topCategories.Count > 0) { col.Item().Text("Top Spending Categories") @@ -189,7 +189,7 @@ public static class PdfExportService col.Item().Height(20); } - // ─ Transactions ─────────────────────────── + // Transactions col.Item().Text($"Transactions ({periodTxs.Count})") .FontSize(11).SemiBold().FontColor(TextPrimary); col.Item().PaddingTop(6).Table(table => @@ -244,7 +244,7 @@ public static class PdfExportService }); }); - // ── Footer ──────────────────────────────────── + // Footer page.Footer().PaddingTop(6).BorderTop(1).BorderColor(Border).Row(row => { row.RelativeItem().Text("Generated by Clario — Your personal finance tracker") diff --git a/Clario/Theme/AppTheme.axaml b/Clario/Theme/AppTheme.axaml index 2b3a244..523c817 100644 --- a/Clario/Theme/AppTheme.axaml +++ b/Clario/Theme/AppTheme.axaml @@ -241,6 +241,20 @@ path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #E8622A; } path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #D4306A; } + + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #F4F5F8; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #0F1117; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #353A4A; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #6B7080; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #A0A6B8; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #3B6AFF; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #18A86B; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #D4A012; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #E53535; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #6B44E0; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #E8622A; } + path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #D4306A; } + @@ -249,10 +263,9 @@ - path - + avares://Clario/Assets/Logo/logo-icon-primary-bg-light.svg avares://Clario/Assets/Logo/logo-icon-primary-bg-light-128.png @@ -606,7 +619,7 @@ @@ -1422,12 +1435,12 @@ - + - + - + - + - + - + - + - + - + - + - + --> - + --> @@ -129,9 +129,9 @@ - + --> - + --> - + --> - + --> diff --git a/Clario/Theme/Styles/ToggleSwitchStyles.axaml b/Clario/Theme/Styles/ToggleSwitchStyles.axaml index 8ec15a1..c6b96e6 100644 --- a/Clario/Theme/Styles/ToggleSwitchStyles.axaml +++ b/Clario/Theme/Styles/ToggleSwitchStyles.axaml @@ -130,7 +130,7 @@ - + @@ -155,7 +155,7 @@ - + @@ -180,7 +180,7 @@ - + diff --git a/Clario/ViewModels/AccountFormViewModel.cs b/Clario/ViewModels/AccountFormViewModel.cs index 6665f78..b1285cc 100644 --- a/Clario/ViewModels/AccountFormViewModel.cs +++ b/Clario/ViewModels/AccountFormViewModel.cs @@ -14,7 +14,7 @@ public partial class AccountFormViewModel : ViewModelBase { public required ViewModelBase parentViewModel; - // ── Mode ──────────────────────────────────────────────── + // Mode [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))] private bool _isEditMode = false; @@ -22,7 +22,7 @@ public partial class AccountFormViewModel : ViewModelBase public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Account"; - // ── Fields ────────────────────────────────────────────── + // Fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] private string _name = ""; @@ -78,12 +78,12 @@ public partial class AccountFormViewModel : ViewModelBase [ObservableProperty] private string _selectedColor = "#3B82F6"; - // ── Options ───────────────────────────────────────────── + // Options [ObservableProperty] private List _accountTypes = new() { "Cash", "Checking", "Savings", "Credit", "Investment", "Other" }; [ObservableProperty] private List _icons = new() { "wallet", "credit-card", "banknote", "landmark", "piggy-bank", "dollar-sign" }; - // ── Validation ────────────────────────────────────────── + // Validation [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; @@ -95,17 +95,17 @@ public partial class AccountFormViewModel : ViewModelBase public bool IsCredit => SelectedType == "Credit"; - // ── Callbacks ─────────────────────────────────────────── + // Callbacks public Action? OnSaved; public Action? OnCancelled; - // ── Edit mode: original account ───────────────────────── + // Edit mode: original account private Guid? _editingId; - // ── Result account ────────────────────────────────────── + // Result account public Account? ResultAccount { get; set; } - // ── Commands ──────────────────────────────────────────── + // Commands partial void OnSelectedTypeChanged(string value) { @@ -220,7 +220,7 @@ public partial class AccountFormViewModel : ViewModelBase OnCancelled?.Invoke(); } - // ── Public setup methods ───────────────────────────────── + // Public setup methods /// Call this to open the form for adding a new account. public void SetupForAdd() diff --git a/Clario/ViewModels/AccountsViewModel.cs b/Clario/ViewModels/AccountsViewModel.cs index 3fc9507..37a1be7 100644 --- a/Clario/ViewModels/AccountsViewModel.cs +++ b/Clario/ViewModels/AccountsViewModel.cs @@ -35,10 +35,15 @@ public partial class AccountsViewModel : ViewModelBase [ObservableProperty] private List _archivedAccounts = new(); public bool HasArchivedAccounts => ArchivedAccounts.Count > 0; + [ObservableProperty] private bool _shouldCloseSheet; + + /// Set by AccountsViewMobile. Returns true and closes the sheet if it was open. + public Func? TryCloseSheet { get; set; } + public AccountsViewModel() { - AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); }; - AppData.Transactions.CollectionChanged += (_, _) => { Initialize(); }; + Track(AppData.Accounts, (_, _) => Initialize()); + Track(AppData.Transactions, (_, _) => Initialize()); Initialize(); } @@ -184,6 +189,7 @@ public partial class AccountsViewModel : ViewModelBase { IsDeleteDialogVisible = false; Initialize(); + ShouldCloseSheet = true; }; DeleteDialog.OnCancelled = () => IsDeleteDialogVisible = false; IsDeleteDialogVisible = true; diff --git a/Clario/ViewModels/AnalyticsViewModel.cs b/Clario/ViewModels/AnalyticsViewModel.cs index 02824af..6d4a0c5 100644 --- a/Clario/ViewModels/AnalyticsViewModel.cs +++ b/Clario/ViewModels/AnalyticsViewModel.cs @@ -12,6 +12,7 @@ using CommunityToolkit.Mvvm.Input; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; +using SkiaSharp; using SKColor = SkiaSharp.SKColor; namespace Clario.ViewModels; @@ -21,7 +22,9 @@ public partial class AnalyticsViewModel : ViewModelBase public required ViewModelBase parentViewModel; public GeneralDataRepo AppData => DataRepo.General; - // ── Period ─────────────────────────────────────────── + private static readonly SKTypeface _interTypeface = SKTypeface.FromFamilyName("Inter"); + + // Period public List PeriodOptions { get; } = new() { "Last 30 Days", "Last 3 Months", "Last 6 Months", "Last 12 Months", "This Year" @@ -31,7 +34,7 @@ public partial class AnalyticsViewModel : ViewModelBase partial void OnSelectedPeriodChanged(string value) => Initialize(); - // ── KPI cards ──────────────────────────────────────── + // KPI cards [ObservableProperty] private string _totalIncomeFormatted = "—"; [ObservableProperty] private string _totalExpensesFormatted = "—"; [ObservableProperty] private string _netSavingsFormatted = "—"; @@ -40,38 +43,38 @@ public partial class AnalyticsViewModel : ViewModelBase public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD"); - // ── Cash Flow chart ────────────────────────────────── + // Cash Flow chart [ObservableProperty] private ISeries[] _cashFlowSeries = []; [ObservableProperty] private Axis[] _cashFlowXAxes = []; [ObservableProperty] private Axis[] _cashFlowYAxes = []; - // ── Net Worth chart ────────────────────────────────── + // Net Worth chart [ObservableProperty] private ISeries[] _netWorthSeries = []; [ObservableProperty] private Axis[] _netWorthXAxes = []; [ObservableProperty] private Axis[] _netWorthYAxes = []; - // ── Day-of-week chart ──────────────────────────────── + // Day-of-week chart [ObservableProperty] private ISeries[] _dayOfWeekSeries = []; [ObservableProperty] private Axis[] _dayOfWeekXAxes = []; - // ── Top categories ─────────────────────────────────── + // Top categories [ObservableProperty] private ObservableCollection _topCategories = new(); [ObservableProperty] private bool _hasTopCategories; - // ── Income sources donut ───────────────────────────── + // Income sources donut [ObservableProperty] private ISeries[] _incomeSourcesSeries = []; [ObservableProperty] private bool _hasIncomeSources; - // ── State ──────────────────────────────────────────── + // State [ObservableProperty] private bool _isExporting; [ObservableProperty] private string? _exportStatusMessage; - // ───────────────────────────────────────────────────── + // public AnalyticsViewModel() { - AppData.Transactions.CollectionChanged += (_, _) => Initialize(); - AppData.Accounts.CollectionChanged += (_, _) => Initialize(); + Track(AppData.Transactions, (_, _) => Initialize()); + Track(AppData.Accounts, (_, _) => Initialize()); Initialize(); } @@ -101,7 +104,7 @@ public partial class AnalyticsViewModel : ViewModelBase } } - // ── Date range ──────────────────────────────────────── + // Date range private (DateTime start, DateTime end) GetDateRange() { @@ -132,7 +135,7 @@ public partial class AnalyticsViewModel : ViewModelBase return buckets; } - // ── Section 1: KPIs ─────────────────────────────────── + // Section 1: KPIs private void ComputeKpis(List income, List expenses) { @@ -150,7 +153,7 @@ public partial class AnalyticsViewModel : ViewModelBase : "—"; } - // ── Section 2: Cash Flow ────────────────────────────── + // Section 2: Cash Flow private void BuildCashFlowChart(DateTime start, DateTime end) { @@ -202,7 +205,7 @@ public partial class AnalyticsViewModel : ViewModelBase new Axis { Labels = labels, - LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface }, SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), TicksPaint = null, TextSize = 11 @@ -214,7 +217,7 @@ public partial class AnalyticsViewModel : ViewModelBase [ new Axis { - LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface }, SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), TicksPaint = null, TextSize = 10, @@ -223,7 +226,7 @@ public partial class AnalyticsViewModel : ViewModelBase ]; } - // ── Section 3: Net Worth ────────────────────────────── + // Section 3: Net Worth private void BuildNetWorthChart(DateTime start, DateTime end) { @@ -266,7 +269,7 @@ public partial class AnalyticsViewModel : ViewModelBase new Axis { Labels = labels, - LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface }, SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), TicksPaint = null, TextSize = 11 @@ -278,7 +281,7 @@ public partial class AnalyticsViewModel : ViewModelBase [ new Axis { - LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface }, SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)), TicksPaint = null, TextSize = 10, @@ -287,7 +290,7 @@ public partial class AnalyticsViewModel : ViewModelBase ]; } - // ── Section 4: Day of Week ──────────────────────────── + // Section 4: Day of Week private void BuildDayOfWeekChart(List expenses, DateTime start, DateTime end) { @@ -331,7 +334,7 @@ public partial class AnalyticsViewModel : ViewModelBase new Axis { Labels = dayLabels, - LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")), + LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface }, SeparatorsPaint = null, TicksPaint = null, TextSize = 11 @@ -339,7 +342,7 @@ public partial class AnalyticsViewModel : ViewModelBase ]; } - // ── Section 5: Top Categories ───────────────────────── + // Section 5: Top Categories private void BuildTopCategories(List expenses) { @@ -372,7 +375,7 @@ public partial class AnalyticsViewModel : ViewModelBase HasTopCategories = grouped.Count > 0; } - // ── Section 6: Income Sources ───────────────────────── + // Section 6: Income Sources private void BuildIncomeSourcesChart(List income) { @@ -403,7 +406,7 @@ public partial class AnalyticsViewModel : ViewModelBase HasIncomeSources = true; } - // ── PDF Export ──────────────────────────────────────── + // PDF Export [RelayCommand] private async Task ExportPdf() diff --git a/Clario/ViewModels/AuthViewModel.cs b/Clario/ViewModels/AuthViewModel.cs index 579c2f5..2c48a66 100644 --- a/Clario/ViewModels/AuthViewModel.cs +++ b/Clario/ViewModels/AuthViewModel.cs @@ -24,7 +24,7 @@ public partial class AuthViewModel : ViewModelBase [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand))] private string _lastName; - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))] + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand), nameof(SendResetLinkCommand))] private string _email; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))] @@ -34,13 +34,15 @@ public partial class AuthViewModel : ViewModelBase private string _confirmPassword; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(isSignin), nameof(isCreateAccount))] - [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))] + [NotifyPropertyChangedFor(nameof(isSignin), nameof(isCreateAccount), nameof(isForgotPassword), nameof(ShowTabs))] + [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand), nameof(SendResetLinkCommand))] private string _operation = "login"; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; + [ObservableProperty] private bool _resetEmailSent; + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); public AuthViewModel() @@ -72,6 +74,30 @@ public partial class AuthViewModel : ViewModelBase { Operation = operation; ErrorMessage = null; + ResetEmailSent = false; + } + + [RelayCommand(CanExecute = nameof(canSendResetLink))] + private async Task SendResetLink() + { + ErrorMessage = null; + try + { + await SupabaseService.Client.Auth.ResetPasswordForEmail(_email); + ResetEmailSent = true; + } + catch (GotrueException e) + { + DebugLogger.Log(e); + ErrorMessage = e.Reason == FailureHint.Reason.UserBadEmailAddress + ? GetErrorMessage(AuthError.InvalidEmail) + : GetErrorMessage(AuthError.Unknown); + } + catch (Exception e) + { + DebugLogger.Log(e); + ErrorMessage = GetErrorMessage(AuthError.Unknown); + } } [RelayCommand(CanExecute = nameof(canSignin))] @@ -185,12 +211,16 @@ public partial class AuthViewModel : ViewModelBase public bool isSignin => Operation == "login"; public bool isCreateAccount => Operation == "signup"; + public bool isForgotPassword => Operation == "forgotPassword"; + public bool ShowTabs => !isForgotPassword; public bool canSignin => isSignin && !string.IsNullOrWhiteSpace(_email) && !string.IsNullOrWhiteSpace(_password); public bool canCreateAccount => isCreateAccount && !string.IsNullOrWhiteSpace(_firstName) && !string.IsNullOrWhiteSpace(_lastName) && !string.IsNullOrWhiteSpace(_email) && !string.IsNullOrWhiteSpace(_password) && _password == _confirmPassword; + + public bool canSendResetLink => isForgotPassword && !string.IsNullOrWhiteSpace(_email); } class Wrapper diff --git a/Clario/ViewModels/BudgetFormViewModel.cs b/Clario/ViewModels/BudgetFormViewModel.cs index 5c92b25..dee7fb1 100644 --- a/Clario/ViewModels/BudgetFormViewModel.cs +++ b/Clario/ViewModels/BudgetFormViewModel.cs @@ -14,7 +14,7 @@ public partial class BudgetFormViewModel : ViewModelBase { public required ViewModelBase parentViewModel; - // ── Mode ──────────────────────────────────────────────── + // Mode [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))] private bool _isEditMode = false; @@ -22,7 +22,7 @@ public partial class BudgetFormViewModel : ViewModelBase public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Budget"; - // ── Fields ────────────────────────────────────────────── + // Fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsMonthly), nameof(IsQuarterly), nameof(IsYearly), nameof(IsValid))] private string _period = "monthly"; @@ -43,7 +43,7 @@ public partial class BudgetFormViewModel : ViewModelBase [ObservableProperty] private bool _rollover = false; - // ── Validation ────────────────────────────────────────── + // Validation [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; @@ -57,20 +57,20 @@ public partial class BudgetFormViewModel : ViewModelBase decimal.TryParse(LimitAmount, out var amt) && amt > 0 && SelectedCategory is not null; - // ── Callbacks ─────────────────────────────────────────── + // Callbacks public Action? OnSaved; public Action? OnCancelled; public Action? OnDeleted; [ObservableProperty] private bool _showDeleteConfirm = false; - // ── Edit mode: original budget ─────────────────────────── + // Edit mode: original budget private Guid? _editingId; - // ── Result ────────────────────────────────────────────── + // Result public Budget? ResultBudget { get; set; } - // ── Commands ──────────────────────────────────────────── + // Commands [RelayCommand] private void SetPeriod(string period) @@ -174,7 +174,7 @@ public partial class BudgetFormViewModel : ViewModelBase OnCancelled?.Invoke(); } - // ── Public setup methods ───────────────────────────────── + // Public setup methods /// Call this to open the form for adding a new budget. public void SetupForAdd(ObservableCollection categories) diff --git a/Clario/ViewModels/BudgetViewModel.cs b/Clario/ViewModels/BudgetViewModel.cs index 34ce8fd..1b563fa 100644 --- a/Clario/ViewModels/BudgetViewModel.cs +++ b/Clario/ViewModels/BudgetViewModel.cs @@ -69,17 +69,20 @@ public partial class BudgetViewModel : ViewModelBase public BudgetViewModel() { - AppData.Budgets.CollectionChanged += async (_, _) => { await Initialize(); }; - AppData.Transactions.CollectionChanged += async (_, _) => { await Initialize(); }; - AppData.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(AppData.Profile)) - NotifyComputedPropertiesOnChanged(); - }; + Track(AppData.Budgets, async (_, _) => await Initialize()); + Track(AppData.Transactions, async (_, _) => await Initialize()); + AppData.PropertyChanged += OnProfileChanged; + OnDispose(() => AppData.PropertyChanged -= OnProfileChanged); WeakReferenceMessenger.Default.Register(this, async (_, _) => await Initialize()); _ = Initialize(); } + private void OnProfileChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppData.Profile)) + NotifyComputedPropertiesOnChanged(); + } + private async Task Initialize() { try @@ -98,19 +101,19 @@ public partial class BudgetViewModel : ViewModelBase [RelayCommand] private void CreateBudget() { - ((MainViewModel)parentViewModel).OpenAddBudgetCommand.Execute(null); + if (parentViewModel is MainViewModel main) main.OpenAddBudgetCommand.Execute(null); } [RelayCommand] private void EditBudget(Budget budget) { - ((MainViewModel)parentViewModel).OpenEditBudgetCommand.Execute(budget); + if (parentViewModel is MainViewModel main) main.OpenEditBudgetCommand.Execute(budget); } [RelayCommand] private void EditSavingsGoal() { - ((MainViewModel)parentViewModel).OpenEditSavingsGoalCommand.Execute(null); + if (parentViewModel is MainViewModel main) main.OpenEditSavingsGoalCommand.Execute(null); } private void ProcessChartData() diff --git a/Clario/ViewModels/CategoriesViewModel.cs b/Clario/ViewModels/CategoriesViewModel.cs new file mode 100644 index 0000000..c2c297d --- /dev/null +++ b/Clario/ViewModels/CategoriesViewModel.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Clario.Data; +using Clario.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Clario.ViewModels; + +public partial class CategoriesViewModel : ViewModelBase +{ + public required ViewModelBase parentViewModel; + public GeneralDataRepo AppData => DataRepo.General; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasExpenseCategories))] + private ObservableCollection _expenseCategories = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasIncomeCategories))] + private ObservableCollection _incomeCategories = new(); + + public bool HasExpenseCategories => ExpenseCategories.Count > 0; + public bool HasIncomeCategories => IncomeCategories.Count > 0; + + public CategoriesViewModel() + { + Track(AppData.Categories, (_, _) => Initialize()); + Initialize(); + } + + public void Initialize() + { + ExpenseCategories = new ObservableCollection( + AppData.Categories.Where(c => c.Type == "expense").OrderBy(c => c.Name)); + IncomeCategories = new ObservableCollection( + AppData.Categories.Where(c => c.Type == "income").OrderBy(c => c.Name)); + } + + [RelayCommand] + private void EditCategory(Category category) + { + if (parentViewModel is MainViewModel mainVm) + mainVm.OpenEditCategory(category); + } + + [RelayCommand] + private void AddCategory() + { + if (parentViewModel is MainViewModel mainVm) + mainVm.OpenAddCategory(); + } +} diff --git a/Clario/ViewModels/CategoryFormViewModel.cs b/Clario/ViewModels/CategoryFormViewModel.cs index 87e2d50..7dc8e15 100644 --- a/Clario/ViewModels/CategoryFormViewModel.cs +++ b/Clario/ViewModels/CategoryFormViewModel.cs @@ -13,7 +13,7 @@ public partial class CategoryFormViewModel : ViewModelBase { public required ViewModelBase parentViewModel; - // ── Mode ──────────────────────────────────────────────── + // Mode [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel), nameof(CanDelete))] private bool _isEditMode = false; @@ -22,7 +22,7 @@ public partial class CategoryFormViewModel : ViewModelBase public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Category"; - // ── Fields ────────────────────────────────────────────── + // Fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] private string _name = ""; @@ -33,7 +33,7 @@ public partial class CategoryFormViewModel : ViewModelBase [ObservableProperty] private string _selectedColor = "#7B9CFF"; - // ── Icon options ───────────────────────────────────────── + // Icon options public List CategoryIcons { get; } = new() { // Food & Dining @@ -59,7 +59,7 @@ public partial class CategoryFormViewModel : ViewModelBase "receipt", "receipt-text", "smartphone", "volume-2", "refresh-cw", }; - // ── Validation ────────────────────────────────────────── + // Validation [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; @@ -69,18 +69,18 @@ public partial class CategoryFormViewModel : ViewModelBase public bool IsValid => !string.IsNullOrWhiteSpace(Name); public bool CanDelete => IsEditMode && DataRepo.General.Categories.Count > 4; - // ── Delete confirm sub-modal ──────────────────────────── + // Delete confirm sub-modal [ObservableProperty] private bool _showDeleteConfirm = false; - // ── Callbacks ─────────────────────────────────────────── + // Callbacks public Action? OnSaved; public Action? OnCancelled; public Action? OnDeleted; - // ── Edit mode: original category ──────────────────────── + // Edit mode: original category private Guid? _editingId; - // ── Commands ──────────────────────────────────────────── + // Commands [RelayCommand] private void SetType(string type) => Type = type; @@ -163,7 +163,7 @@ public partial class CategoryFormViewModel : ViewModelBase } } - // ── Public setup methods ───────────────────────────────── + // Public setup methods public void SetupForAdd() { diff --git a/Clario/ViewModels/DashboardSkeletonViewModel.cs b/Clario/ViewModels/DashboardSkeletonViewModel.cs new file mode 100644 index 0000000..9e5390d --- /dev/null +++ b/Clario/ViewModels/DashboardSkeletonViewModel.cs @@ -0,0 +1,5 @@ +namespace Clario.ViewModels; + +public partial class DashboardSkeletonViewModel : ViewModelBase +{ +} diff --git a/Clario/ViewModels/DashboardViewModel.cs b/Clario/ViewModels/DashboardViewModel.cs index 1cdc6c7..feb3efa 100644 --- a/Clario/ViewModels/DashboardViewModel.cs +++ b/Clario/ViewModels/DashboardViewModel.cs @@ -86,38 +86,25 @@ public partial class DashboardViewModel : ViewModelBase partial void OnSelectedChartTimePeriodChanged(string value) { - ChartTimePeriod period = value switch - { - "This Month" => ChartTimePeriod.ThisMonth, - "Last Month" => ChartTimePeriod.LastMonth, - "This Quarter" => ChartTimePeriod.ThisQuarter, - "This Year" => ChartTimePeriod.ThisYear, - _ => ChartTimePeriod.ThisMonth - }; + var (_, _, subtitle) = DateRangeService.Resolve(value); + SelectedChartTimPeriodSubTitle = subtitle.Length > 0 + ? char.ToUpper(subtitle[0]) + subtitle.Substring(1).ToLower() + : subtitle; - SelectedChartTimPeriodSubTitle = value switch - { - "This Month" => DateTime.Now.ToString("MMMM yyyy"), - "Last Month" => DateTime.Now.AddMonths(-1).ToString("MMMM yyyy"), - "This Quarter" => $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}", - "This Year" => DateTime.Now.Year.ToString(), - _ => DateTime.Now.ToString("MMMM yyyy") - }; - - UpdateSpendingByCategoryChart(period); + UpdateSpendingByCategoryChart(value); } public DashboardViewModel() { - AppData.Transactions.CollectionChanged += (s, e) => UpdateUserOverview(); - AppData.Accounts.CollectionChanged += (s, e) => UpdateUserOverview(); - AppData.Categories.CollectionChanged += (s, e) => UpdateUserOverview(); - AppData.Budgets.CollectionChanged += (s, e) => UpdateUserOverview(); + Track(AppData.Transactions, (_, _) => UpdateUserOverview()); + Track(AppData.Accounts, (_, _) => UpdateUserOverview()); + Track(AppData.Categories, (_, _) => UpdateUserOverview()); + Track(AppData.Budgets, (_, _) => UpdateUserOverview()); WeakReferenceMessenger.Default.Register(this, (_, _) => UpdateUserOverview()); - initialize(); + Initialize(); } - public void initialize() + public void Initialize() { UpdateUserOverview(); } @@ -126,7 +113,7 @@ public partial class DashboardViewModel : ViewModelBase private void UpdateUserOverview() { CalculateMonthlyValues(); - UpdateSpendingByCategoryChart(); + UpdateSpendingByCategoryChart(SelectedChartTimePeriod); _ = UpdateBudgetTracker(); UpdateRecentTransactions(); UpdateAccountsSummary(); @@ -175,53 +162,50 @@ public partial class DashboardViewModel : ViewModelBase [RelayCommand] private void CreateTransaction() { - ((MainViewModel)parentViewModel).OpenAddTransaction(); + if (parentViewModel is MainViewModel main) main.OpenAddTransaction(); } [RelayCommand] private void NavigateToSettings() { - ((MainViewModel)parentViewModel).GoToSettingsCommand.Execute(null); + if (parentViewModel is MainViewModel main) main.GoToSettingsCommand.Execute(null); } - private void UpdateSpendingByCategoryChart(ChartTimePeriod period = ChartTimePeriod.ThisMonth) + [RelayCommand] + private void NavigateToBudget() { + if (parentViewModel is MainViewModel main) main.GoToBudgetCommand.Execute(null); + } + + [RelayCommand] + private void OpenAddBudget() + { + if (parentViewModel is MainViewModel main) main.OpenAddBudgetCommand.Execute(null); + } + + private void UpdateSpendingByCategoryChart(string period = "This Month") + { + var (start, end, _) = DateRangeService.Resolve(period); var tempList = new List(); foreach (var category in AppData.Categories) { - var categoryTransactions = - AppData.Transactions.Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase)); + var txns = AppData.Transactions + .Where(x => x.CategoryId == category.Id + && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase) + && (start is null || x.Date.Date >= start.Value) + && (end is null || x.Date.Date <= end.Value)); - switch (period) + var total = txns.Sum(x => x.ConvertedAmount); + if (total == 0) continue; + + tempList.Add(new ColumnChartData { - case ChartTimePeriod.ThisMonth: - categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.Month); - break; - - case ChartTimePeriod.LastMonth: - categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.AddMonths(-1).Month); - break; - - case ChartTimePeriod.ThisQuarter: - categoryTransactions = categoryTransactions.Where(x => - x.Date.Month >= DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3).Month && - x.Date.Month <= DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3).AddMonths(3).Month); - break; - - case ChartTimePeriod.ThisYear: - categoryTransactions = categoryTransactions.Where(x => x.Date.Year == DateTime.Now.Year); - break; - - default: - categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.Month); - break; - } - - var balance = categoryTransactions.Sum(x => x.ConvertedAmount); - if (balance == 0) continue; - tempList.Add(new ColumnChartData() - { id = category.Id, Name = category.Name, Values = [(double)balance], Fill = new SolidColorPaint(SKColor.Parse(category.Color)) }); + id = category.Id, + Name = category.Name, + Values = [(double)total], + Fill = new SolidColorPaint(SKColor.Parse(category.Color)) + }); } tempList = tempList.OrderByDescending(x => x.Values[0]).ToList(); @@ -268,12 +252,4 @@ public partial class DashboardViewModel : ViewModelBase AccountsSummaryData = new ObservableCollection(AppData.Accounts.Where(a => !a.IsArchived).OrderBy(x => x.CreatedAt)); OnPropertyChanged(nameof(AccountsSubtitle)); } - - private enum ChartTimePeriod - { - ThisMonth, - LastMonth, - ThisQuarter, - ThisYear - } } \ No newline at end of file diff --git a/Clario/ViewModels/DeleteAccountDialogViewModel.cs b/Clario/ViewModels/DeleteAccountDialogViewModel.cs index 7cbe1d6..389d692 100644 --- a/Clario/ViewModels/DeleteAccountDialogViewModel.cs +++ b/Clario/ViewModels/DeleteAccountDialogViewModel.cs @@ -12,7 +12,7 @@ namespace Clario.ViewModels; public partial class DeleteAccountDialogViewModel : ViewModelBase { - // ── State machine ──────────────────────────────────────── + // State machine public enum DialogStep { SimpleConfirm, @@ -31,7 +31,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase public bool IsHasTransactionsStep => CurrentStep == DialogStep.HasTransactions; public bool IsMigrateStep => CurrentStep == DialogStep.Migrate; - // ── Data ───────────────────────────────────────────────── + // Data [ObservableProperty] private Account? _account; public GeneralDataRepo AppData => DataRepo.General; @@ -40,7 +40,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _availableAccounts = new(); - // ── Validation ─────────────────────────────────────────── + // Validation [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; @@ -50,11 +50,11 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase TargetAccount is not null && TargetAccount.Id != Account?.Id; - // ── Callbacks ──────────────────────────────────────────── + // Callbacks public Action? OnDeleted; public Action? OnCancelled; - // ── Setup ──────────────────────────────────────────────── + // Setup /// /// Call this to open the dialog for a specific account. @@ -79,7 +79,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase : DialogStep.SimpleConfirm; } - // ── Commands ───────────────────────────────────────────── + // Commands [RelayCommand] private void Cancel() => OnCancelled?.Invoke(); diff --git a/Clario/ViewModels/LoadingViewModel.cs b/Clario/ViewModels/LoadingViewModel.cs index 534e025..4777a9e 100644 --- a/Clario/ViewModels/LoadingViewModel.cs +++ b/Clario/ViewModels/LoadingViewModel.cs @@ -1,8 +1,10 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using Avalonia.Xaml.Interactivity; +using Clario.Services; +using CommunityToolkit.Mvvm.ComponentModel; namespace Clario.ViewModels; public partial class LoadingViewModel : ViewModelBase { - } \ No newline at end of file diff --git a/Clario/ViewModels/MainViewModel.cs b/Clario/ViewModels/MainViewModel.cs index 2e9bdb8..933f4f1 100644 --- a/Clario/ViewModels/MainViewModel.cs +++ b/Clario/ViewModels/MainViewModel.cs @@ -21,7 +21,9 @@ public partial class MainViewModel : ViewModelBase public TransactionsViewModel _transactionsViewModel = null!; private AccountsViewModel _accountsViewModel = null!; private BudgetViewModel _budgetViewModel = null!; + private CategoriesViewModel _categoriesViewModel = null!; private AnalyticsViewModel _analyticsViewModel = null!; + private MoreViewModel _moreViewModel = null!; GeneralDataRepo AppData => DataRepo.General; [ObservableProperty] private Profile? _profile; @@ -34,6 +36,9 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] private SetSavingsGoalDialogViewModel _setSavingsGoalDialogViewModel = null!; [ObservableProperty] private bool _isDimmed; + [ObservableProperty] private bool _isMessageBoxVisible; + [ObservableProperty] private MessageBoxViewModel _messageBoxViewModel = new(); + [ObservableProperty] private bool _isTransactionFormVisible; [ObservableProperty] private bool _isAccountFormVisible; [ObservableProperty] private bool _isBudgetFormVisible; @@ -42,7 +47,8 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] - [NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnAnalytics), nameof(isOnSettings))] + [NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnCategories), nameof(isOnAnalytics), + nameof(isOnSettings), nameof(isOnMore))] private ViewModelBase? _currentView; [ObservableProperty] private bool _isDarkTheme; @@ -50,13 +56,13 @@ public partial class MainViewModel : ViewModelBase public MainViewModel() { DebugLogger.Log("main vm loaded"); - WeakReferenceMessenger.Default.Register(this, (_, m) => + WeakReferenceMessenger.Default.Register(this, (s, m) => { Profile = AppData.Profile; _ = DataRepo.General.RefreshLiveRatesAndEnrich(); }); IsDimmed = true; - CurrentView = new LoadingViewModel(); + CurrentView = new DashboardSkeletonViewModel(); _ = InitializeApp(); } @@ -73,12 +79,12 @@ public partial class MainViewModel : ViewModelBase var accountsTask = DataRepo.General.FetchAccounts(); var budgetsTask = DataRepo.General.FetchBudgets(); await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask); - Profile = profilesTask.Result; DataRepo.General.LinkTransactionCategories(); await DataRepo.General.RefreshLiveRatesAndEnrich(); + DebugLogger.Log("fetched all data"); }); @@ -89,12 +95,13 @@ public partial class MainViewModel : ViewModelBase parentViewModel = this }; DebugLogger.Log("initialized DashboardViewModel"); + _transactionsViewModel = new TransactionsViewModel() { parentViewModel = this }; - DebugLogger.Log("initialized TransactionsViewModel"); + _accountsViewModel = new AccountsViewModel() { parentViewModel = this @@ -106,6 +113,16 @@ public partial class MainViewModel : ViewModelBase parentViewModel = this }; DebugLogger.Log("initialized BudgetViewModel"); + _categoriesViewModel = new CategoriesViewModel() + { + parentViewModel = this + }; + DebugLogger.Log("initialized CategoriesViewModel"); + _moreViewModel = new MoreViewModel() + { + parentViewModel = this + }; + DebugLogger.Log("initialized MoreViewModel"); _analyticsViewModel = new AnalyticsViewModel() { parentViewModel = this @@ -144,6 +161,7 @@ public partial class MainViewModel : ViewModelBase IsDarkTheme = ThemeService.IsDarkTheme; ThemeService.SwitchToTheme(AppData.Profile?.Theme ?? "system"); + AppData.StartRealtimeSync(); CurrentView = _dashboardViewModel; IsDimmed = false; } @@ -153,6 +171,24 @@ public partial class MainViewModel : ViewModelBase } } + /// Shows a themed message box overlay. Safe to call from any child ViewModel. + public void ShowMessage(MessageType type, string title, string message) + { + MessageBoxViewModel.Type = type; + MessageBoxViewModel.Title = title; + MessageBoxViewModel.Message = message; + MessageBoxViewModel.OnClose = CloseMessageBox; + IsMessageBoxVisible = true; + IsDimmed = true; + } + + [RelayCommand] + private void CloseMessageBox() + { + IsMessageBoxVisible = false; + IsDimmed = false; + } + [RelayCommand] public void OpenAddTransaction() { @@ -403,6 +439,18 @@ public partial class MainViewModel : ViewModelBase CurrentView = _budgetViewModel; } + [RelayCommand] + private void GoToCategories() + { + CurrentView = _categoriesViewModel; + } + + [RelayCommand] + private void GoToMore() + { + CurrentView = _moreViewModel; + } + [RelayCommand] private void GoToAnalytics() { @@ -432,10 +480,74 @@ public partial class MainViewModel : ViewModelBase } } + /// Returns true if the back event was handled (suppress system back), false to let the system close the app. + public bool HandleBackNavigation() + { + // 1. Close deepest-nested modal first (category form sits on top of transaction form) + if (IsCategoryFormVisible) + { + CloseCategoryForm(); + return true; + } + + if (IsTransactionFormVisible) + { + CloseTransactionForm(); + return true; + } + + if (IsAccountFormVisible) + { + CloseAccountForm(); + return true; + } + + if (IsBudgetFormVisible) + { + CloseBudgetForm(); + return true; + } + + if (IsSavingsGoalDialogVisible) + { + CloseSavingsGoalDialog(); + return true; + } + + // 2. Close dialogs inside AccountsView + if (_accountsViewModel is { IsDeleteDialogVisible: true }) + { + _accountsViewModel.IsDeleteDialogVisible = false; + return true; + } + + if (_accountsViewModel is { IsArchiveDialogVisible: true }) + { + _accountsViewModel.IsArchiveDialogVisible = false; + return true; + } + + // 3. Close AccountsView bottom sheet + if (_accountsViewModel?.TryCloseSheet?.Invoke() == true) + return true; + + // 4. Navigate back to dashboard from any non-dashboard main view + if (!isOnDashboard) + { + CurrentView = _dashboardViewModel; + return true; + } + + // 5. Already on dashboard — let the system handle (closes the app) + return false; + } + public bool isOnDashboard => CurrentView is DashboardViewModel; public bool isOnTransactions => CurrentView is TransactionsViewModel; public bool isOnAccounts => CurrentView is AccountsViewModel; public bool isOnBudget => CurrentView is BudgetViewModel; + public bool isOnCategories => CurrentView is CategoriesViewModel; public bool isOnAnalytics => CurrentView is AnalyticsViewModel; public bool isOnSettings => CurrentView is SettingsViewModel; + public bool isOnMore => CurrentView is MoreViewModel or AnalyticsViewModel or BudgetViewModel or CategoriesViewModel; } \ No newline at end of file diff --git a/Clario/ViewModels/MessageBoxViewModel.cs b/Clario/ViewModels/MessageBoxViewModel.cs new file mode 100644 index 0000000..0da2e51 --- /dev/null +++ b/Clario/ViewModels/MessageBoxViewModel.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System; + +namespace Clario.ViewModels; + +public enum MessageType { Error, Warning, Success, Info } + +public partial class MessageBoxViewModel : ViewModelBase +{ + [ObservableProperty] private MessageType _type = MessageType.Info; + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _message = ""; + + public bool IsError => Type == MessageType.Error; + public bool IsWarning => Type == MessageType.Warning; + public bool IsSuccess => Type == MessageType.Success; + public bool IsInfo => Type == MessageType.Info; + + public Action? OnClose { get; set; } + + partial void OnTypeChanged(MessageType value) + { + OnPropertyChanged(nameof(IsError)); + OnPropertyChanged(nameof(IsWarning)); + OnPropertyChanged(nameof(IsSuccess)); + OnPropertyChanged(nameof(IsInfo)); + } + + [RelayCommand] + private void Close() => OnClose?.Invoke(); +} diff --git a/Clario/ViewModels/MoreViewModel.cs b/Clario/ViewModels/MoreViewModel.cs new file mode 100644 index 0000000..182831c --- /dev/null +++ b/Clario/ViewModels/MoreViewModel.cs @@ -0,0 +1,29 @@ +using CommunityToolkit.Mvvm.Input; + +namespace Clario.ViewModels; + +public partial class MoreViewModel : ViewModelBase +{ + public required ViewModelBase parentViewModel; + + [RelayCommand] + private void GoToAnalytics() + { + if (parentViewModel is MainViewModel mainVm) + mainVm.GoToAnalyticsCommand.Execute(null); + } + + [RelayCommand] + private void GoToBudget() + { + if (parentViewModel is MainViewModel mainVm) + mainVm.GoToBudgetCommand.Execute(null); + } + + [RelayCommand] + private void GoToCategories() + { + if (parentViewModel is MainViewModel mainVm) + mainVm.GoToCategoriesCommand.Execute(null); + } +} diff --git a/Clario/ViewModels/ResetPasswordViewModel.cs b/Clario/ViewModels/ResetPasswordViewModel.cs new file mode 100644 index 0000000..8e89bb8 --- /dev/null +++ b/Clario/ViewModels/ResetPasswordViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Clario.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; + +namespace Clario.ViewModels; + +public partial class ResetPasswordViewModel : ViewModelBase +{ + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SetNewPasswordCommand))] + private string _newPassword = string.Empty; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SetNewPasswordCommand))] + private string _confirmPassword = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? _errorMessage; + + [ObservableProperty] private bool _passwordUpdated; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + + [RelayCommand(CanExecute = nameof(CanSetPassword))] + private async Task SetNewPassword() + { + ErrorMessage = null; + try + { + await SupabaseService.Client.Auth.Update(new UserAttributes { Password = _newPassword }); + PasswordUpdated = true; + } + catch (GotrueException e) + { + DebugLogger.Log(e); + ErrorMessage = e.Reason == FailureHint.Reason.UserBadPassword + ? "Password must be at least 6 characters." + : "Something went wrong. Please try again."; + } + catch (Exception e) + { + DebugLogger.Log(e); + ErrorMessage = "Something went wrong. Please try again."; + } + } + + [RelayCommand] + private void GoToSignIn() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow!.DataContext = new AuthViewModel(); + else if (Application.Current?.ApplicationLifetime is ISingleViewApplicationLifetime sv) + sv.MainView!.DataContext = new AuthViewModel(); + } + + private bool CanSetPassword => + !string.IsNullOrWhiteSpace(_newPassword) && + _newPassword.Length >= 6 && + _newPassword == _confirmPassword; +} diff --git a/Clario/ViewModels/SetSavingsGoalDialogViewModel.cs b/Clario/ViewModels/SetSavingsGoalDialogViewModel.cs index a42fc4f..f65b432 100644 --- a/Clario/ViewModels/SetSavingsGoalDialogViewModel.cs +++ b/Clario/ViewModels/SetSavingsGoalDialogViewModel.cs @@ -26,11 +26,11 @@ public partial class SetSavingsGoalDialogViewModel : ViewModelBase public bool IsValid => decimal.TryParse(GoalInput, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) && v >= 0; - // ── Callbacks ──────────────────────────────────────────── + // Callbacks public Action? OnSaved; public Action? OnCancelled; - // ── Setup ──────────────────────────────────────────────── + // Setup public void Setup(decimal? currentGoal) { GoalInput = currentGoal.HasValue @@ -39,7 +39,7 @@ public partial class SetSavingsGoalDialogViewModel : ViewModelBase ErrorMessage = null; } - // ── Commands ───────────────────────────────────────────── + // Commands [RelayCommand] private void Cancel() => OnCancelled?.Invoke(); diff --git a/Clario/ViewModels/SettingsViewModel.cs b/Clario/ViewModels/SettingsViewModel.cs index 259ff68..995ed5d 100644 --- a/Clario/ViewModels/SettingsViewModel.cs +++ b/Clario/ViewModels/SettingsViewModel.cs @@ -24,29 +24,29 @@ public partial class SettingsViewModel : ViewModelBase public static readonly HttpClient _HttpClient = new(); - // ── Profile fields ─────────────────────────────────────── + // Profile fields [ObservableProperty] private string _displayName = ""; [ObservableProperty] private string _avatarUrl = ""; [ObservableProperty] private Bitmap? _avatarImage; [ObservableProperty] private string _selectedTheme = "system"; [ObservableProperty] private string _selectedLanguage = "en"; - // ── Account (auth) fields ──────────────────────────────── + // Account (auth) fields [ObservableProperty] private string _maskedEmail = ""; private string _fullEmail = ""; - // ── Change email flow ──────────────────────────────────── + // Change email flow [ObservableProperty] private bool _isChangingEmail = false; [ObservableProperty] private string _newEmail = ""; [ObservableProperty] private string _emailConfirmPassword = ""; - // ── Change password flow ───────────────────────────────── + // Change password flow [ObservableProperty] private bool _isChangingPassword = false; [ObservableProperty] private string _currentPassword = ""; [ObservableProperty] private string _newPassword = ""; [ObservableProperty] private string _confirmNewPassword = ""; - // ── UI state ───────────────────────────────────────────── + // UI state [ObservableProperty] private bool _isSaving = false; [ObservableProperty] private bool _isUploadingAvatar = false; @@ -76,7 +76,7 @@ public partial class SettingsViewModel : ViewModelBase public bool HasPasswordError => !string.IsNullOrEmpty(PasswordErrorMessage); public bool HasAvatar => !string.IsNullOrEmpty(AvatarUrl); - // ── Options ────────────────────────────────────────────── + // Options public ObservableCollection<(string Value, string Label)> Themes { get; } = new() { @@ -118,7 +118,7 @@ public partial class SettingsViewModel : ViewModelBase SelectedLanguage = value switch { 0 => "en", 1 => "ar", _ => "en" }; } - // ── Init ───────────────────────────────────────────────── + // Init public SettingsViewModel() { _ = Initialize(); @@ -155,7 +155,7 @@ public partial class SettingsViewModel : ViewModelBase return $"{visible}{masked}{domain}"; } - // ── Avatar commands ─────────────────────────────────────── + // Avatar commands [RelayCommand] private async Task UploadAvatar() @@ -215,7 +215,7 @@ public partial class SettingsViewModel : ViewModelBase } } - // ── Save profile ───────────────────────────────────────── + // Save profile [RelayCommand] private async Task SaveProfile() @@ -263,7 +263,7 @@ public partial class SettingsViewModel : ViewModelBase } } - // ── Change email ───────────────────────────────────────── + // Change email [RelayCommand] private void StartChangeEmail() @@ -327,7 +327,7 @@ public partial class SettingsViewModel : ViewModelBase } } - // ── Change password ────────────────────────────────────── + // Change password [RelayCommand] private void StartChangePassword() @@ -397,7 +397,7 @@ public partial class SettingsViewModel : ViewModelBase } } - // ── Sign out ───────────────────────────────────────────── + // Sign out [RelayCommand] private async Task SignOut() diff --git a/Clario/ViewModels/TransactionFormViewModel.cs b/Clario/ViewModels/TransactionFormViewModel.cs index 45678c4..423184a 100644 --- a/Clario/ViewModels/TransactionFormViewModel.cs +++ b/Clario/ViewModels/TransactionFormViewModel.cs @@ -17,7 +17,7 @@ public partial class TransactionFormViewModel : ViewModelBase public required ViewModelBase parentViewModel; public GeneralDataRepo AppData => DataRepo.General; - // ── Mode ──────────────────────────────────────────────── + // Mode [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))] private bool _isEditMode = false; @@ -25,7 +25,7 @@ public partial class TransactionFormViewModel : ViewModelBase public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below"; public string SaveButtonLabel => IsEditMode ? "Save Changes" : (IsTransfer ? "Save Transfer" : "Save Transaction"); - // ── Fields ────────────────────────────────────────────── + // Fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome), nameof(IsTransfer), nameof(IsValid), nameof(FormTitle), nameof(SaveButtonLabel))] private string _type = "expense"; @@ -64,7 +64,7 @@ public partial class TransactionFormViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _categories = new(); [ObservableProperty] private ObservableCollection _accounts = new(); - // ── Validation ────────────────────────────────────────── + // Validation [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string? _errorMessage; @@ -80,7 +80,7 @@ public partial class TransactionFormViewModel : ViewModelBase ? SelectedAccount is not null && SelectedToAccount is not null && SelectedAccount.Id != SelectedToAccount.Id : !string.IsNullOrWhiteSpace(Description) && SelectedCategory is not null && SelectedAccount is not null); - // ── Callbacks ─────────────────────────────────────────── + // Callbacks public Action? OnSaved; public Action? OnCancelled; public Action? OnDeleted; @@ -89,17 +89,17 @@ public partial class TransactionFormViewModel : ViewModelBase [ObservableProperty] private bool _showDeleteConfirm = false; - // ── Edit mode: original transaction ───────────────────── + // Edit mode: original transaction private Transaction? _editingTransaction; private Guid? _editingId; private Guid? _transferPairId; private decimal _editingOriginalAmount; private Guid? _editingOriginalCategoryId; - // ── Result transaction ────────────────────────────────── + // Result transaction public Transaction? ResultTransaction { get; set; } - // ── Budget warning ────────────────────────────────────── + // Budget warning [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasBudgetWarning), nameof(HasBudgetApproachingWarning))] private string? _budgetWarningMessage; @@ -109,7 +109,7 @@ public partial class TransactionFormViewModel : ViewModelBase public bool HasBudgetWarning => !string.IsNullOrEmpty(BudgetWarningMessage); public bool HasBudgetApproachingWarning => HasBudgetWarning && !BudgetWarningIsOverBudget; - // ── Commands ──────────────────────────────────────────── + // Commands partial void OnSelectedCategoryChanged(Category? value) { @@ -414,7 +414,7 @@ public partial class TransactionFormViewModel : ViewModelBase OnCancelled?.Invoke(); } - // ── Public setup methods ───────────────────────────────── + // Public setup methods /// Call this to open the form for adding a new transaction. public void SetupForAdd() diff --git a/Clario/ViewModels/TransactionsViewModel.cs b/Clario/ViewModels/TransactionsViewModel.cs index 0dc8656..46789dd 100644 --- a/Clario/ViewModels/TransactionsViewModel.cs +++ b/Clario/ViewModels/TransactionsViewModel.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Globalization; using System.Linq; using Clario.Data; using Clario.Messages; @@ -11,60 +10,98 @@ 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; + private GeneralDataRepo AppData => DataRepo.General; + + // ── Filter dropdowns ──────────────────────────────────────────────────── [ObservableProperty] private ObservableCollection _categories = new(); [ObservableProperty] private ObservableCollection _accounts = new(); - [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))] + private static readonly IReadOnlyList _sortOptions = new[] + { + "Date — Newest first", "Date — Oldest first", + "Amount — High to low", "Amount — Low to high", + "Category A → Z" + }; + + private static readonly IReadOnlyList _dateRangeOptions = new[] + { + "All Time", "Today", "This Week", "This Month", + "Last Month", "This Quarter", "This Year", "Custom Range" + }; + + public IReadOnlyList SortOptions => _sortOptions; + public IReadOnlyList DateRangeOptions => _dateRangeOptions; + + // ── Active filter values ───────────────────────────────────────────────── + + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private Category _selectedCategory; + [ObservableProperty] private Account _selectedAccount; + [ObservableProperty] private string _selectedSortOption = _sortOptions[0]; + [ObservableProperty] private string _selectedDateRangeOption = _dateRangeOptions[0]; + + [ObservableProperty] private List? _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 bool FilterTypeAll => TransactionType == "all"; + public bool FilterTypeIncome => TransactionType == "income"; + public bool FilterTypeExpense => TransactionType == "expense"; + public bool FilterTypeTransfer => TransactionType == "transfer"; + + // ── Filtered / paged data ──────────────────────────────────────────────── + + private List _filteredAll = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))] private List _filteredTransactions = new(); public int FilteredTransactionCount => _filteredTransactions.Count; + [ObservableProperty] private ObservableCollection _pagedTransactions = new(); + + // ── Desktop pagination ─────────────────────────────────────────────────── private int _pageSize = 25; [ObservableProperty] private int _pageSizeIndex; - [ObservableProperty] [NotifyPropertyChangedFor(nameof(TotalPages))] [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))] + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TotalPages))] + [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))] private int _currentPage = 1; - [ObservableProperty] private string _paginationSummaryText; - - [ObservableProperty] private ObservableCollection _pagedTransactions = new(); - - [ObservableProperty] private ObservableCollection _sortOptions = new() - { - "Date — Newest first", - "Date — Oldest first", - "Amount — High to low", - "Amount — Low to high", - "Category A → Z" - }; - - [ObservableProperty] private ObservableCollection _DateRangeOptions = new() - { - "All Time", - "Today", - "This Week", - "This Month", - "Last Month", - "This Quarter", - "This Year", - "Custom Range" - }; - - public List PageNumbers { get; set; } + [ObservableProperty] private string _paginationSummaryText = ""; [ObservableProperty] private ObservableCollection _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; + + public int TotalPages => (int)Math.Ceiling(_filteredAll.Count / (double)_pageSize); + public bool HasNoTransactions => _filteredAll.Count == 0; + public bool HasPreviousPage => CurrentPage > 1; + + // HasNextPage differs by platform + public bool HasNextPage => App.IsMobile + ? _mobileDisplayCount < _filteredAll.Count + : CurrentPage < TotalPages; + + // ── Mobile infinite scroll ─────────────────────────────────────────────── + + /// How many real (non-header) items are currently rendered in PagedTransactions. + private int _mobileDisplayCount; + + // ── Summary stats ──────────────────────────────────────────────────────── [ObservableProperty] private double _totalExpenses; [ObservableProperty] private double _totalIncome; @@ -75,261 +112,23 @@ public partial class TransactionsViewModel : ViewModelBase 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? _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"; - + // ── Constructor ────────────────────────────────────────────────────────── public TransactionsViewModel() { - AppData.Transactions.CollectionChanged += (_, _) => + Track(AppData.Transactions, (_, _) => { InitializeCategories(); InitializeAccounts(); - LoadPage(1); - }; - WeakReferenceMessenger.Default.Register(this, (_, _) => LoadPage(CurrentPage)); + Refresh(); + }); + + WeakReferenceMessenger.Default.Register(this, (_, _) => Refresh()); + 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); - } - } + // ── Initialization ─────────────────────────────────────────────────────── public void Initialize() { @@ -337,9 +136,7 @@ public partial class TransactionsViewModel : ViewModelBase { InitializeCategories(); InitializeAccounts(); - CalculateMonthlyFinancials(); - CurrentPage = 1; OnPropertyChanged(nameof(TotalPages)); ResetFilters(); @@ -354,59 +151,274 @@ public partial class TransactionsViewModel : ViewModelBase private void InitializeCategories() { Categories.Clear(); - Categories.Insert(0, new Category() { Name = "All Categories" }); - foreach (var appDataCategory in AppData.Categories) - { - Categories.Add(appDataCategory); - } - + Categories.Insert(0, new Category { Name = "All Categories" }); + foreach (var cat in AppData.Categories) + Categories.Add(cat); 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); - } - + Accounts.Insert(0, new Account { Name = "All Accounts" }); + foreach (var acc in AppData.Accounts) + Accounts.Add(acc); 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); + var now = DateTime.Now; + var monthly = AppData.Transactions + .Where(x => x.Date.Month == now.Month && x.Date.Year == now.Year); + TotalExpenses = monthly.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.ConvertedAmount)); + TotalIncome = monthly.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.ConvertedAmount)); + ExpensesCount = monthly.Count(x => x.Type == "expense"); + IncomeCount = monthly.Count(x => x.Type == "income"); } - public static List GetSurrounding(List list, T item, int count = 5) + // ── Filter pipeline ────────────────────────────────────────────────────── + + [RelayCommand] + private void ApplyFilters() + { + var filteringByAccount = SelectedAccount?.Name != "All Accounts"; + + // 1. Search + transfer-in visibility + IEnumerable source = AppData.Transactions.Where(x => + (filteringByAccount || x.Type != "transfer_in") && + (x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (x.Note?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false))); + + // 2. Date range + source = ApplyDateFilter(source, out var label); + DateRangeLabel = label; + + // 3. Totals use the date-scoped set (before category/type filters) + CalculateTotalsFromSource(source); + + // 4. Remaining filters + source = ApplyCategoryFilter(source); + source = ApplyAccountFilter(source); + source = ApplyTypeFilter(source); + source = ApplySortFilter(source); + + _filteredAll = source.ToList(); + FilteredTransactions = _filteredAll; + } + + private IEnumerable ApplyDateFilter(IEnumerable source, out string label) + { + var (start, end, lbl) = DateRangeService.Resolve(SelectedDateRangeOption, SelectedDates); + label = lbl; + if (start is null || end is null) return source; + return source.Where(x => x.Date.Date >= start.Value && x.Date.Date <= end.Value); + } + + private void CalculateTotalsFromSource(IEnumerable source) + { + var list = source.ToList(); + TotalExpenses = list.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.ConvertedAmount)); + TotalIncome = list.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.ConvertedAmount)); + } + + private IEnumerable ApplyCategoryFilter(IEnumerable source) + { + if (SelectedCategory?.Name == "All Categories") return source; + return source.Where(x => x.CategoryId == SelectedCategory?.Id); + } + + private IEnumerable ApplyAccountFilter(IEnumerable source) + { + if (SelectedAccount?.Name == "All Accounts") return source; + return source.Where(x => x.AccountId == SelectedAccount?.Id); + } + + private IEnumerable ApplyTypeFilter(IEnumerable source) => + TransactionType switch + { + "income" => source.Where(x => x.Type == "income"), + "expense" => source.Where(x => x.Type == "expense"), + "transfer" => source.Where(x => x.IsTransfer), + _ => source + }; + + private IEnumerable ApplySortFilter(IEnumerable source) => + SelectedSortOption switch + { + "Date — Oldest first" => source.OrderBy(x => x.Date), + "Amount — High to low" => source.OrderByDescending(x => x.Amount), + "Amount — Low to high" => source.OrderBy(x => x.Amount), + "Category A → Z" => source.OrderBy(x => x.Category?.Name), + _ => source.OrderByDescending(x => x.Date) // default: newest first + }; + + // ── Desktop pagination ─────────────────────────────────────────────────── + + partial void OnPageSizeIndexChanged(int value) + { + _pageSize = value switch { 1 => 50, 2 => 100, _ => 25 }; + LoadPage(1); + OnPropertyChanged(nameof(HasNextPage)); + OnPropertyChanged(nameof(HasPreviousPage)); + } + + partial void OnCurrentPageChanged(int value) + { + if (App.IsMobile) return; + 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 = _filteredAll.Skip((page - 1) * _pageSize).Take(_pageSize).ToList(); + + PagedTransactions.Clear(); + foreach (var item in items) PagedTransactions.Add(item); + + OnPropertyChanged(nameof(HasNoTransactions)); + OnPropertyChanged(nameof(HasNextPage)); + OnPropertyChanged(nameof(HasPreviousPage)); + + PaginationSummaryText = _filteredAll.Count == 0 + ? "No transactions" + : $"Showing {(page - 1) * _pageSize + 1}–{Math.Min(page * _pageSize, _filteredAll.Count)} of {_filteredAll.Count}"; + + var allPages = Enumerable.Range(1, Math.Max(TotalPages, 1)).ToList(); + VisiblePageNumbers.Clear(); + foreach (var n in GetSurrounding(allPages, page)) VisiblePageNumbers.Add(n); + + WeakReferenceMessenger.Default.Send(new TransactionsScrollToTop()); + GroupTransactions(); + } + + [RelayCommand(CanExecute = nameof(HasNextPage))] + private void NextPage() { if (CurrentPage < TotalPages) CurrentPage++; } + + [RelayCommand(CanExecute = nameof(HasPreviousPage))] + private void PreviousPage() { if (CurrentPage > 1) CurrentPage--; } + + // ── Mobile infinite scroll ─────────────────────────────────────────────── + + private void RefreshMobile() + { + _mobileDisplayCount = 0; + PagedTransactions.Clear(); + AppendMobileItems(_pageSize * 3); + OnPropertyChanged(nameof(HasNoTransactions)); + OnPropertyChanged(nameof(HasNextPage)); + } + + private void AppendMobileItems(int count) + { + var batch = _filteredAll.Skip(_mobileDisplayCount).Take(count).ToList(); + + foreach (var item in batch) + { + var needsHeader = _mobileDisplayCount == 0 + || item.Date.Date != _filteredAll[_mobileDisplayCount - 1].Date.Date; + + if (needsHeader) + { + PagedTransactions.Add(new Transaction + { + Description = DateRangeService.FormatGroupHeader(item.Date), + Date = item.Date, + GroupHeader = true + }); + } + + PagedTransactions.Add(item); + _mobileDisplayCount++; + } + + OnPropertyChanged(nameof(HasNextPage)); + } + + /// Adds 3 pages of items at once. Shown behind "Load More" button. + [RelayCommand(CanExecute = nameof(HasNextPage))] + private void LoadMore() + { + if (_mobileDisplayCount >= _filteredAll.Count) return; + AppendMobileItems(_pageSize * 3); + } + + // ── Shared helpers ─────────────────────────────────────────────────────── + + private void Refresh() + { + CalculateMonthlyFinancials(); + if (App.IsMobile) { ApplyFilters(); RefreshMobile(); } + else LoadPage(CurrentPage); + } + + [RelayCommand] + private void ResetFilters() + { + SearchText = ""; + SelectedCategory = Categories.FirstOrDefault() ?? new Category { Name = "All Categories" }; + SelectedAccount = Accounts.FirstOrDefault() ?? new Account { Name = "All Accounts" }; + TransactionType = "all"; + SelectedSortOption = SortOptions[0]; + SelectedDateRangeOption = DateRangeOptions[0]; + + if (App.IsMobile) { ApplyFilters(); RefreshMobile(); } + else LoadPage(1); + } + + [RelayCommand] + private void SetTransactionType(string type) => TransactionType = type; + + /// Desktop: inserts date group headers into PagedTransactions. + private void GroupTransactions() + { + // Remove all existing headers + foreach (var h in PagedTransactions.Where(x => x.GroupHeader).ToList()) + PagedTransactions.Remove(h); + + // Insert a header before the first item of each date group + var dates = PagedTransactions.Select(x => x.Date.Date).Distinct().ToList(); + foreach (var date in dates) + { + var firstItem = PagedTransactions.FirstOrDefault(x => !x.GroupHeader && x.Date.Date == date); + if (firstItem is null) continue; + PagedTransactions.Insert(PagedTransactions.IndexOf(firstItem), new Transaction + { + Description = DateRangeService.FormatGroupHeader(date), + Date = date, + GroupHeader = true + }); + } + } + + private static List GetSurrounding(List list, T item, int count = 5) { var index = list.IndexOf(item); if (index == -1) return new List(); - - 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); + var start = Math.Max(0, Math.Min(index - count / 2, list.Count - count)); + return list.GetRange(start, Math.Min(count, list.Count - start)); } + // ── Navigation ─────────────────────────────────────────────────────────── + [RelayCommand] private void CreateTransaction() { - ((MainViewModel)parentViewModel).OpenAddTransaction(); + if (parentViewModel is MainViewModel main) main.OpenAddTransaction(); } [RelayCommand] private void EditTransaction(Transaction transaction) { - ((MainViewModel)parentViewModel).OpenEditTransaction(transaction); + if (parentViewModel is MainViewModel main) main.OpenEditTransaction(transaction); } -} \ No newline at end of file +} diff --git a/Clario/ViewModels/ViewModelBase.cs b/Clario/ViewModels/ViewModelBase.cs index 5d39b51..7216b55 100644 --- a/Clario/ViewModels/ViewModelBase.cs +++ b/Clario/ViewModels/ViewModelBase.cs @@ -1,7 +1,36 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Specialized; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; namespace Clario.ViewModels; -public abstract class ViewModelBase : ObservableObject +public abstract class ViewModelBase : ObservableObject, IDisposable { -} \ No newline at end of file + private readonly System.Collections.Generic.List _cleanup = new(); + + /// + /// Subscribes to a CollectionChanged event and registers automatic unsubscription on Dispose. + /// + protected void Track(INotifyCollectionChanged collection, NotifyCollectionChangedEventHandler handler) + { + collection.CollectionChanged += handler; + _cleanup.Add(() => collection.CollectionChanged -= handler); + } + + /// + /// Registers an arbitrary cleanup action to run on Dispose. + /// + protected void OnDispose(Action action) => _cleanup.Add(action); + + protected virtual void DisposeManaged() { } + + public void Dispose() + { + DisposeManaged(); + foreach (var action in _cleanup) action(); + _cleanup.Clear(); + WeakReferenceMessenger.Default.UnregisterAll(this); + GC.SuppressFinalize(this); + } +} diff --git a/Clario/Views/AccountFormView.axaml b/Clario/Views/AccountFormView.axaml index f925e34..91d8a0c 100644 --- a/Clario/Views/AccountFormView.axaml +++ b/Clario/Views/AccountFormView.axaml @@ -13,11 +13,11 @@ - + - + - + - + - + - + @@ -122,7 +122,7 @@ - + @@ -204,7 +204,7 @@ - + - + - + @@ -304,7 +304,7 @@ - + @@ -320,7 +320,7 @@ VerticalAlignment="Center" /> - + - + - + - + @@ -179,7 +179,7 @@ - + @@ -263,7 +263,7 @@ - + @@ -308,7 +308,7 @@ - + @@ -375,7 +375,7 @@ - + @@ -410,7 +410,7 @@ - + diff --git a/Clario/Views/AnalyticsView.axaml b/Clario/Views/AnalyticsView.axaml index 7fb740a..3f1ba3a 100644 --- a/Clario/Views/AnalyticsView.axaml +++ b/Clario/Views/AnalyticsView.axaml @@ -15,7 +15,7 @@ - + @@ -45,7 +45,7 @@ - + @@ -60,7 +60,7 @@ Foreground="{DynamicResource AccentGreen}" FontSize="12" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/DashboardSkeletonView.axaml.cs b/Clario/Views/DashboardSkeletonView.axaml.cs new file mode 100644 index 0000000..0e854b7 --- /dev/null +++ b/Clario/Views/DashboardSkeletonView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.Views; + +public partial class DashboardSkeletonView : UserControl +{ + public DashboardSkeletonView() + { + InitializeComponent(); + } +} diff --git a/Clario/Views/DashboardView.axaml b/Clario/Views/DashboardView.axaml index e5363ff..2020d38 100644 --- a/Clario/Views/DashboardView.axaml +++ b/Clario/Views/DashboardView.axaml @@ -16,7 +16,7 @@ - + @@ -30,7 +30,7 @@ Cursor="Hand" Content="+ Add Transaction" Command="{Binding CreateTransactionCommand}" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Clario/Views/ResetPasswordView.axaml.cs b/Clario/Views/ResetPasswordView.axaml.cs new file mode 100644 index 0000000..512e3a0 --- /dev/null +++ b/Clario/Views/ResetPasswordView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Clario.Views; + +public partial class ResetPasswordView : UserControl +{ + public ResetPasswordView() + { + InitializeComponent(); + } +} diff --git a/Clario/Views/SettingsView.axaml b/Clario/Views/SettingsView.axaml index f7143a2..dc91db7 100644 --- a/Clario/Views/SettingsView.axaml +++ b/Clario/Views/SettingsView.axaml @@ -17,7 +17,7 @@ Spacing="0" MaxWidth="720"> - + - + - + --> @@ -223,9 +223,9 @@ - + --> @@ -238,7 +238,7 @@ Margin="0,0,0,24"> - + @@ -350,7 +350,7 @@ - + @@ -473,9 +473,9 @@ - + --> diff --git a/Clario/Views/TransactionFormView.axaml b/Clario/Views/TransactionFormView.axaml index 6ab6936..2a30003 100644 --- a/Clario/Views/TransactionFormView.axaml +++ b/Clario/Views/TransactionFormView.axaml @@ -12,11 +12,11 @@ - + - + - + - + - + - + - + @@ -282,7 +282,7 @@ - + @@ -366,7 +366,7 @@ - + - + - + - + - + - + - + - + diff --git a/Clario/Views/TransactionsView.axaml b/Clario/Views/TransactionsView.axaml index 28864ec..5cef766 100644 --- a/Clario/Views/TransactionsView.axaml +++ b/Clario/Views/TransactionsView.axaml @@ -14,9 +14,7 @@ - + - - @@ -145,7 +143,7 @@ - @@ -165,7 +163,7 @@ Command="{Binding ResetFiltersCommand}" /> - - @@ -276,7 +274,7 @@ Margin="0,0,0,14"> - @@ -319,9 +317,9 @@ - + --> @@ -352,7 +350,7 @@ FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource BgBase}" - VerticalAlignment="Center"/> + VerticalAlignment="Center" /> @@ -488,7 +486,7 @@ + Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}" /> -