Added Budgets Create/Update/Delete, Account Create/Update/Delete, and fully added settings tab, and refactored a lot of the data logic

This commit is contained in:
2026-04-01 21:34:36 +03:00
parent 52e6ca4f62
commit 1dce7d64a2
317 changed files with 6380 additions and 719 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ obj/
.idea/ .idea/
*.user *.user
*.suo *.suo
./Clario/CLAUDE_CONTEXT.md

View File

@@ -15,10 +15,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Android"/> <PackageReference Include="Avalonia.Android"/>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia" /> <PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Deadpikle.AvaloniaProgressRing" /> <PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" /> <PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" /> <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<PackageReference Include="Supabase" /> <PackageReference Include="Supabase" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen"/> <PackageReference Include="Xamarin.AndroidX.Core.SplashScreen"/>

View File

@@ -8,10 +8,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser"/> <PackageReference Include="Avalonia.Browser"/>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia"/> <PackageReference Include="Avalonia.Svg.Skia"/>
<PackageReference Include="Deadpikle.AvaloniaProgressRing"/> <PackageReference Include="Deadpikle.AvaloniaProgressRing"/>
<PackageReference Include="FluentAvalonia.ProgressRing"/> <PackageReference Include="FluentAvalonia.ProgressRing"/>
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia"/> <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia"/>
<PackageReference Include="SkiaSharp"/>
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly"/> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly"/>
<PackageReference Include="Supabase"/> <PackageReference Include="Supabase"/>
</ItemGroup> </ItemGroup>

View File

@@ -3,6 +3,7 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
@@ -10,6 +11,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Desktop"/> <PackageReference Include="Avalonia.Desktop"/>
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics"> <PackageReference Include="Avalonia.Diagnostics">
@@ -20,6 +22,7 @@
<PackageReference Include="Deadpikle.AvaloniaProgressRing" /> <PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" /> <PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" /> <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<PackageReference Include="Supabase" /> <PackageReference Include="Supabase" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,36 @@
{
"GeneralSettings": {
"NetProjectPath": "Clario.Desktop.csproj",
"ApplicationName": "Clario",
"Version": "0.3.0",
"PackageName": {
"$type": "msbuild",
"property": "AssemblyName"
},
"AssemblyName": {
"$type": "msbuild",
"property": "AssemblyName"
}
},
"LinuxSettings": {
"AppIcon": "../Clario/Assets/Logo.png",
"CreateBinSymlink": "True"
},
"Win32Settings": {
"InstallerIcon": "../Clario/Assets/Clario-Logo.svg",
"Company": "Clario",
"IncludeUninstaller": "True"
},
"MacOsSettings": {
"CreateBundle": true,
"BundleIdentifier": "com.CompanyName.Clario-Desktop",
"SigningCredentialsType": "AdHoc"
},
"PublishSettings": {
"PublishSingleFile": "True",
"PublishReadyToRun": "True",
"ExtraBuildProperties": {
"RuntimeFrameworkVersion": "8.0.11"
}
}
}

View File

@@ -7,11 +7,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.iOS"/> <PackageReference Include="Avalonia.iOS"/>
<PackageReference Include="Avalonia.Svg.Skia" /> <PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Deadpikle.AvaloniaProgressRing" /> <PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" /> <PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" /> <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<PackageReference Include="Supabase" /> <PackageReference Include="Supabase" />
</ItemGroup> </ItemGroup>

View File

@@ -28,10 +28,13 @@
<converters:DecimalColorConverter x:Key="DecimalColorConverter" /> <converters:DecimalColorConverter x:Key="DecimalColorConverter" />
<converters:BoolToColorConverter x:Key="BoolToColorConverter" /> <converters:BoolToColorConverter x:Key="BoolToColorConverter" />
<converters:BoolToCssConverter x:Key="BoolToCssConverter" /> <converters:BoolToCssConverter x:Key="BoolToCssConverter" />
<converters:CreditAmountConverter x:Key="CreditAmountConverter"/>
<converters:BoolToStringConverter x:Key="BoolToStringConverter"/>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="../Theme/AppTheme.axaml" /> <StyleInclude Source="../Theme/AppTheme.axaml" />
<StyleInclude Source="avares://AvaloniaProgressRing/Styles/ProgressRing.xaml"/> <StyleInclude Source="avares://AvaloniaProgressRing/Styles/ProgressRing.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@@ -60,8 +60,9 @@ public partial class App : Application
{ {
await SupabaseService.Client.Auth.RetrieveSessionAsync(); await SupabaseService.Client.Auth.RetrieveSessionAsync();
} }
catch catch (Exception e)
{ {
Console.WriteLine($"[Auth] RetrieveSession failed: {e.Message}");
} }
var user = SupabaseService.Client.Auth.CurrentUser; var user = SupabaseService.Client.Auth.CurrentUser;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-log-out-icon lucide-log-out"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-upload-icon lucide-upload"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wallet-cards-icon lucide-wallet-cards"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2"/><path d="M3 11h3c.8 0 1.6.3 2.1.9l1.1.9c1.6 1.6 4.1 1.6 5.7 0l1.1-.9c.5-.5 1.3-.9 2.1-.9H21"/></svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia"/> <PackageReference Include="Avalonia"/>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia" /> <PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Avalonia.Themes.Fluent"/> <PackageReference Include="Avalonia.Themes.Fluent"/>
<PackageReference Include="Avalonia.Fonts.Inter"/> <PackageReference Include="Avalonia.Fonts.Inter"/>
@@ -30,6 +31,7 @@
<PackageReference Include="Xaml.Behaviors.Interactions" /> <PackageReference Include="Xaml.Behaviors.Interactions" />
<PackageReference Include="Xaml.Behaviors.Interactivity" /> <PackageReference Include="Xaml.Behaviors.Interactivity" />
<PackageReference Include="SkiaSharp" /> <PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" />
</ItemGroup> </ItemGroup>
@@ -38,5 +40,8 @@
<DependentUpon>MobileMainView.axaml</DependentUpon> <DependentUpon>MobileMainView.axaml</DependentUpon>
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
<Compile Update="Views\AccountFormView.axaml.cs">
<DependentUpon>AccountFormView.axaml</DependentUpon>
</Compile>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,20 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace Clario.Converters;
public class BoolToStringConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not bool b || parameter is not string s) return null;
var results = s.Split('|');
return b ? results[0] : results[1];
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace Clario.Converters;
public class CreditAmountConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not decimal amount) return 0;
return amount < 0 ? amount * -1 : 0;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using Avalonia.Controls.Converters;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Media; using Avalonia.Media;
@@ -21,6 +22,18 @@ public class HexToColorConverter : IValueConverter
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
throw new NotImplementedException(); if (parameter is not string type) return null;
var color = Color.Parse("#ffffff");
if (value is Color c)
{
color = c;
}
if (value is SolidColorBrush b)
{
color = b.Color;
}
return $"#{color.R:X2}{color.G:X2}{color.B:X2}";
} }
} }

View File

@@ -2,6 +2,76 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Clario.CustomControls"> xmlns:local="clr-namespace:Clario.CustomControls">
<Styles.Resources>
<!--
Full ControlTheme replacement for CalendarButton.
This is necessary because in Avalonia 11 the Fluent theme owns
the internal template parts at ControlTheme priority, making
external /template/ style selectors unreliable for CalendarButton.
Replacing the entire ControlTheme is the only reliable approach.
-->
<ControlTheme x:Key="{x:Type CalendarButton}" TargetType="CalendarButton">
<Setter Property="MinWidth" Value="40" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="Root"
Background="{TemplateBinding Background}"
CornerRadius="6"
Padding="4">
<ContentControl x:Name="Content"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}"
FontSize="{TemplateBinding FontSize}"
FontWeight="{TemplateBinding FontWeight}" />
</Border>
</ControlTemplate>
</Setter>
<!-- Hover -->
<Style Selector="^:pointerover /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
</Style>
<Style Selector="^:pointerover /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
<!-- Pressed -->
<Style Selector="^:pressed /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
</Style>
<!-- Selected (current month/year) -->
<Style Selector="^:selected /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderAccent}" />
</Style>
<Style Selector="^:selected /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
<!-- Selected + hover -->
<Style Selector="^:selected:pointerover /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderAccent}" />
</Style>
<!-- Inactive (out-of-range months/years) -->
<Style Selector="^:inactive /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextMuted}" />
</Style>
</ControlTheme>
</Styles.Resources>
<!-- ============================================================ -->
<!-- DateRangePicker control template -->
<!-- ============================================================ -->
<Style Selector="local|DateRangePicker"> <Style Selector="local|DateRangePicker">
<Setter Property="MinHeight" Value="15" /> <Setter Property="MinHeight" Value="15" />
<Setter Property="MinWidth" Value="50" /> <Setter Property="MinWidth" Value="50" />
@@ -14,8 +84,6 @@
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<Grid> <Grid>
<!-- Trigger button -->
<Button x:Name="PART_Button" <Button x:Name="PART_Button"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Background="{TemplateBinding Background}" Background="{TemplateBinding Background}"
@@ -48,7 +116,6 @@
</Grid> </Grid>
</Button> </Button>
<!-- Popup -->
<Popup x:Name="PART_Popup" <Popup x:Name="PART_Popup"
PlacementTarget="{Binding #PART_Button}" PlacementTarget="{Binding #PART_Button}"
Placement="Bottom" Placement="Bottom"
@@ -65,21 +132,50 @@
BorderThickness="0" /> BorderThickness="0" />
</Border> </Border>
</Popup> </Popup>
</Grid> </Grid>
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
</Style> </Style>
<!-- pointerover -->
<Style Selector="local|DateRangePicker:pointerover /template/ Button#PART_Button"> <Style Selector="local|DateRangePicker:pointerover /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BgHover}" /> <Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}" /> <Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}" />
</Style> </Style>
<!-- pressed -->
<Style Selector="local|DateRangePicker:pressed /template/ Button#PART_Button"> <Style Selector="local|DateRangePicker:pressed /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" /> <Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
</Style> </Style>
<!-- ============================================================ -->
<!-- CalendarItem: nav header buttons (prev / title / next) -->
<!-- ============================================================ -->
<Style Selector="CalendarItem /template/ Button#PART_HeaderButton">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style Selector="CalendarItem /template/ Button#PART_HeaderButton:pointerover">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
</Style>
<Style Selector="CalendarItem /template/ Button#PART_PreviousButton">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}" />
</Style>
<Style Selector="CalendarItem /template/ Button#PART_PreviousButton:pointerover">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
<Style Selector="CalendarItem /template/ Button#PART_NextButton">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}" />
</Style>
<Style Selector="CalendarItem /template/ Button#PART_NextButton:pointerover">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
</Styles> </Styles>

View File

@@ -5,8 +5,10 @@ using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree;
using Calendar = Avalonia.Controls.Calendar; using Calendar = Avalonia.Controls.Calendar;
namespace Clario.CustomControls; namespace Clario.CustomControls;
@@ -23,19 +25,28 @@ public class DateRangePicker : TemplatedControl
set => SetValue(SelectionModeProperty, value); set => SetValue(SelectionModeProperty, value);
} }
public static readonly StyledProperty<IList<DateTime>> SelectedDatesProperty = // FIX: Use DirectProperty to avoid shared-instance default and get proper TwoWay support
AvaloniaProperty.Register<DateRangePicker, IList<DateTime>>( private IList<DateTime> _selectedDates = new List<DateTime>();
nameof(SelectedDates), new List<DateTime>());
public static readonly DirectProperty<DateRangePicker, IList<DateTime>> SelectedDatesProperty =
AvaloniaProperty.RegisterDirect<DateRangePicker, IList<DateTime>>(
nameof(SelectedDates),
o => o.SelectedDates,
(o, v) => o.SelectedDates = v,
defaultBindingMode: BindingMode.TwoWay);
public IList<DateTime> SelectedDates public IList<DateTime> SelectedDates
{ {
get => GetValue(SelectedDatesProperty); get => _selectedDates;
set => SetValue(SelectedDatesProperty, value); set => SetAndRaise(SelectedDatesProperty, ref _selectedDates, value);
} }
// FIX: Add defaultBindingMode: TwoWay so changes propagate back to the ViewModel
public static readonly StyledProperty<DateTime?> SelectedDateProperty = public static readonly StyledProperty<DateTime?> SelectedDateProperty =
AvaloniaProperty.Register<DateRangePicker, DateTime?>( AvaloniaProperty.Register<DateRangePicker, DateTime?>(
nameof(SelectedDate), null); nameof(SelectedDate),
defaultValue: null,
defaultBindingMode: BindingMode.TwoWay);
public DateTime? SelectedDate public DateTime? SelectedDate
{ {
@@ -58,7 +69,6 @@ public class DateRangePicker : TemplatedControl
private Popup? _popup; private Popup? _popup;
private Calendar? _calendar; private Calendar? _calendar;
private bool _isSyncing = false; private bool _isSyncing = false;
@@ -66,12 +76,12 @@ public class DateRangePicker : TemplatedControl
{ {
base.OnApplyTemplate(e); base.OnApplyTemplate(e);
if (_button != null) _button.Click -= OnButtonClick; if (_button != null) _button.Click -= OnButtonClick;
if (_calendar != null) if (_calendar != null)
{ {
_calendar.SelectedDatesChanged -= OnCalendarDatesChanged; _calendar.SelectedDatesChanged -= OnCalendarDatesChanged;
_calendar.RemoveHandler(PointerReleasedEvent, OnCalendarPointerReleased); _calendar.RemoveHandler(PointerReleasedEvent, OnCalendarPointerReleased);
// _calendar.RemoveHandler(Button.ClickEvent, OnCalendarInternalClick); // add this
} }
_button = e.NameScope.Find<Button>("PART_Button"); _button = e.NameScope.Find<Button>("PART_Button");
@@ -85,10 +95,9 @@ public class DateRangePicker : TemplatedControl
{ {
_calendar.AllowTapRangeSelection = true; _calendar.AllowTapRangeSelection = true;
_calendar.SelectedDatesChanged += OnCalendarDatesChanged; _calendar.SelectedDatesChanged += OnCalendarDatesChanged;
_calendar.AddHandler(PointerReleasedEvent, OnCalendarPointerReleased, RoutingStrategies.Tunnel); _calendar.AddHandler(PointerReleasedEvent, OnCalendarPointerReleased, RoutingStrategies.Tunnel);
// _calendar.AddHandler(Button.ClickEvent, OnCalendarInternalClick, RoutingStrategies.Tunnel);
SyncToCalendar(); SyncToCalendar();
} }
@@ -96,27 +105,32 @@ public class DateRangePicker : TemplatedControl
UpdateDisplayText(); UpdateDisplayText();
} }
private void OnCalendarInternalClick(object? sender, RoutedEventArgs e)
{
e.Handled = true;
}
private void OnCalendarPointerReleased(object? sender, PointerReleasedEventArgs e) private void OnCalendarPointerReleased(object? sender, PointerReleasedEventArgs e)
{ {
if (_calendar!.SelectionMode != CalendarSelectionMode.SingleDate) return; if (_calendar!.SelectionMode != CalendarSelectionMode.SingleDate) return;
if (_isSyncing) return; if (_isSyncing) return;
if (_popup is null || !_popup.IsOpen) return; if (_popup is null || !_popup.IsOpen) return;
// FIX: Ignore clicks on the nav buttons/header — only react to day cell clicks
if (e.Source is not Control source) return;
if (source.TemplatedParent is CalendarDayButton == false &&
source.FindAncestorOfType<CalendarDayButton>() is null)
return;
var newDates = _calendar!.SelectedDates.OrderBy(d => d).ToList(); var newDates = _calendar!.SelectedDates.OrderBy(d => d).ToList();
_isSyncing = true; _isSyncing = true;
try try
{ {
SelectedDates = newDates; SelectedDates = newDates;
SelectedDate = newDates.Count > 0 ? newDates[0] : null; SelectedDate = newDates.Count > 0 ? newDates[0] : null;
UpdateDisplayText(); UpdateDisplayText();
bool shouldClose = SelectionMode switch bool shouldClose = SelectionMode switch
{ {
CalendarSelectionMode.SingleDate => newDates.Count >= 1, CalendarSelectionMode.SingleDate => newDates.Count >= 1,
@@ -133,12 +147,10 @@ public class DateRangePicker : TemplatedControl
} }
} }
private void OnButtonClick(object? sender, RoutedEventArgs e) private void OnButtonClick(object? sender, RoutedEventArgs e)
{ {
if (_popup is null) return; if (_popup is null) return;
SyncToCalendar(); SyncToCalendar();
_popup.IsOpen = true; _popup.IsOpen = true;
} }
@@ -157,13 +169,10 @@ public class DateRangePicker : TemplatedControl
try try
{ {
SelectedDates = newDates; SelectedDates = newDates;
SelectedDate = newDates.Count > 0 ? newDates[0] : null; SelectedDate = newDates.Count > 0 ? newDates[0] : null;
UpdateDisplayText(); UpdateDisplayText();
bool shouldClose = SelectionMode switch bool shouldClose = SelectionMode switch
{ {
CalendarSelectionMode.SingleDate => newDates.Count >= 1, CalendarSelectionMode.SingleDate => newDates.Count >= 1,
@@ -180,7 +189,6 @@ public class DateRangePicker : TemplatedControl
} }
} }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
@@ -220,7 +228,6 @@ public class DateRangePicker : TemplatedControl
} }
} }
private void SyncToCalendar() private void SyncToCalendar()
{ {
if (_calendar is null || _isSyncing) return; if (_calendar is null || _isSyncing) return;

View File

@@ -1,49 +1,71 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media.Imaging;
using Clario.Models; using Clario.Models;
using Clario.Models.GeneralModels; using Clario.Models.GeneralModels;
using Clario.Services; using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Supabase.Postgrest;
using FileOptions = Supabase.Storage.FileOptions;
namespace Clario.Data; namespace Clario.Data;
public class GeneralDataRepo public record ProfileUpdated();
{
public Profile? Profile { get; set; }
public List<Category>? Categories { get; set; }
public List<Account>? Accounts { get; set; }
public List<Budget>? Budgets { get; set; }
public List<Transaction>? Transactions { get; set; }
public async Task<Profile?> FetchProfileInfo() public partial class GeneralDataRepo : ObservableObject
{ {
if (Profile is not null) return Profile; [ObservableProperty] private Profile? _profile;
[ObservableProperty] private ObservableCollection<Category> _categories = new();
[ObservableProperty] private ObservableCollection<Account> _accounts = new();
[ObservableProperty] private ObservableCollection<Budget> _budgets = new();
[ObservableProperty] private ObservableCollection<Transaction> _transactions = new();
private static readonly HttpClient _HttpClient = new();
private const string Bucket = "avatars";
private const string ProjectRef = "xzxstbllaivumhtpctmo";
private const string PublicBaseUrl = $"https://{ProjectRef}.supabase.co/storage/v1/object/public/{Bucket}";
partial void OnProfileChanged(Profile? value)
{
_ = GetAvatarFromUrl(value?.AvatarUrl);
}
public async Task<Profile?> FetchProfileInfo(bool forceRefresh = false)
{
if (Profile is not null && !forceRefresh) return Profile;
var profile = await SupabaseService.Client.From<Profile>().Get(); var profile = await SupabaseService.Client.From<Profile>().Get();
if (profile.Models.Count == 0) return null;
Profile = profile.Model; Profile = profile.Model;
return profile.Model;
return Profile;
} }
public async Task InsertProfileInfo(Profile profile) private async Task GetAvatarFromUrl(string? url)
{ {
try if (!string.IsNullOrEmpty(url))
{ {
await SupabaseService.Client.From<Profile>().Insert(profile); var bytes = await _HttpClient.GetByteArrayAsync(url);
} var stream = new MemoryStream(bytes);
catch (Exception e) Profile.Avatar = new Bitmap(stream);
{
Console.WriteLine(e);
return;
} }
Profile = profile; WeakReferenceMessenger.Default.Send(new ProfileUpdated());
} }
public async Task<List<Transaction>> FetchTransactions()
public async Task<List<Transaction>> FetchTransactions(bool forceRefresh = false)
{ {
if (Transactions is not null) return Transactions; if (Transactions.Count != 0 && !forceRefresh) return Transactions.ToList();
var transactions = await SupabaseService.Client.From<Transaction>().Get(); var transactions = await SupabaseService.Client.From<Transaction>().Get();
Transactions = transactions.Models; Transactions = new ObservableCollection<Transaction>(transactions.Models);
return transactions.Models; return transactions.Models;
} }
@@ -51,11 +73,18 @@ public class GeneralDataRepo
{ {
try try
{ {
await SupabaseService.Client.From<Transaction>().Insert(transaction); var result = await SupabaseService.Client.From<Transaction>().Insert(transaction);
if (result.Models.Count >= 1)
{
var resultItem = LinkTransactionCategories(result.Models[0]);
Transactions.Add(resultItem);
}
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine(e); Console.WriteLine(e);
return;
} }
} }
@@ -63,7 +92,13 @@ public class GeneralDataRepo
{ {
try try
{ {
await SupabaseService.Client.From<Transaction>().Update(transaction); var result = await SupabaseService.Client.From<Transaction>().Update(transaction);
if (result.Model is null) return;
var item = Transactions.FirstOrDefault(x => x.Id == result.Model.Id);
if (item is null) return;
var index = Transactions.IndexOf(item);
if (index != -1) Transactions[index] = LinkTransactionCategories(result.Model);
} }
catch (Exception e) catch (Exception e)
{ {
@@ -76,6 +111,9 @@ public class GeneralDataRepo
try try
{ {
await SupabaseService.Client.From<Transaction>().Where(x => x.Id == id).Delete(); await SupabaseService.Client.From<Transaction>().Where(x => x.Id == id).Delete();
var item = Transactions.FirstOrDefault(x => x.Id == id);
if (item is null) return;
Transactions.Remove(item);
} }
catch (Exception e) catch (Exception e)
{ {
@@ -84,60 +122,62 @@ public class GeneralDataRepo
} }
} }
public async Task<List<Category>> FetchCategories() public async Task<List<Category>> FetchCategories(bool forceRefresh = false)
{ {
if (Categories is not null) return Categories; if (Categories.Count != 0 && !forceRefresh) return Categories.ToList();
var categories = await SupabaseService.Client.From<Category>().Get(); var categories = await SupabaseService.Client.From<Category>().Get();
Categories = categories.Models; Categories = new ObservableCollection<Category>(categories.Models);
return categories.Models; return categories.Models;
} }
public async Task<List<Account>> FetchAccounts() public async Task<List<Account>> FetchAccounts(bool forceRefresh = false)
{ {
if (Accounts is not null) return Accounts; if (Accounts.Count != 0 && !forceRefresh) return Accounts.ToList();
var accounts = await SupabaseService.Client.From<Account>().Get(); var accounts = await SupabaseService.Client.From<Account>().Get();
Accounts = accounts.Models; Accounts = new ObservableCollection<Account>(accounts.Models);
return accounts.Models; return accounts.Models;
} }
public async Task<List<Budget>> FetchBudgets() public async Task<List<Budget>> FetchBudgets(bool forceRefresh = false)
{ {
if (Budgets is not null) return Budgets; if (Budgets.Count != 0 && !forceRefresh) return Budgets.ToList();
var budgets = await SupabaseService.Client.From<Budget>().Get(); var budgets = await SupabaseService.Client.From<Budget>().Get();
Budgets = budgets.Models; Budgets = new ObservableCollection<Budget>(budgets.Models);
return budgets.Models; return budgets.Models;
} }
public async Task<List<Budget>> FetchProcessedBudgets(DateTime CurrentPeriod) public async Task<List<Budget>> FetchProcessedBudgets(DateTime CurrentPeriod)
{ {
var categories = await FetchCategories(); var budgets = Budgets;
var transactions = await FetchTransactions();
var budgets = await FetchBudgets();
var outputList = new List<Budget>(); var outputList = new List<Budget>();
foreach (var budget in budgets) foreach (var budget in budgets)
{ {
budget.Category = categories.FirstOrDefault(x => x.Id == budget.CategoryId); budget.Category = Categories.FirstOrDefault(x => x.Id == budget.CategoryId);
switch (budget.Period.ToLower()) switch (budget.Period.ToLower())
{ {
case "monthly": case "monthly":
var budgetTransactions = transactions.Where(x => var budgetTransactions = Transactions.Where(x =>
x.Date.Month == CurrentPeriod.Month && x.Date.Year == CurrentPeriod.Year && x.CategoryId == budget.CategoryId).ToList(); x.Date.Month == CurrentPeriod.Month && x.Date.Year == CurrentPeriod.Year && x.CategoryId == budget.CategoryId).ToList();
budget.Spent = budgetTransactions.Sum(x => x.Amount); budget.Spent = budgetTransactions.Sum(x => x.Amount);
budget.TransactionsCount = budgetTransactions.Count; budget.TransactionsCount = budgetTransactions.Count;
break; break;
case "quarterly": case "quarterly":
var quarterTransactions = transactions.Where(x => var quarterTransactions = Transactions.Where(x =>
x.Date.Month >= CurrentPeriod.Month - 3 && x.Date.Month <= CurrentPeriod.Month && x.CategoryId == budget.CategoryId).ToList(); x.Date.Month >= CurrentPeriod.Month - 3 && x.Date.Month <= CurrentPeriod.Month && x.CategoryId == budget.CategoryId).ToList();
budget.Spent = quarterTransactions.Sum(x => x.Amount); budget.Spent = quarterTransactions.Sum(x => x.Amount);
budget.TransactionsCount = quarterTransactions.Count; budget.TransactionsCount = quarterTransactions.Count;
break; break;
case "yearly": case "yearly":
var yearTransactions = transactions.Where(x => x.Date.Year == CurrentPeriod.Year && x.CategoryId == budget.CategoryId).ToList(); var yearTransactions = Transactions.Where(x => x.Date.Year == CurrentPeriod.Year && x.CategoryId == budget.CategoryId).ToList();
budget.Spent = yearTransactions.Sum(x => x.Amount); budget.Spent = yearTransactions.Sum(x => x.Amount);
budget.TransactionsCount = yearTransactions.Count; budget.TransactionsCount = yearTransactions.Count;
break; break;
} }
OnPropertyChanged(nameof(budget.IsOnTrack));
OnPropertyChanged(nameof(budget.IsWarning));
OnPropertyChanged(nameof(budget.IsOverBudget));
} }
@@ -173,4 +213,239 @@ public class GeneralDataRepo
return outputList; return outputList;
} }
public async Task<Account?> InsertAccount(Account account)
{
try
{
var result = await SupabaseService.Client.From<Account>()
.Insert(account, new QueryOptions() { Returning = QueryOptions.ReturnType.Representation });
if (result.Model is null) return null;
Accounts.Add(result.Model);
return result.Model;
}
catch (Exception e)
{
Console.WriteLine(e);
return null;
}
}
public async Task UpdateAccount(Account account)
{
try
{
var result = await SupabaseService.Client.From<Account>().Update(account);
if (result.Model is null) return;
var item = Accounts.FirstOrDefault(x => x.Id == result.Model.Id);
if (item is null) return;
var index = Accounts.IndexOf(item);
if (index != -1) Accounts[index] = result.Model;
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
public async Task MigrateTransactions(Guid accountId, Guid targetAccountId)
{
try
{
var update = await SupabaseService.Client
.From<Transaction>()
.Where(x => x.AccountId == accountId)
.Set(x => x.AccountId, targetAccountId)
.Update();
foreach (var updateModel in update.Models)
{
var item = Transactions.SingleOrDefault(x => x.Id == updateModel.Id);
if (item is null) return;
var index = Transactions.IndexOf(item);
if (index != -1) Transactions[index] = updateModel;
}
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task RecalculateAccountBalance(Guid targetAccountId)
{
var accountResult = Accounts
.SingleOrDefault(a => a.Id == targetAccountId);
if (accountResult is null) return;
var transactionsResult = Transactions
.Where(t => t.AccountId == targetAccountId);
var balance = accountResult.OpeningBalance +
transactionsResult.Sum(t =>
t.Type == "income" ? t.Amount : -t.Amount);
accountResult.CurrentBalance = balance;
await SupabaseService.Client
.From<Account>()
.Update(accountResult);
var index = Accounts.IndexOf(accountResult);
if (index != -1) Accounts[index] = accountResult;
}
public async Task DeleteAccount(Guid accountId)
{
await SupabaseService.Client
.From<Account>()
.Where(a => a.Id == accountId)
.Delete();
var item = Accounts.FirstOrDefault(x => x.Id == accountId);
if (item is null) return;
Accounts.Remove(item);
}
public async Task InsertBudget(Budget budget)
{
try
{
var result = await SupabaseService.Client.From<Budget>().Insert(budget);
if (result.Models.Count >= 1)
{
Budgets.Add(result.Models[0]);
}
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task UpdateBudget(Budget budget)
{
try
{
var result = await SupabaseService.Client.From<Budget>().Update(budget);
if (result.Model is null) return;
var item = Budgets.FirstOrDefault(x => x.Id == result.Model.Id);
if (item is null) return;
var index = Budgets.IndexOf(item);
if (index != -1) Budgets[index] = result.Model;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task DeleteBudget(Guid BudgetId)
{
try
{
await SupabaseService.Client.From<Budget>().Where(x => x.Id == BudgetId).Delete();
var item = Budgets.FirstOrDefault(x => x.Id == BudgetId);
if (item is null) return;
Budgets.Remove(item);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public void LinkTransactionCategories()
{
foreach (var transaction in Transactions)
{
transaction.Category = Categories.FirstOrDefault(x => x.Id == transaction.CategoryId);
}
}
public Transaction LinkTransactionCategories(Transaction transaction)
{
transaction.Category = Categories.FirstOrDefault(x => x.Id == transaction.CategoryId);
return transaction;
}
public async Task UpdateProfile(Profile profile)
{
var result = await SupabaseService.Client.From<Profile>().Update(profile);
if (result.Models.Count > 0) Profile = result.Models[0];
}
public async Task UpdateProfileAvatar(string? avatarUrl)
{
var profile = Profile;
profile.AvatarUrl = avatarUrl;
var result = await SupabaseService.Client
.From<Profile>()
.Update(profile);
Profile = result.Models[0];
}
/// <summary>Upload a local file as the current user's avatar. Returns the public URL.</summary>
public async Task<string> UploadAvatarAsync(string localFilePath)
{
var userId = SupabaseService.Client.Auth.CurrentUser!.Id;
var ext = Path.GetExtension(localFilePath).ToLowerInvariant();
var storagePath = $"{userId}/avatar{ext}";
var bytes = await File.ReadAllBytesAsync(localFilePath);
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
_ => "application/octet-stream"
};
var bucket = SupabaseService.Client.Storage.From(Bucket);
// Upsert: upload if not exists, replace if it does
await bucket.Upload(bytes, storagePath, new FileOptions
{
ContentType = mimeType,
Upsert = true
});
var stream = new MemoryStream(bytes);
// Append cache-buster so Avalonia Image re-fetches the new file
return $"{PublicBaseUrl}/{storagePath}?t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
}
/// <summary>Delete the current user's avatar from storage.</summary>
public async Task DeleteAvatarAsync()
{
var userId = SupabaseService.Client.Auth.CurrentUser!.Id;
// Try both extensions since we don't track which was uploaded
var bucket = SupabaseService.Client.Storage.From(Bucket);
foreach (var ext in new[] { "jpg", "jpeg", "png", "webp" })
{
try
{
await bucket.Remove([$"{userId}/avatar.{ext}"]);
}
catch
{
/* file with that ext may not exist, ignore */
}
}
}
/// <summary>Build the public URL for a given avatar_url stored in the profile.</summary>
public string? BuildPublicUrl(string? avatarUrl)
{
if (string.IsNullOrWhiteSpace(avatarUrl)) return null;
// If already a full URL (from storage or external), return as-is
if (avatarUrl.StartsWith("http")) return avatarUrl;
return $"{PublicBaseUrl}/{avatarUrl}";
}
} }

View File

@@ -4,7 +4,6 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia" xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:views="clr-namespace:Clario.Views"
xmlns:model="clr-namespace:Clario.Models" xmlns:model="clr-namespace:Clario.Models"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.BudgetViewMobile" x:Class="Clario.MobileViews.BudgetViewMobile"
@@ -255,12 +254,6 @@
BorderThickness="0" BorderThickness="0"
Padding="4" Padding="4"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:BudgetCardMenuView />
</Flyout>
</Button.Flyout>
<Svg Path="../Assets/Icons/ellipsis.svg" <Svg Path="../Assets/Icons/ellipsis.svg"
Width="15" Height="15" Width="15" Height="15"
Css="{DynamicResource SvgMuted}" /> Css="{DynamicResource SvgMuted}" />
@@ -434,7 +427,7 @@
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<TextBlock Grid.Column="1" Text="{Binding Profile.SavingsGoal, StringFormat='$0'}" FontSize="12" FontWeight="SemiBold" <TextBlock Grid.Column="1" Text="{Binding AppData.Profile.SavingsGoal, StringFormat='$0'}" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" /> Foreground="{DynamicResource TextPrimary}" />
</Grid> </Grid>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
@@ -447,7 +440,7 @@
<ProgressBar Classes="yellow" <ProgressBar Classes="yellow"
Value="{Binding TotalLeft}" Value="{Binding TotalLeft}"
Minimum="0" Minimum="0"
Maximum="{Binding Profile.SavingsGoal}" Maximum="{Binding AppData.Profile.SavingsGoal}"
Height="6" /> Height="6" />
<Border Background="{DynamicResource BadgeBgYellow}" <Border Background="{DynamicResource BadgeBgYellow}"

View File

@@ -30,7 +30,7 @@ public class Account : BaseModel
[Column("is_archived")] public bool IsArchived { get; set; } [Column("is_archived")] public bool IsArchived { get; set; }
[Column("opened_at")] public DateOnly? OpenedAt { get; set; } [Column("opened_at")] public DateTime? OpenedAt { get; set; }
[Column("created_at")] public DateTime CreatedAt { get; set; } [Column("created_at")] public DateTime CreatedAt { get; set; }
@@ -45,5 +45,7 @@ public class Account : BaseModel
[JsonIgnore] public decimal TotalExpenseThisMonth { get; set; } [JsonIgnore] public decimal TotalExpenseThisMonth { get; set; }
[JsonIgnore] public decimal MonthlyIncrease { get; set; } [JsonIgnore] public decimal MonthlyIncrease { get; set; }
[JsonIgnore] public List<Transaction>? RecentTransactions { get; set; } [JsonIgnore] public List<Transaction>? RecentTransactions { get; set; }
[JsonIgnore] public bool isCredit => Type == "Credit";
[JsonIgnore] public decimal CreditUtilizationPerc => (CurrentBalance < 0 ? CurrentBalance * -1 : 0) / (CreditLimit == 0 ? 1 : CreditLimit) ?? 1;
[JsonIgnore] public bool GroupHeader { get; set; } = false; [JsonIgnore] public bool GroupHeader { get; set; } = false;
} }

View File

@@ -29,13 +29,13 @@ public class Budget : BaseModel
[JsonIgnore] public Category? Category { get; set; } [JsonIgnore] public Category? Category { get; set; }
[JsonIgnore] public int TransactionsCount { get; set; } [JsonIgnore] public int TransactionsCount { get; set; }
[JsonIgnore] public decimal Spent { get; set; } // populated after joining with transactions [JsonIgnore] public decimal Spent { get; set; }
[JsonIgnore] public decimal Remaining => LimitAmount - Spent; [JsonIgnore] public decimal Remaining => LimitAmount - Spent;
[JsonIgnore] public double PercentageUsed => LimitAmount > 0 ? Math.Round((double)(Spent / LimitAmount), 2) : 0; [JsonIgnore] public double PercentageUsed => LimitAmount > 0 ? Math.Round((double)(Spent / LimitAmount), 2) : 0;
[JsonIgnore] public bool IsOverBudget => Spent > LimitAmount; [JsonIgnore] public bool IsOverBudget => Spent > LimitAmount;
[JsonIgnore] public bool IsWarning => !IsOverBudget && PercentageUsed * 100 >= AlertThreshold; [JsonIgnore] public bool IsWarning => !IsOverBudget && PercentageUsed * 100 >= AlertThreshold;
[JsonIgnore] public bool IsOnTrack => !IsOverBudget && PercentageUsed * 100 < AlertThreshold; [JsonIgnore] public bool IsOnTrack => PercentageUsed * 100 < AlertThreshold;
[JsonIgnore] public string SpentFormatted => $"${Spent:N0}"; [JsonIgnore] public string SpentFormatted => $"${Spent:N0}";
[JsonIgnore] public string AmountFormatted => $"of ${LimitAmount:N0}"; [JsonIgnore] public string AmountFormatted => $"of ${LimitAmount:N0}";

View File

@@ -1,4 +1,6 @@
using System; using System;
using Avalonia.Media.Imaging;
using Newtonsoft.Json;
using Supabase.Postgrest.Attributes; using Supabase.Postgrest.Attributes;
using Supabase.Postgrest.Models; using Supabase.Postgrest.Models;
@@ -10,6 +12,8 @@ public class Profile : BaseModel
[PrimaryKey("id", false)] public Guid Id { get; set; } [PrimaryKey("id", false)] public Guid Id { get; set; }
[Column("display_name")] public string DisplayName { get; set; } [Column("display_name")] public string DisplayName { get; set; }
[Column("avatar_url")] public string? AvatarUrl { get; set; } [Column("avatar_url")] public string? AvatarUrl { get; set; }
[JsonIgnore] public Bitmap? Avatar { get; set; }
[JsonIgnore] public bool HasAvatar => !string.IsNullOrWhiteSpace(AvatarUrl);
[Column("currency")] public string Currency { get; set; } [Column("currency")] public string Currency { get; set; }
[Column("theme")] public string Theme { get; set; } [Column("theme")] public string Theme { get; set; }
[Column("language")] public string Language { get; set; } [Column("language")] public string Language { get; set; }

View File

@@ -16,19 +16,7 @@ public class Transaction : BaseModel
[Column("account_id")] public Guid AccountId { get; set; } [Column("account_id")] public Guid AccountId { get; set; }
private Guid? _categoryId; [Column("category_id")] public Guid? CategoryId { get; set; }
[Column("category_id")]
public Guid? CategoryId
{
get => _categoryId;
set
{
_categoryId = value;
Category = DataRepo.General.FetchCategories().Result.FirstOrDefault(x => x.Id == value);
}
}
[JsonIgnore] public Category? Category { get; set; } [JsonIgnore] public Category? Category { get; set; }

View File

@@ -0,0 +1,78 @@
// using System;
// using System.IO;
// using System.Threading.Tasks;
// using Avalonia.Media.Imaging;
// using Clario.Data;
// using Supabase.Storage;
// using FileOptions = Supabase.Storage.FileOptions;
//
// namespace Clario.Services;
//
// public class AvatarService
// {
// public static AvatarService Instance = new();
//
// private const string Bucket = "avatars";
// private const string ProjectRef = "xzxstbllaivumhtpctmo";
// private const string PublicBaseUrl = $"https://{ProjectRef}.supabase.co/storage/v1/object/public/{Bucket}";
//
// /// <summary>Upload a local file as the current user's avatar. Returns the public URL.</summary>
// public async Task<string> UploadAvatarAsync(string localFilePath)
// {
// var userId = SupabaseService.Client.Auth.CurrentUser!.Id;
// var ext = Path.GetExtension(localFilePath).ToLowerInvariant();
// var storagePath = $"{userId}/avatar{ext}";
//
// var bytes = await File.ReadAllBytesAsync(localFilePath);
// var mimeType = ext switch
// {
// ".jpg" or ".jpeg" => "image/jpeg",
// ".png" => "image/png",
// ".webp" => "image/webp",
// _ => "application/octet-stream"
// };
//
// var bucket = SupabaseService.Client.Storage.From(Bucket);
//
// // Upsert: upload if not exists, replace if it does
// await bucket.Upload(bytes, storagePath, new FileOptions
// {
// ContentType = mimeType,
// Upsert = true
// });
//
// var stream = new MemoryStream(bytes);
// DataRepo.General.Profile!.Avatar = new Bitmap(stream);
// // Append cache-buster so Avalonia Image re-fetches the new file
// return $"{PublicBaseUrl}/{storagePath}?t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
// }
//
// /// <summary>Delete the current user's avatar from storage.</summary>
// public async Task DeleteAvatarAsync()
// {
// var userId = SupabaseService.Client.Auth.CurrentUser!.Id;
//
// // Try both extensions since we don't track which was uploaded
// var bucket = SupabaseService.Client.Storage.From(Bucket);
// foreach (var ext in new[] { "jpg", "jpeg", "png", "webp" })
// {
// try
// {
// await bucket.Remove([$"{userId}/avatar.{ext}"]);
// }
// catch
// {
// /* file with that ext may not exist, ignore */
// }
// }
// }
//
// /// <summary>Build the public URL for a given avatar_url stored in the profile.</summary>
// public static string? BuildPublicUrl(string? avatarUrl)
// {
// if (string.IsNullOrWhiteSpace(avatarUrl)) return null;
// // If already a full URL (from storage or external), return as-is
// if (avatarUrl.StartsWith("http")) return avatarUrl;
// return $"{PublicBaseUrl}/{avatarUrl}";
// }
// }

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
namespace Clario.Services;
public class FilePickerService
{
public static FilePickerService Instance { get; } = new();
private static TopLevel? GetTopLevel()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return TopLevel.GetTopLevel(desktop.MainWindow);
if (Application.Current?.ApplicationLifetime is ISingleViewApplicationLifetime single)
return TopLevel.GetTopLevel(single.MainView as Visual);
return null;
}
public async Task<IStorageFile?> PickImageAsync()
{
var topLevel = GetTopLevel();
if (topLevel is null) return null;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Avatar Image",
AllowMultiple = false,
FileTypeFilter = new List<FilePickerFileType>
{
new("Images") { Patterns = ["*.jpg", "*.jpeg", "*.png", "*.webp"] }
}
});
return files.Count > 0 ? files[0] : null;
}
}

View File

@@ -9,17 +9,13 @@ public class FileSessionStorage : ISessionStorage
public void Save(string json) public void Save(string json)
{ {
// Console.WriteLine($"Saving session to {_path}");
Directory.CreateDirectory(Path.GetDirectoryName(_path)!); Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, json); File.WriteAllText(_path, json);
} }
public string? Load() public string? Load()
{ {
if (!File.Exists(_path)) if (!File.Exists(_path)) return null;
{
return null;
}
var json = File.ReadAllText(_path); var json = File.ReadAllText(_path);
return json; return json;

View File

@@ -11,8 +11,9 @@
</Border> </Border>
</Design.PreviewWith> </Design.PreviewWith>
<StyleInclude Source="Styles/ToggleSwitchStyles.axaml" /> <StyleInclude Source="Styles/ToggleSwitchStyles.axaml" />
<!-- <StyleInclude Source="Styles/CalenderItemStyles.axaml" /> --> <StyleInclude Source="Styles/ColorPickerStyles.axaml" />
<StyleInclude Source="Styles/CalendarStyles.axaml" /> <StyleInclude Source="Styles/CalendarStyles.axaml" />
<StyleInclude Source="Styles/SliderStyles.axaml" />
<StyleInclude Source="../CustomControls/DateRangePicker.axaml" /> <StyleInclude Source="../CustomControls/DateRangePicker.axaml" />
<Styles.Resources> <Styles.Resources>
<ResourceDictionary> <ResourceDictionary>
@@ -1045,5 +1046,56 @@
<Setter Property="Opacity" Value="0.5" /> <Setter Property="Opacity" Value="0.5" />
</Style> </Style>
<!-- ── Budget Card — On Track ─────────────────────── -->
<Style Selector="Border.budget-card">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="20" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Budget Card — Warning ──────────────────────── -->
<Style Selector="Border.budget-card-warning">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentYellow}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="20" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Budget Card — Over Budget ─────────────────── -->
<Style Selector="Border.budget-card-over">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentRed}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="20" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Progress Bar — Yellow ─────────────────────── -->
<Style Selector="ProgressBar.yellow /template/ Border#PART_Indicator">
<Setter Property="Background" Value="{DynamicResource AccentYellow}" />
<Setter Property="CornerRadius" Value="3" />
</Style>
<!-- ── Badge — Warning ───────────────────────────── -->
<Style Selector="Border.badge-warning">
<Setter Property="Background" Value="{DynamicResource BadgeBgYellow}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource AccentYellow}" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Padding" Value="6,2" />
</Style>
<!-- ── Badge — Over ──────────────────────────────── -->
<Style Selector="Border.badge-over">
<Setter Property="Background" Value="{DynamicResource BadgeBgRed}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource AccentRed}" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Padding" Value="6,2" />
</Style>
</Styles> </Styles>

View File

@@ -0,0 +1,210 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:primitives="using:Avalonia.Controls.Primitives">
<Design.PreviewWith>
<Border Padding="20" Background="#0D0F14">
<ColorPicker Width="200" Height="36"/>
</Border>
</Design.PreviewWith>
<!-- ═══════════════════════════════════════════════════════════
RESOURCE OVERRIDES
These override the Fluent resource keys used internally
by the ColorPicker flyout template.
═══════════════════════════════════════════════════════════════ -->
<Styles.Resources>
<!-- Tab strip background (top 48px bar) -->
<SolidColorBrush x:Key="SystemControlBackgroundBaseLowBrush" Color="#13161E"/>
<!-- Tab strip border -->
<SolidColorBrush x:Key="ColorViewTabBorderBrush" Color="#1E2330"/>
<!-- Content area background (below tab strip) -->
<SolidColorBrush x:Key="ColorViewContentBackgroundBrush" Color="#13161E"/>
<!-- Content area border (the top-border line between tabs and content) -->
<SolidColorBrush x:Key="ColorViewContentBorderBrush" Color="#1E2330"/>
<!-- Fluent text control resources used by component label borders + hex # border -->
<SolidColorBrush x:Key="TextControlBackgroundDisabled" Color="#1A1E2A"/>
<SolidColorBrush x:Key="TextControlBorderBrush" Color="#1E2330"/>
<SolidColorBrush x:Key="TextControlForegroundDisabled" Color="#7A8090"/>
<!-- TextBox (hex input + component spinners) -->
<SolidColorBrush x:Key="TextControlBackground" Color="#13161E"/>
<SolidColorBrush x:Key="TextControlBackgroundPointerOver" Color="#1A1E2A"/>
<SolidColorBrush x:Key="TextControlBackgroundFocused" Color="#13161E"/>
<SolidColorBrush x:Key="TextControlForeground" Color="#F0F2F8"/>
<SolidColorBrush x:Key="TextControlForegroundPointerOver" Color="#F0F2F8"/>
<SolidColorBrush x:Key="TextControlForegroundFocused" Color="#F0F2F8"/>
<SolidColorBrush x:Key="TextControlBorderBrushPointerOver" Color="#2A3050"/>
<SolidColorBrush x:Key="TextControlBorderBrushFocused" Color="#7B9CFF"/>
<SolidColorBrush x:Key="TextControlPlaceholderForeground" Color="#5A6070"/>
<!-- RadioButton (RGB/HSV toggle) -->
<SolidColorBrush x:Key="RadioButtonBackground" Color="#13161E"/>
<SolidColorBrush x:Key="RadioButtonBackgroundPointerOver" Color="#1A1E2A"/>
<SolidColorBrush x:Key="RadioButtonBackgroundPressed" Color="#1E2330"/>
<SolidColorBrush x:Key="RadioButtonBorderBrush" Color="#1E2330"/>
<SolidColorBrush x:Key="RadioButtonBorderBrushPointerOver" Color="#2A3050"/>
<SolidColorBrush x:Key="RadioButtonForeground" Color="#C8D0E8"/>
<SolidColorBrush x:Key="RadioButtonForegroundPointerOver" Color="#F0F2F8"/>
<SolidColorBrush x:Key="RadioButtonOuterEllipseStroke" Color="#7B9CFF"/>
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStroke" Color="#7B9CFF"/>
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFill" Color="#7B9CFF"/>
<SolidColorBrush x:Key="RadioButtonCheckGlyphFill" Color="#0D0F14"/>
<!-- ColorPreviewer -->
<SolidColorBrush x:Key="ColorViewPreviewBorderBrush" Color="#1E2330"/>
</Styles.Resources>
<!-- ═══════════════════════════════════════════════════════════
ColorPicker — the drop-down button itself
═══════════════════════════════════════════════════════════════ -->
<Style Selector="ColorPicker">
<Setter Property="CornerRadius" Value="{DynamicResource RadiusControl}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="Background" Value="{DynamicResource BgBase}"/>
</Style>
<!-- The DropDownButton inside ColorPicker -->
<Style Selector="ColorPicker /template/ DropDownButton">
<Setter Property="Background" Value="{DynamicResource BgBase}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="{DynamicResource RadiusControl}"/>
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton:pointerover /template/ Border#Background">
<Setter Property="Background" Value="{DynamicResource BgHover}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton:pressed /template/ Border#Background">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}"/>
</Style>
<!-- The chevron arrow inside DropDownButton -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ PathIcon">
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
Flyout popup wrapper
═══════════════════════════════════════════════════════════════ -->
<Style Selector="FlyoutPresenter.nopadding">
<Setter Property="Padding" Value="0"/>
<Setter Property="Background" Value="{DynamicResource BgSurface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="{DynamicResource RadiusControl}"/>
<!-- <Setter Property="BoxShadow" Value="0 8 32 0 #3C000000"/> -->
</Style>
<!-- ═══════════════════════════════════════════════════════════
Tab strip inside the flyout
═══════════════════════════════════════════════════════════════ -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabControl">
<Setter Property="Background" Value="{DynamicResource BgSurface}"/>
</Style>
<!-- TabItem (spectrum / palette / components icons) -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="48"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabItem:selected">
<Setter Property="Foreground" Value="{DynamicResource AccentBlue}"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabItem:pointerover">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Style>
<!-- PathIcon inside tab headers -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabItem PathIcon">
<Setter Property="Foreground" Value="{DynamicResource TextMuted}"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TabItem:selected PathIcon">
<Setter Property="Foreground" Value="{DynamicResource AccentBlue}"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
Hex input TextBox
═══════════════════════════════════════════════════════════════ -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TextBox">
<Setter Property="Background" Value="{DynamicResource BgBase}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="0,4,4,0"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBlue}"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup TextBox:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
NumericUpDown (RGB/HSV component value inputs)
═══════════════════════════════════════════════════════════════ -->
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup NumericUpDown">
<Setter Property="Background" Value="{DynamicResource BgBase}"/>
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="ColorPicker /template/ DropDownButton /template/ Popup NumericUpDown /template/ Border">
<Setter Property="Background" Value="{DynamicResource BgBase}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
ColorSlider (hue, saturation, value sliders)
═══════════════════════════════════════════════════════════════ -->
<Style Selector="primitives|ColorSlider">
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Height" Value="16"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
</Style>
<!-- Slider thumb -->
<Style Selector="primitives|ColorSlider /template/ Thumb">
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Background" Value="{DynamicResource TextPrimary}"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="BorderBrush" Value="{DynamicResource BgBase}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="primitives|ColorSlider /template/ Thumb:pointerover">
<Setter Property="Background" Value="{DynamicResource AccentBlue}"/>
</Style>
<Style Selector="primitives|ColorSlider /template/ Thumb:pressed">
<Setter Property="Background" Value="{DynamicResource AccentBlue}"/>
</Style>
<!-- Vertical slider (the third component slider beside the spectrum) -->
<Style Selector="primitives|ColorSlider[Orientation=Vertical]">
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="NaN"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
ColorPreviewer (accent color swatches at the bottom)
═══════════════════════════════════════════════════════════════ -->
<Style Selector="primitives|ColorPreviewer">
<Setter Property="Background" Value="{DynamicResource BgSurface}"/>
</Style>
<!-- Individual preview color swatch borders -->
<Style Selector="primitives|ColorPreviewer Border">
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
</Styles>

View File

@@ -0,0 +1,116 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Border Padding="24" Background="#0D0F14" Width="320">
<StackPanel Spacing="24">
<Slider Minimum="0" Maximum="100" Value="30" />
<Slider Minimum="0" Maximum="100" Value="65" />
<Slider Minimum="0" Maximum="100" Value="80" IsEnabled="False" />
</StackPanel>
</Border>
</Design.PreviewWith>
<!-- Add to SliderStyles.axaml inside <Styles.Resources> -->
<Styles.Resources>
<ControlTheme x:Key="SliderRepeatButton" TargetType="RepeatButton">
<Setter Property="Focusable" Value="False" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
CornerRadius="2"
Height="4"
VerticalAlignment="Center" />
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="{x:Type Slider}" TargetType="Slider">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
<Setter Property="Foreground" Value="{DynamicResource AccentBlue}" />
<Style Selector="^:horizontal">
<Setter Property="MinHeight" Value="20" />
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="grid">
<Border x:Name="TrackBackground"
Height="4"
Margin="8,0"
Background="{DynamicResource BorderSubtle}"
CornerRadius="2"
VerticalAlignment="Center" />
<Track x:Name="PART_Track"
Minimum="{TemplateBinding Minimum}"
Maximum="{TemplateBinding Maximum}"
Value="{TemplateBinding Value, Mode=TwoWay}"
IsDirectionReversed="{TemplateBinding IsDirectionReversed}"
Orientation="Horizontal">
<Track.DecreaseButton>
<RepeatButton x:Name="PART_DecreaseButton"
Background="{DynamicResource AccentBlue}"
Theme="{StaticResource SliderRepeatButton}" />
</Track.DecreaseButton>
<Track.IncreaseButton>
<RepeatButton x:Name="PART_IncreaseButton"
Background="{DynamicResource BorderSubtle}"
Theme="{StaticResource SliderRepeatButton}" />
</Track.IncreaseButton>
<Thumb x:Name="thumb" Width="16" Height="16">
<Thumb.Template>
<ControlTemplate>
<Border Width="16" Height="16"
CornerRadius="8"
Background="{DynamicResource AccentBlue}"
BorderBrush="{DynamicResource BgSurface}"
BorderThickness="2"
BoxShadow="0 2 8 0 #40000000" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter>
</Style>
<!-- Pointer over -->
<Style Selector="^:pointerover /template/ RepeatButton#PART_DecreaseButton">
<Setter Property="Background" Value="#8FAEFF" />
</Style>
<Style Selector="^:pointerover /template/ Thumb#thumb">
<Setter Property="Template">
<ControlTemplate>
<Border Width="16" Height="16" CornerRadius="8"
Background="#8FAEFF"
BorderBrush="{DynamicResource BgSurface}"
BorderThickness="2"
BoxShadow="0 2 12 0 #507B9CFF" />
</ControlTemplate>
</Setter>
</Style>
<!-- Pressed -->
<Style Selector="^:pressed /template/ RepeatButton#PART_DecreaseButton">
<Setter Property="Background" Value="#6B8AEF" />
</Style>
<Style Selector="^:pressed /template/ Thumb#thumb">
<Setter Property="Template">
<ControlTemplate>
<Border Width="14" Height="14" CornerRadius="7"
Background="#6B8AEF"
BorderBrush="{DynamicResource BgSurface}"
BorderThickness="2" />
</ControlTemplate>
</Setter>
</Style>
<!-- Disabled -->
<Style Selector="^:disabled /template/ Grid#grid">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
</Styles.Resources>
</Styles>

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Clario.ViewModels;
public partial class AccountFormViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
// ── Mode ────────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
private bool _isEditMode = false;
public string FormTitle => IsEditMode ? "Edit Account" : "New Account";
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Account";
// ── Fields ──────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private string _name = "";
[ObservableProperty] private string _selectedType = "Checking";
[ObservableProperty] private string? _institution;
[ObservableProperty] private string? _mask;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private string _openingBalance = "0.00";
[ObservableProperty] private string _currency = "USD";
[ObservableProperty] private string? _creditLimit;
[ObservableProperty] private List<DateTime>? _openedAtDates;
[ObservableProperty] private string _selectedIcon = "wallet";
[ObservableProperty] private string _selectedColor = "#3B82F6";
// ── Options ─────────────────────────────────────────────
[ObservableProperty] private List<string> _accountTypes = new() { "Cash", "Checking", "Savings", "Credit", "Investment", "Other" };
[ObservableProperty] private List<string> _currencies = new() { "USD", "EUR", "GBP", "CAD", "AUD" };
[ObservableProperty] private List<string> _icons = new() { "wallet", "credit-card", "banknote", "landmark", "piggy-bank", "dollar-sign" };
// ── Validation ──────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool IsValid =>
!string.IsNullOrWhiteSpace(Name) &&
decimal.TryParse(OpeningBalance, out _);
public bool IsCredit => SelectedType == "Credit";
// ── Callbacks ───────────────────────────────────────────
public Action? OnSaved;
public Action? OnCancelled;
// ── Edit mode: original account ─────────────────────────
private Guid? _editingId;
// ── Result account ──────────────────────────────────────
public Account? ResultAccount { get; set; }
// ── Commands ────────────────────────────────────────────
partial void OnSelectedTypeChanged(string value)
{
OnPropertyChanged(nameof(IsCredit));
}
[RelayCommand]
private async Task Save()
{
ErrorMessage = null;
if (string.IsNullOrWhiteSpace(Name))
{
ErrorMessage = "Name is required.";
return;
}
if (!decimal.TryParse(OpeningBalance, out var balance))
{
ErrorMessage = "Please enter a valid opening balance.";
return;
}
decimal? creditLimitValue = null;
if (IsCredit && !string.IsNullOrWhiteSpace(CreditLimit))
{
if (!decimal.TryParse(CreditLimit, out var limit))
{
ErrorMessage = "Please enter a valid credit limit.";
return;
}
creditLimitValue = limit;
}
try
{
if (IsEditMode && _editingId.HasValue)
{
var updated = new Account
{
Id = _editingId.Value,
UserId = Guid.Parse(Services.SupabaseService.Client.Auth.CurrentUser!.Id),
Name = Name.Trim(),
Type = SelectedType,
Institution = Institution?.Trim(),
Mask = Mask?.Trim(),
Currency = Currency,
OpeningBalance = balance,
CreditLimit = creditLimitValue,
OpenedAt = OpenedAtDates?[0],
Icon = SelectedIcon,
Color = SelectedColor,
};
await DataRepo.General.UpdateAccount(updated);
ResultAccount = updated;
}
else
{
var account = new Account
{
Id = Guid.NewGuid(),
UserId = Guid.Parse(Services.SupabaseService.Client.Auth.CurrentUser!.Id!),
Name = Name.Trim(),
Type = SelectedType,
Institution = Institution?.Trim(),
Mask = Mask?.Trim(),
Currency = Currency,
OpeningBalance = balance,
CreditLimit = creditLimitValue,
OpenedAt = OpenedAtDates?[0],
Icon = SelectedIcon,
Color = SelectedColor,
};
var result = await DataRepo.General.InsertAccount(account);
ResultAccount = result;
}
OnSaved?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Something went wrong. Please try again.";
Console.WriteLine(ex);
}
}
[RelayCommand]
private void Cancel()
{
OnCancelled?.Invoke();
}
// ── Public setup methods ─────────────────────────────────
/// <summary>Call this to open the form for adding a new account.</summary>
public void SetupForAdd()
{
IsEditMode = false;
_editingId = null;
Name = "";
SelectedType = "Checking";
Institution = null;
Mask = null;
OpeningBalance = "0.00";
Currency = DataRepo.General.Profile?.Currency ?? "USD";
CreditLimit = null;
OpenedAtDates = null;
SelectedIcon = "wallet";
SelectedColor = "#3B82F6";
ErrorMessage = null;
ResultAccount = null;
}
/// <summary>Call this to open the form for editing an existing account.</summary>
public void SetupForEdit(Account account)
{
IsEditMode = true;
_editingId = account.Id;
Name = account.Name;
SelectedType = account.Type;
Institution = account.Institution;
Mask = account.Mask;
OpeningBalance = account.OpeningBalance.ToString("0.00");
Currency = account.Currency;
CreditLimit = account.CreditLimit?.ToString("0.00");
OpenedAtDates = account.OpenedAt.HasValue ? new List<DateTime> { account.OpenedAt.Value } : null;
SelectedIcon = account.Icon;
SelectedColor = account.Color;
ErrorMessage = null;
ResultAccount = account;
}
}

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Clario.Data; using Clario.Data;
using Clario.Models; using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -13,18 +12,25 @@ namespace Clario.ViewModels;
public partial class AccountsViewModel : ViewModelBase public partial class AccountsViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
public required List<Account> Accounts = new();
public required List<Transaction> Transactions = new(); public GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private ObservableCollection<Account> _visibleAccounts = new(); [ObservableProperty] private ObservableCollection<Account> _visibleAccounts = new();
[ObservableProperty] private decimal _totalBalance = 0; [ObservableProperty] private decimal _totalBalance;
[ObservableProperty] private Account? _selectedAccount; [ObservableProperty] private Account? _selectedAccount;
[ObservableProperty] private bool _isAccountDeletionConfirmationVisible;
public bool CanDeleteAccount => VisibleAccounts.Count > 1;
[ObservableProperty] private bool _isDeleteDialogVisible;
[ObservableProperty] private DeleteAccountDialogViewModel _deleteDialog = new();
public AccountsViewModel() public AccountsViewModel()
{ {
AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); };
Initialize();
} }
public async Task Initialize() public void Initialize()
{ {
FetchAndProcessAccountInfo(); FetchAndProcessAccountInfo();
GroupAccounts(); GroupAccounts();
@@ -33,9 +39,9 @@ public partial class AccountsViewModel : ViewModelBase
private void FetchAndProcessAccountInfo() private void FetchAndProcessAccountInfo()
{ {
foreach (var account in Accounts) foreach (var account in AppData.Accounts)
{ {
var accountTransactions = Transactions.Where(t => t.AccountId == account.Id).ToList(); var accountTransactions = AppData.Transactions.Where(t => t.AccountId == account.Id).ToList();
account.TransactionsCount = accountTransactions.Count; account.TransactionsCount = accountTransactions.Count;
account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount); account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount);
account.TotalIncomeThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type == "income").Sum(t => t.Amount); account.TotalIncomeThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type == "income").Sum(t => t.Amount);
@@ -50,22 +56,37 @@ public partial class AccountsViewModel : ViewModelBase
} }
} }
[RelayCommand]
private void CreateAccount()
{
((MainViewModel)parentViewModel).OpenAddAccount();
}
[RelayCommand]
private void EditAccount(Account account)
{
((MainViewModel)parentViewModel).OpenEditAccount(account);
}
private void GroupAccounts() private void GroupAccounts()
{ {
var accountTypes = new Dictionary<string, string>() var accountTypes = new List<string>()
{ {
{ "checking", "Cash & Checking" }, "Cash",
{ "savings", "Savings" }, "Checking",
{ "credit", "Credit" }, "Savings",
{ "investment", "Investments" } "Credit",
"Investment",
"Other"
}; };
VisibleAccounts.Clear();
foreach (var type in accountTypes) foreach (var type in accountTypes)
{ {
var accountsOfType = Accounts.Where(a => a.Type == type.Key).ToList(); var accountsOfType = AppData.Accounts.Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase)).ToList();
if (accountsOfType.Any()) if (accountsOfType.Any())
{ {
var header = new Account { Name = type.Value.ToUpper(), GroupHeader = true }; var header = new Account { Name = type.ToUpper(), GroupHeader = true };
VisibleAccounts.Add(header); VisibleAccounts.Add(header);
foreach (var account in accountsOfType) foreach (var account in accountsOfType)
{ {
@@ -73,6 +94,21 @@ public partial class AccountsViewModel : ViewModelBase
} }
} }
} }
OnPropertyChanged(nameof(CanDeleteAccount));
}
[RelayCommand]
private void RequestDeleteAccount(Account account)
{
DeleteDialog.Setup(account, new ObservableCollection<Account>(AppData.Accounts));
DeleteDialog.OnDeleted = () =>
{
IsDeleteDialogVisible = false;
Initialize();
};
DeleteDialog.OnCancelled = () => IsDeleteDialogVisible = false;
IsDeleteDialogVisible = true;
} }
[RelayCommand] [RelayCommand]
@@ -86,6 +122,7 @@ public partial class AccountsViewModel : ViewModelBase
{ {
if (parentViewModel is MainViewModel mainViewModel) if (parentViewModel is MainViewModel mainViewModel)
{ {
if (SelectedAccount is null) return;
var vm = mainViewModel._transactionsViewModel; var vm = mainViewModel._transactionsViewModel;
vm.SelectedAccount = vm.Accounts.First(x => x.Id == SelectedAccount.Id); vm.SelectedAccount = vm.Accounts.First(x => x.Id == SelectedAccount.Id);
vm.LoadPageCommand.Execute(1); vm.LoadPageCommand.Execute(1);

View File

@@ -40,11 +40,11 @@ public partial class AuthViewModel : ViewModelBase
private void setDefaults() private void setDefaults()
{ {
FirstName = "nouredeen"; FirstName = "clario";
LastName = "ghazal"; LastName = "testing";
Email = "nouredeen.ghazal42@gmail.com"; Email = "Clario@testing.com";
Password = "Nour1Clario"; Password = "1234ABCD6767";
ConfirmPassword = "Nour1Clario"; ConfirmPassword = "1234ABCD6767";
ThemeService.SwitchToTheme("system"); ThemeService.SwitchToTheme("system");
} }
@@ -99,7 +99,6 @@ public partial class AuthViewModel : ViewModelBase
await SupabaseService.Client.Auth.SetSession(session.AccessToken, session.RefreshToken); await SupabaseService.Client.Auth.SetSession(session.AccessToken, session.RefreshToken);
var user = session.User; var user = session.User;
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();

View File

@@ -1,6 +0,0 @@
namespace Clario.ViewModels;
public partial class BudgetCardMenuViewModel : ViewModelBase
{
}

View File

@@ -1,6 +1,210 @@
namespace Clario.ViewModels; using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Clario.ViewModels;
public partial class BudgetFormViewModel : ViewModelBase public partial class BudgetFormViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
// ── Mode ────────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
private bool _isEditMode = false;
public string FormTitle => IsEditMode ? "Edit Budget" : "New Budget";
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Budget";
// ── Fields ──────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsMonthly), nameof(IsQuarterly), nameof(IsYearly), nameof(IsValid))]
private string _period = "monthly";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private string _limitAmount = "";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private Category? _selectedCategory;
[ObservableProperty] private ObservableCollection<Category> _categories = new();
// AlertThreshold: 0100 int, stored as double for Slider binding
// Slider.Value is double; we round to int when saving
[ObservableProperty] [NotifyPropertyChangedFor(nameof(AlertThresholdLabel))]
private double _alertThreshold = 80;
public string AlertThresholdLabel => $"{(int)AlertThreshold}%";
[ObservableProperty] private bool _rollover = false;
// ── Validation ──────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool IsMonthly => Period == "monthly";
public bool IsQuarterly => Period == "quarterly";
public bool IsYearly => Period == "yearly";
public bool IsValid =>
decimal.TryParse(LimitAmount, out var amt) && amt > 0 &&
SelectedCategory is not null;
// ── Callbacks ───────────────────────────────────────────
public Action? OnSaved;
public Action? OnCancelled;
public Action? OnDeleted;
[ObservableProperty] private bool _showDeleteConfirm = false;
// ── Edit mode: original budget ───────────────────────────
private Guid? _editingId;
// ── Result ──────────────────────────────────────────────
public Budget? ResultBudget { get; set; }
// ── Commands ────────────────────────────────────────────
[RelayCommand]
private void SetPeriod(string period)
{
Period = period;
}
[RelayCommand]
private async Task Save()
{
ErrorMessage = null;
if (!decimal.TryParse(LimitAmount, out var amt) || amt <= 0)
{
ErrorMessage = "Please enter a valid amount.";
return;
}
if (SelectedCategory is null)
{
ErrorMessage = "Please select a category.";
return;
}
try
{
if (IsEditMode && _editingId.HasValue)
{
var updated = new Budget
{
Id = _editingId.Value,
UserId = Guid.Parse(Services.SupabaseService.Client.Auth.CurrentUser!.Id),
CategoryId = SelectedCategory.Id,
LimitAmount = amt,
Period = Period,
AlertThreshold = (int)Math.Round(AlertThreshold),
Rollover = Rollover,
Category = SelectedCategory,
};
await DataRepo.General.UpdateBudget(updated);
ResultBudget = updated;
}
else
{
var budget = new Budget
{
Id = Guid.NewGuid(),
UserId = Guid.Parse(Services.SupabaseService.Client.Auth.CurrentUser!.Id!),
CategoryId = SelectedCategory.Id,
LimitAmount = amt,
Period = Period,
AlertThreshold = (int)Math.Round(AlertThreshold),
Rollover = Rollover,
Category = SelectedCategory,
};
await DataRepo.General.InsertBudget(budget);
ResultBudget = budget;
}
OnSaved?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Something went wrong. Please try again.";
Console.WriteLine(ex);
}
}
[RelayCommand]
private async Task ConfirmDelete()
{
if (!IsEditMode || !_editingId.HasValue) return;
try
{
await DataRepo.General.DeleteBudget(_editingId.Value);
OnDeleted?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Failed to delete budget.";
Console.WriteLine(ex);
}
}
[RelayCommand]
private void RequestDelete()
{
ShowDeleteConfirm = true;
}
[RelayCommand]
private void CancelDelete()
{
ShowDeleteConfirm = false;
}
[RelayCommand]
private void Cancel()
{
OnCancelled?.Invoke();
}
// ── Public setup methods ─────────────────────────────────
/// <summary>Call this to open the form for adding a new budget.</summary>
public void SetupForAdd(ObservableCollection<Category> categories)
{
ShowDeleteConfirm = false;
IsEditMode = false;
_editingId = null;
Categories = categories;
LimitAmount = "";
Period = "monthly";
AlertThreshold = 80;
Rollover = false;
ErrorMessage = null;
SelectedCategory = categories.Count > 0 ? categories[0] : null;
ResultBudget = null;
}
/// <summary>Call this to open the form for editing an existing budget.</summary>
public void SetupForEdit(Budget budget, ObservableCollection<Category> categories)
{
ShowDeleteConfirm = false;
IsEditMode = true;
_editingId = budget.Id;
Categories = categories;
LimitAmount = budget.LimitAmount.ToString("0.00");
Period = budget.Period;
AlertThreshold = budget.AlertThreshold;
Rollover = budget.Rollover;
ErrorMessage = null;
SelectedCategory = categories.FirstOrDefault(c => c.Id == budget.CategoryId)
?? (categories.Count > 0 ? categories[0] : null);
ResultBudget = budget;
}
} }

View File

@@ -1,13 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Data;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Clario.Data; using Clario.Data;
using Clario.Models; using Clario.Models;
using Clario.Models.GeneralModels;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LiveChartsCore; using LiveChartsCore;
@@ -20,17 +18,15 @@ namespace Clario.ViewModels;
public partial class BudgetViewModel : ViewModelBase public partial class BudgetViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
[ObservableProperty] private Profile? _profile; public GeneralDataRepo AppData => DataRepo.General;
public required List<Budget> Budgets = new();
[ObservableProperty] private ObservableCollection<Budget> _visibleBudgets = new(); [ObservableProperty] private ObservableCollection<Budget> _visibleBudgets = new();
public required List<Category> Categories = new();
public required List<Transaction> Transactions = new();
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextPeriodCommand), nameof(PreviousPeriodCommand))] [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextPeriodCommand), nameof(PreviousPeriodCommand))]
private DateTime _currentPeriod = DateTime.Now.Date; private DateTime _currentPeriod = DateTime.Now.Date;
public bool CanGoToNextPeriod => CurrentPeriod.Month < DateTime.Now.Month; public bool CanGoToNextPeriod => CurrentPeriod.Month < DateTime.Now.Month;
public bool CanGoToPreviousPeriod => Transactions.Any() && CurrentPeriod.Month > Transactions.Min(x => x.Date.Month); public bool CanGoToPreviousPeriod => AppData.Transactions.Any() && CurrentPeriod.Month > AppData.Transactions.Min(x => x.Date.Month);
public string CurrentPeriodFormatted => CurrentPeriod.ToString("MMMM yyyy"); public string CurrentPeriodFormatted => CurrentPeriod.ToString("MMMM yyyy");
[ObservableProperty] private ISeries[] _spendingBreakdownChartSeries = []; [ObservableProperty] private ISeries[] _spendingBreakdownChartSeries = [];
@@ -43,9 +39,9 @@ public partial class BudgetViewModel : ViewModelBase
public decimal TotalLeft => Math.Clamp(Math.Round(TotalBudgeted - TotalSpent), 0, decimal.MaxValue); public decimal TotalLeft => Math.Clamp(Math.Round(TotalBudgeted - TotalSpent), 0, decimal.MaxValue);
public string TotalLeftFormatted => TotalLeft.ToString("C0") + " left"; public string TotalLeftFormatted => TotalLeft.ToString("C0") + " left";
public string SavingsHint => TotalLeft >= (Profile != null ? Profile.SavingsGoal : 0) public string SavingsHint => TotalLeft >= (AppData.Profile != null ? AppData.Profile.SavingsGoal : 0)
? "You're on track!" ? "You're on track!"
: $"Reduce your spending by ${Math.Round((Profile != null ? Profile.SavingsGoal ?? 0 : 0) - TotalLeft)} to hit your goal."; : $"Reduce your spending by ${Math.Round((AppData.Profile != null ? AppData.Profile.SavingsGoal ?? 0 : 0) - TotalLeft)} to hit your goal.";
private int _onTrackCount; private int _onTrackCount;
private int _approachingCount; private int _approachingCount;
@@ -60,13 +56,17 @@ public partial class BudgetViewModel : ViewModelBase
private int PeriodDaysLeft => PeriodLength - PeriodDaysPassed; private int PeriodDaysLeft => PeriodLength - PeriodDaysPassed;
public string PeriodDaysLeftFormatted => PeriodDaysLeft == 1 ? PeriodDaysLeft + " day left" : PeriodDaysLeft + " days left"; public string PeriodDaysLeftFormatted => PeriodDaysLeft == 1 ? PeriodDaysLeft + " day left" : PeriodDaysLeft + " days left";
public string DailyBudgetLeftFormatted => ((TotalBudgeted - TotalSpent) / PeriodDaysLeft).ToString("C", new CultureInfo("en-US")); public string DailyBudgetLeftFormatted =>
((TotalBudgeted - TotalSpent) / ((PeriodDaysLeft == 0) ? 1 : PeriodDaysLeft)).ToString("C", new CultureInfo("en-US"));
public BudgetViewModel() public BudgetViewModel()
{ {
AppData.Budgets.CollectionChanged += async (_, _) => { await Initialize(); };
AppData.Transactions.CollectionChanged += async (_, _) => { await Initialize(); };
_ = Initialize();
} }
public async Task Initialize() private async Task Initialize()
{ {
try try
{ {
@@ -80,15 +80,25 @@ public partial class BudgetViewModel : ViewModelBase
} }
} }
[RelayCommand]
private void CreateBudget()
{
((MainViewModel)parentViewModel).OpenAddBudgetCommand.Execute(null);
}
[RelayCommand]
private void EditBudget(Budget budget)
{
((MainViewModel)parentViewModel).OpenEditBudgetCommand.Execute(budget);
}
private void ProcessChartData() private void ProcessChartData()
{ {
var categories = Categories;
var transactions = Transactions;
var tempCategorySpendingBreakdown = new List<(Category category, double[] spent)>(); var tempCategorySpendingBreakdown = new List<(Category category, double[] spent)>();
var tempSpendingBreakdownLegends = new List<Budget>(); var tempSpendingBreakdownLegends = new List<Budget>();
foreach (var category in categories) foreach (var category in AppData.Categories)
{ {
var spent = transactions var spent = AppData.Transactions
.Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase) && .Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase) &&
x.Date.Month == CurrentPeriod.Month && x.Date.Year == CurrentPeriod.Year) x.Date.Month == CurrentPeriod.Month && x.Date.Year == CurrentPeriod.Year)
.Sum(x => x.Amount); .Sum(x => x.Amount);
@@ -115,11 +125,31 @@ public partial class BudgetViewModel : ViewModelBase
{ {
VisibleBudgets.Clear(); VisibleBudgets.Clear();
VisibleBudgets = new ObservableCollection<Budget>(await DataRepo.General.FetchProcessedBudgets(CurrentPeriod)); VisibleBudgets = new ObservableCollection<Budget>(await DataRepo.General.FetchProcessedBudgets(CurrentPeriod));
_onTrackCount = VisibleBudgets.Count(x => x.IsOnTrack); _onTrackCount = VisibleBudgets.Count(x => x is { IsOnTrack: true, GroupHeader: false });
_approachingCount = VisibleBudgets.Count(x => x.IsWarning); _approachingCount = VisibleBudgets.Count(x => x is { IsWarning: true, GroupHeader: false });
_overBudgetCount = VisibleBudgets.Count(x => x.IsOverBudget); _overBudgetCount = VisibleBudgets.Count(x => x is { IsOverBudget: true, GroupHeader: false });
TotalBudgeted = VisibleBudgets.Sum(x => x.LimitAmount); TotalBudgeted = VisibleBudgets.Sum(x => x.LimitAmount);
TotalSpent = VisibleBudgets.Sum(x => x.Spent); TotalSpent = VisibleBudgets.Sum(x => x.Spent);
NotifyComputedPropertiesOnChanged();
}
private void NotifyComputedPropertiesOnChanged()
{
OnPropertyChanged(nameof(CanGoToNextPeriod));
OnPropertyChanged(nameof(CanGoToPreviousPeriod));
OnPropertyChanged(nameof(CurrentPeriodFormatted));
OnPropertyChanged(nameof(SpentPercentageFormatted));
OnPropertyChanged(nameof(TotalLeft));
OnPropertyChanged(nameof(TotalLeftFormatted));
OnPropertyChanged(nameof(SavingsHint));
OnPropertyChanged(nameof(OnTrackCountFormatted));
OnPropertyChanged(nameof(ApproachingCountFormatted));
OnPropertyChanged(nameof(OverBudgetCountFormatted));
OnPropertyChanged(nameof(PeriodLength));
OnPropertyChanged(nameof(PeriodDaysPassed));
OnPropertyChanged(nameof(PeriodDaysLeftFormatted));
OnPropertyChanged(nameof(DailyBudgetLeftFormatted));
} }
[RelayCommand(CanExecute = nameof(CanGoToNextPeriod))] [RelayCommand(CanExecute = nameof(CanGoToNextPeriod))]

View File

@@ -18,10 +18,8 @@ namespace Clario.ViewModels;
public partial class DashboardViewModel : ViewModelBase public partial class DashboardViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
public required List<Transaction> Transactions = new(); public GeneralDataRepo AppData => DataRepo.General;
public required List<Category> Categories = new(); // public required List<Account> Accounts = new();
public required List<Budget> Budgets = new();
public required List<Account> Accounts = new();
[ObservableProperty] private ObservableCollection<ColumnChartData> _spendingByCategoryChartData = new(); [ObservableProperty] private ObservableCollection<ColumnChartData> _spendingByCategoryChartData = new();
[ObservableProperty] private ISeries[] _spendingByCategoryChartSeries = new ISeries[] { }; [ObservableProperty] private ISeries[] _spendingByCategoryChartSeries = new ISeries[] { };
@@ -31,19 +29,37 @@ public partial class DashboardViewModel : ViewModelBase
[ObservableProperty] private decimal _totalNetworth; [ObservableProperty] private decimal _totalNetworth;
[ObservableProperty] private decimal _monthlyIncome; [ObservableProperty] private decimal _monthlyIncome;
private decimal _monthlyIncomeChange; private decimal _monthlyIncomeChange;
private bool _hasLastMonthIncome;
public int MaxChartWidth => SpendingByCategoryChartData.Count * 150; public int MaxChartWidth => SpendingByCategoryChartData.Count * 150;
public string MonthlyIncomeChangeFormatted => _monthlyIncomeChange >= 0 public string MonthlyIncomeChangeFormatted
{
get
{
if (!_hasLastMonthIncome)
return MonthlyIncome > 0 ? "NEW" : "—";
return _monthlyIncomeChange >= 0
? "↑ " + _monthlyIncomeChange.ToString("0.0%") ? "↑ " + _monthlyIncomeChange.ToString("0.0%")
: "↓ " + _monthlyIncomeChange.ToString("0.0%"); : "↓ " + _monthlyIncomeChange.ToString("0.0%");
}
}
[ObservableProperty] private decimal _monthlyExpenses; [ObservableProperty] private decimal _monthlyExpenses;
private decimal _monthlyExpensesChange; private decimal _monthlyExpensesChange;
private bool _hasLastMonthExpenses;
public string MonthlyExpenseChangeFormatted => _monthlyExpensesChange >= 0 public string MonthlyExpenseChangeFormatted
{
get
{
if (!_hasLastMonthExpenses)
return MonthlyExpenses > 0 ? "NEW" : "—";
return _monthlyExpensesChange >= 0
? "↑ " + _monthlyExpensesChange.ToString("0.0%") ? "↑ " + _monthlyExpensesChange.ToString("0.0%")
: "↓ " + _monthlyExpensesChange.ToString("0.0%"); : "↓ " + _monthlyExpensesChange.ToString("0.0%");
}
}
public string AccountsSubtitle => public string AccountsSubtitle =>
AccountsSummaryData.Count == 1 ? $" {AccountsSummaryData.Count} linked Account" : $"{AccountsSummaryData.Count} linked Accounts"; AccountsSummaryData.Count == 1 ? $" {AccountsSummaryData.Count} linked Account" : $"{AccountsSummaryData.Count} linked Accounts";
@@ -61,6 +77,8 @@ public partial class DashboardViewModel : ViewModelBase
}; };
[ObservableProperty] private string _selectedChartTimePeriod = "This Month"; [ObservableProperty] private string _selectedChartTimePeriod = "This Month";
[ObservableProperty] private string _selectedChartTimPeriodSubTitle = DateTime.Now.ToString("MMMM yyyy");
[ObservableProperty] private string _dateToday = DateTime.Now.ToString("dddd, MMMM d, yyyy");
partial void OnSelectedChartTimePeriodChanged(string value) partial void OnSelectedChartTimePeriodChanged(string value)
{ {
@@ -73,11 +91,25 @@ public partial class DashboardViewModel : ViewModelBase
_ => ChartTimePeriod.ThisMonth _ => ChartTimePeriod.ThisMonth
}; };
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(period);
} }
public DashboardViewModel() 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();
initialize();
} }
public void initialize() public void initialize()
@@ -88,36 +120,44 @@ public partial class DashboardViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void UpdateUserOverview() private void UpdateUserOverview()
{ {
var thisMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1); CalculateMonthlyValues();
var lastMonth = thisMonth.AddMonths(-1);
MonthlyIncome = Transactions.Where(x => x.Type == "income" && x.Date.Month == thisMonth.Month && x.Date.Year == thisMonth.Year)
.Sum(x => x.Amount);
MonthlyExpenses = Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month && x.Date.Year == DateTime.Now.Year)
.Sum(x => x.Amount);
var lastMonthIncome = Transactions.Where(x => x.Type == "income" && x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year)
.Sum(x => x.Amount);
var lastMonthExpenses = Transactions.Where(x => x.Type == "expense" && x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year)
.Sum(x => x.Amount);
try
{
_monthlyIncomeChange = Math.Round((MonthlyIncome / ((lastMonthIncome == 0) ? 1 : lastMonthIncome)) - 1, 2);
_monthlyExpensesChange = Math.Round((MonthlyExpenses / ((lastMonthExpenses == 0) ? 1 : lastMonthExpenses)) - 1, 2);
}
catch (Exception e)
{
Console.WriteLine(e);
}
OnPropertyChanged(nameof(MonthlyIncomeChangeFormatted));
OnPropertyChanged(nameof(MonthlyExpenseChangeFormatted));
UpdateSpendingByCategoryChart(); UpdateSpendingByCategoryChart();
_ = UpdateBudgetTracker(); _ = UpdateBudgetTracker();
UpdateRecentTransactions(); UpdateRecentTransactions();
UpdateAccountsSummary(); UpdateAccountsSummary();
} }
private void CalculateMonthlyValues()
{
var thisMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var lastMonth = thisMonth.AddMonths(-1);
MonthlyIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == thisMonth.Month && x.Date.Year == thisMonth.Year)
.Sum(x => x.Amount);
MonthlyExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month && x.Date.Year == DateTime.Now.Year)
.Sum(x => x.Amount);
var lastMonthIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year)
.Sum(x => x.Amount);
var lastMonthExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year)
.Sum(x => x.Amount);
_hasLastMonthIncome = lastMonthIncome > 0;
_hasLastMonthExpenses = lastMonthExpenses > 0;
if (_hasLastMonthIncome)
{
_monthlyIncomeChange = Math.Round((MonthlyIncome / lastMonthIncome) - 1, 2);
}
if (_hasLastMonthExpenses)
{
_monthlyExpensesChange = Math.Round((MonthlyExpenses / lastMonthExpenses) - 1, 2);
}
OnPropertyChanged(nameof(MonthlyIncomeChangeFormatted));
OnPropertyChanged(nameof(MonthlyExpenseChangeFormatted));
}
[RelayCommand] [RelayCommand]
private void ViewAllTransactions() private void ViewAllTransactions()
{ {
@@ -137,10 +177,10 @@ public partial class DashboardViewModel : ViewModelBase
{ {
var tempList = new List<ColumnChartData>(); var tempList = new List<ColumnChartData>();
foreach (var category in Categories) foreach (var category in AppData.Categories)
{ {
var categoryTransactions = var categoryTransactions =
Transactions.Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase)); AppData.Transactions.Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase));
switch (period) switch (period)
{ {
@@ -196,20 +236,20 @@ public partial class DashboardViewModel : ViewModelBase
private void UpdateRecentTransactions() private void UpdateRecentTransactions()
{ {
RecentTransactions = new ObservableCollection<Transaction>(Transactions.OrderByDescending(x => x.Date).Take(5)); RecentTransactions = new ObservableCollection<Transaction>(AppData.Transactions.OrderByDescending(x => x.Date).Take(5));
OnPropertyChanged(nameof(HasTransactionData)); OnPropertyChanged(nameof(HasTransactionData));
} }
private void UpdateAccountsSummary() private void UpdateAccountsSummary()
{ {
foreach (var account in Accounts) foreach (var account in AppData.Accounts)
{ {
var accountTransactions = Transactions.Where(t => t.AccountId == account.Id).ToList(); var accountTransactions = AppData.Transactions.Where(t => t.AccountId == account.Id).ToList();
account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount); account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount);
TotalNetworth += account.CurrentBalance; TotalNetworth += account.CurrentBalance;
} }
AccountsSummaryData = new ObservableCollection<Account>(Accounts.OrderBy(x => x.CreatedAt)); AccountsSummaryData = new ObservableCollection<Account>(AppData.Accounts.OrderBy(x => x.CreatedAt));
OnPropertyChanged(nameof(AccountsSubtitle)); OnPropertyChanged(nameof(AccountsSubtitle));
} }

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Clario.ViewModels;
public partial class DeleteAccountDialogViewModel : ViewModelBase
{
// ── State machine ────────────────────────────────────────
public enum DialogStep
{
SimpleConfirm,
HasTransactions,
Migrate
}
[ObservableProperty]
[NotifyPropertyChangedFor(
nameof(IsSimpleConfirmStep),
nameof(IsHasTransactionsStep),
nameof(IsMigrateStep))]
private DialogStep _currentStep;
public bool IsSimpleConfirmStep => CurrentStep == DialogStep.SimpleConfirm;
public bool IsHasTransactionsStep => CurrentStep == DialogStep.HasTransactions;
public bool IsMigrateStep => CurrentStep == DialogStep.Migrate;
// ── Data ─────────────────────────────────────────────────
[ObservableProperty] private Account? _account;
public GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(CanMigrateAndDelete))]
private Account? _targetAccount;
[ObservableProperty] private ObservableCollection<Account> _availableAccounts = new();
// ── Validation ───────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool CanMigrateAndDelete =>
TargetAccount is not null &&
TargetAccount.Id != Account?.Id;
// ── Callbacks ────────────────────────────────────────────
public Action? OnDeleted;
public Action? OnCancelled;
// ── Setup ────────────────────────────────────────────────
/// <summary>
/// Call this to open the dialog for a specific account.
/// Automatically determines whether to show simple confirm or migrate warning.
/// </summary>
public void Setup(Account account, ObservableCollection<Account> allAccounts)
{
Account = account;
ErrorMessage = null;
// filter out the account being deleted from target options
var others = allAccounts
.Where(a => a.Id != account.Id && !a.GroupHeader)
.ToList();
AvailableAccounts = new ObservableCollection<Account>(others);
TargetAccount = AvailableAccounts.FirstOrDefault();
// decide which step to show based on transaction count
CurrentStep = account.TransactionsCount > 0
? DialogStep.HasTransactions
: DialogStep.SimpleConfirm;
}
// ── Commands ─────────────────────────────────────────────
[RelayCommand]
private void Cancel() => OnCancelled?.Invoke();
[RelayCommand]
private void GoToMigrateStep()
{
ErrorMessage = null;
CurrentStep = DialogStep.Migrate;
}
[RelayCommand]
private void BackToWarning()
{
ErrorMessage = null;
CurrentStep = DialogStep.HasTransactions;
}
[RelayCommand]
private async Task ConfirmDelete()
{
if (Account is null) return;
ErrorMessage = null;
try
{
await DataRepo.General.DeleteAccount(Account.Id);
OnDeleted?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Failed to delete account. Please try again.";
Console.WriteLine(ex);
}
}
[RelayCommand]
private async Task MigrateAndDelete()
{
if (Account is null || TargetAccount is null)
{
ErrorMessage = "Please select a target account.";
return;
}
if (TargetAccount.Id == Account.Id)
{
ErrorMessage = "Target account must be different from the account being deleted.";
return;
}
ErrorMessage = null;
try
{
// 1. re-link all transactions from deleted account to target
await DataRepo.General.MigrateTransactions(Account.Id, TargetAccount.Id);
// 2. recalculate balances on both accounts
await DataRepo.General.RecalculateAccountBalance(TargetAccount.Id);
// 3. delete the account
await DataRepo.General.DeleteAccount(Account.Id);
OnDeleted?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Migration failed. Please try again.";
Console.WriteLine(ex);
}
}
}

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -12,26 +11,33 @@ using Clario.Models.GeneralModels;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Clario.Services; using Clario.Services;
using CommunityToolkit.Mvvm.Messaging;
namespace Clario.ViewModels; namespace Clario.ViewModels;
public partial class MainViewModel : ViewModelBase public partial class MainViewModel : ViewModelBase
{ {
private DashboardViewModel _dashboardViewModel; private DashboardViewModel _dashboardViewModel = null!;
public TransactionsViewModel _transactionsViewModel; public TransactionsViewModel _transactionsViewModel = null!;
private AccountsViewModel _accountsViewModel; private AccountsViewModel _accountsViewModel = null!;
private BudgetViewModel _budgetViewModel; private BudgetViewModel _budgetViewModel = null!;
[ObservableProperty] private TransactionFormViewModel _transactionFormViewModel;
[ObservableProperty] public Profile? _profile;
private List<Transaction> _transactions = new();
private List<Category> _categories = new();
private List<Budget> _budgets = new();
private List<Account> _accounts = new();
GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private Profile? _profile;
[ObservableProperty] private TransactionFormViewModel _transactionFormViewModel = null!;
[ObservableProperty] private AccountFormViewModel _accountFormViewModel = null!;
[ObservableProperty] private BudgetFormViewModel _budgetFormViewModel = null!;
[ObservableProperty] private SettingsViewModel _settingsViewModel = null!;
[ObservableProperty] private bool _isDimmed;
[ObservableProperty] private bool _isTransactionFormVisible; [ObservableProperty] private bool _isTransactionFormVisible;
[ObservableProperty] private bool _isAccountFormVisible;
[ObservableProperty] private bool _isBudgetFormVisible;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget))] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnSettings))]
private ViewModelBase? _currentView; private ViewModelBase? _currentView;
[ObservableProperty] private bool _isDarkTheme; [ObservableProperty] private bool _isDarkTheme;
@@ -39,6 +45,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel() public MainViewModel()
{ {
Console.WriteLine("main vm loaded"); Console.WriteLine("main vm loaded");
WeakReferenceMessenger.Default.Register<ProfileUpdated>(this, (_, m) => { Profile = AppData.Profile; });
CurrentView = new LoadingViewModel(); CurrentView = new LoadingViewModel();
_ = InitializeApp(); _ = InitializeApp();
} }
@@ -48,72 +55,71 @@ public partial class MainViewModel : ViewModelBase
{ {
try try
{ {
var profilesTask = DataRepo.General.FetchProfileInfo(); await Task.Run(async () =>
{
var profilesTask = DataRepo.General.FetchProfileInfo(forceRefresh: true);
var categoriesTask = DataRepo.General.FetchCategories(); var categoriesTask = DataRepo.General.FetchCategories();
var accountsTask = DataRepo.General.FetchAccounts();
var transactionsTask = DataRepo.General.FetchTransactions(); var transactionsTask = DataRepo.General.FetchTransactions();
var accountsTask = DataRepo.General.FetchAccounts();
var budgetsTask = DataRepo.General.FetchBudgets(); var budgetsTask = DataRepo.General.FetchBudgets();
await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask); await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask);
Profile = profilesTask.Result; Profile = profilesTask.Result;
_categories = categoriesTask.Result;
_accounts = accountsTask.Result; DataRepo.General.LinkTransactionCategories();
_transactions = transactionsTask.Result;
_budgets = budgetsTask.Result;
Console.WriteLine("fetched all data"); Console.WriteLine("fetched all data");
});
_dashboardViewModel = new DashboardViewModel() _dashboardViewModel = new DashboardViewModel()
{ {
parentViewModel = this, parentViewModel = this
Transactions = _transactions,
Categories = _categories,
Accounts = _accounts,
Budgets = _budgets
}; };
_dashboardViewModel.initialize();
CurrentView = _dashboardViewModel; CurrentView = _dashboardViewModel;
Console.WriteLine("initialized DashboardViewModel"); Console.WriteLine("initialized DashboardViewModel");
_transactionsViewModel = new TransactionsViewModel() _transactionsViewModel = new TransactionsViewModel()
{ {
parentViewModel = this, parentViewModel = this
AllTransactions = _transactions.OrderByDescending(x => x.Date).ToList(),
Categories = new ObservableCollection<Category>(_categories.OrderBy(x => x.CreatedAt)),
Accounts = new ObservableCollection<Account>(_accounts.OrderBy(x => x.CreatedAt))
}; };
await _transactionsViewModel.Initialize();
Console.WriteLine("initialized TransactionsViewModel"); Console.WriteLine("initialized TransactionsViewModel");
_accountsViewModel = new AccountsViewModel() _accountsViewModel = new AccountsViewModel()
{ {
parentViewModel = this, parentViewModel = this
Accounts = _accounts,
Transactions = _transactions
}; };
await _accountsViewModel.Initialize();
Console.WriteLine("initialized AccountsViewModel"); Console.WriteLine("initialized AccountsViewModel");
_budgetViewModel = new BudgetViewModel() _budgetViewModel = new BudgetViewModel()
{ {
parentViewModel = this, parentViewModel = this
Profile = Profile,
Budgets = _budgets,
Categories = _categories,
Transactions = _transactions
}; };
await _budgetViewModel.Initialize();
Console.WriteLine("initialized BudgetViewModel"); Console.WriteLine("initialized BudgetViewModel");
SettingsViewModel = new SettingsViewModel()
{
parentViewModel = this
};
Console.WriteLine("initialized SettingsViewModel");
TransactionFormViewModel = new TransactionFormViewModel() TransactionFormViewModel = new TransactionFormViewModel()
{ {
parentViewModel = this parentViewModel = this
}; };
Console.WriteLine("initialized TransactionFormViewModel"); Console.WriteLine("initialized TransactionFormViewModel");
AccountFormViewModel = new AccountFormViewModel()
{
parentViewModel = this
};
Console.WriteLine("initialized AccountFormViewModel");
BudgetFormViewModel = new BudgetFormViewModel()
{
parentViewModel = this
};
Console.WriteLine("initialized BudgetFormViewModel");
IsDarkTheme = ThemeService.IsDarkTheme; IsDarkTheme = ThemeService.IsDarkTheme;
ThemeService.SwitchToTheme(Profile?.Theme ?? "system"); ThemeService.SwitchToTheme(AppData.Profile?.Theme ?? "system");
} }
catch (Exception e) catch (Exception e)
{ {
@@ -124,42 +130,15 @@ public partial class MainViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
public void OpenAddTransaction() public void OpenAddTransaction()
{ {
if (IsTransactionFormVisible) return; if (IsDimmed) return;
try try
{ {
TransactionFormViewModel.SetupForAdd( TransactionFormViewModel.SetupForAdd();
new ObservableCollection<Category>(_categories), TransactionFormViewModel.OnSaved = CloseTransactionForm;
new ObservableCollection<Account>(_accounts) TransactionFormViewModel.OnCancelled = CloseTransactionForm;
); TransactionFormViewModel.OnDeleted = CloseTransactionForm;
TransactionFormViewModel.OnSaved = () =>
{
if (TransactionFormViewModel.ResultTransaction is not null)
{
var previousItem = _transactionsViewModel.AllTransactions.FirstOrDefault(x => x.Date < TransactionFormViewModel.ResultTransaction.Date);
var index = 0;
if (previousItem is not null)
index = _transactionsViewModel.AllTransactions.IndexOf(previousItem);
if (index == -1) index = 0;
_transactionsViewModel.AllTransactions.Insert(index, TransactionFormViewModel.ResultTransaction);
_dashboardViewModel.Transactions.Insert(index, TransactionFormViewModel.ResultTransaction);
_dashboardViewModel.UpdateUserOverviewCommand.Execute(null);
_transactionsViewModel.LoadPageCommand.Execute(1);
}
CloseTransactionForm();
};
TransactionFormViewModel.OnCancelled = () => CloseTransactionForm();
TransactionFormViewModel.OnDeleted = () =>
{
if (TransactionFormViewModel.ResultTransaction is { } resultTransaction)
{
_transactionsViewModel.AllTransactions.Remove(resultTransaction);
_transactionsViewModel.LoadPageCommand.Execute(1);
}
CloseTransactionForm();
};
IsTransactionFormVisible = true; IsTransactionFormVisible = true;
IsDimmed = true;
} }
catch (Exception e) catch (Exception e)
{ {
@@ -171,41 +150,111 @@ public partial class MainViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
public void OpenEditTransaction(Transaction transaction) public void OpenEditTransaction(Transaction transaction)
{ {
TransactionFormViewModel.SetupForEdit( if (IsDimmed) return;
transaction, TransactionFormViewModel.SetupForEdit(transaction);
new ObservableCollection<Category>(_categories), TransactionFormViewModel.OnSaved = CloseTransactionForm;
new ObservableCollection<Account>(_accounts)
);
TransactionFormViewModel.OnSaved = () =>
{
if (TransactionFormViewModel.ResultTransaction is { } resultTransaction)
{
var index = _transactionsViewModel.AllTransactions.FindIndex(x => x.Id == transaction.Id);
if (index != -1)
_transactionsViewModel.AllTransactions[index] = resultTransaction;
_transactionsViewModel.LoadPageCommand.Execute(1);
}
CloseTransactionForm();
};
TransactionFormViewModel.OnCancelled = CloseTransactionForm; TransactionFormViewModel.OnCancelled = CloseTransactionForm;
TransactionFormViewModel.OnDeleted = () => TransactionFormViewModel.OnDeleted = CloseTransactionForm;
{
if (TransactionFormViewModel.ResultTransaction is { } resultTransaction)
{
_transactionsViewModel.AllTransactions.Remove(resultTransaction);
_transactionsViewModel.LoadPageCommand.Execute(1);
}
CloseTransactionForm();
};
IsTransactionFormVisible = true; IsTransactionFormVisible = true;
IsDimmed = true;
} }
private void CloseTransactionForm() private void CloseTransactionForm()
{ {
IsTransactionFormVisible = false; IsTransactionFormVisible = false;
IsDimmed = false;
}
[RelayCommand]
public void OpenAddAccount()
{
if (IsDimmed) return;
try
{
AccountFormViewModel.SetupForAdd();
AccountFormViewModel.OnSaved = CloseAccountForm;
AccountFormViewModel.OnCancelled = CloseAccountForm;
IsAccountFormVisible = true;
IsDimmed = true;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
[RelayCommand]
public void OpenEditAccount(Account account)
{
if (IsDimmed) return;
try
{
AccountFormViewModel.SetupForEdit(account);
AccountFormViewModel.OnSaved = CloseAccountForm;
AccountFormViewModel.OnCancelled = CloseAccountForm;
IsAccountFormVisible = true;
IsDimmed = true;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
private void CloseAccountForm()
{
IsAccountFormVisible = false;
IsDimmed = false;
}
[RelayCommand]
private void OpenAddBudget()
{
if (IsDimmed) return;
try
{
var unusedCategories = AppData.Categories.Where(x => AppData.Budgets.All(y => y.Category?.Id != x.Id)).ToList();
BudgetFormViewModel.SetupForAdd(new ObservableCollection<Category>(unusedCategories));
BudgetFormViewModel.OnSaved = CloseBudgetForm;
BudgetFormViewModel.OnCancelled = CloseBudgetForm;
BudgetFormViewModel.OnDeleted = CloseBudgetForm;
IsBudgetFormVisible = true;
IsDimmed = true;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
[RelayCommand]
private void OpenEditBudget(Budget budget)
{
if (IsDimmed) return;
try
{
var unusedCategories = AppData.Categories.Where(x => AppData.Budgets.All(y => y.Category?.Id != x.Id) || x.Id == budget.CategoryId).ToList();
BudgetFormViewModel.SetupForEdit(budget, new ObservableCollection<Category>(unusedCategories));
BudgetFormViewModel.OnSaved = CloseBudgetForm;
BudgetFormViewModel.OnCancelled = CloseBudgetForm;
BudgetFormViewModel.OnDeleted = CloseBudgetForm;
IsBudgetFormVisible = true;
IsDimmed = true;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
private void CloseBudgetForm()
{
IsDimmed = false;
IsBudgetFormVisible = false;
} }
[RelayCommand] [RelayCommand]
@@ -239,13 +288,19 @@ public partial class MainViewModel : ViewModelBase
CurrentView = _budgetViewModel; CurrentView = _budgetViewModel;
} }
[RelayCommand]
private void GoToSettings()
{
CurrentView = _settingsViewModel;
}
[RelayCommand] [RelayCommand]
private async Task SignOut() private async Task SignOut()
{ {
await SupabaseService.Client.Auth.SignOut(); await SupabaseService.Client.Auth.SignOut();
var user = SupabaseService.Client.Auth.CurrentUser; var user = SupabaseService.Client.Auth.CurrentUser;
switch (Application.Current.ApplicationLifetime) switch (Application.Current?.ApplicationLifetime)
{ {
case IClassicDesktopStyleApplicationLifetime desktop: case IClassicDesktopStyleApplicationLifetime desktop:
desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel(); desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
@@ -260,4 +315,5 @@ public partial class MainViewModel : ViewModelBase
public bool isOnTransactions => CurrentView is TransactionsViewModel; public bool isOnTransactions => CurrentView is TransactionsViewModel;
public bool isOnAccounts => CurrentView is AccountsViewModel; public bool isOnAccounts => CurrentView is AccountsViewModel;
public bool isOnBudget => CurrentView is BudgetViewModel; public bool isOnBudget => CurrentView is BudgetViewModel;
public bool isOnSettings => CurrentView is SettingsViewModel;
} }

View File

@@ -0,0 +1,418 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Clario.Data;
using Clario.Models;
using Clario.Models.GeneralModels;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
namespace Clario.ViewModels;
public partial class SettingsViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
public static readonly HttpClient _HttpClient = new();
// ── Profile fields ───────────────────────────────────────
[ObservableProperty] private string _displayName = "";
[ObservableProperty] private string _avatarUrl = "";
[ObservableProperty] private Bitmap? _avatarImage;
[ObservableProperty] private string _selectedCurrency = "USD";
[ObservableProperty] private string _selectedTheme = "system";
[ObservableProperty] private string _selectedLanguage = "en";
// ── Account (auth) fields ────────────────────────────────
[ObservableProperty] private string _maskedEmail = "";
private string _fullEmail = "";
// ── Change email flow ────────────────────────────────────
[ObservableProperty] private bool _isChangingEmail = false;
[ObservableProperty] private string _newEmail = "";
[ObservableProperty] private string _emailConfirmPassword = "";
// ── Change password flow ─────────────────────────────────
[ObservableProperty] private bool _isChangingPassword = false;
[ObservableProperty] private string _currentPassword = "";
[ObservableProperty] private string _newPassword = "";
[ObservableProperty] private string _confirmNewPassword = "";
// ── UI state ─────────────────────────────────────────────
[ObservableProperty] private bool _isSaving = false;
[ObservableProperty] private bool _isUploadingAvatar = false;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSuccess))]
private string? _successMessage;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasEmailSuccess))]
private string? _emailSuccessMessage;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasEmailError))]
private string? _emailErrorMessage;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasPasswordSuccess))]
private string? _passwordSuccessMessage;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasPasswordError))]
private string? _passwordErrorMessage;
public bool HasSuccess => !string.IsNullOrEmpty(SuccessMessage);
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool HasEmailSuccess => !string.IsNullOrEmpty(EmailSuccessMessage);
public bool HasEmailError => !string.IsNullOrEmpty(EmailErrorMessage);
public bool HasPasswordSuccess => !string.IsNullOrEmpty(PasswordSuccessMessage);
public bool HasPasswordError => !string.IsNullOrEmpty(PasswordErrorMessage);
public bool HasAvatar => !string.IsNullOrEmpty(AvatarUrl);
// ── Options ──────────────────────────────────────────────
public ObservableCollection<string> Currencies { get; } = new()
{
"USD", "EUR", "GBP", "JPY", "AED", "SAR", "JOD",
"EGP", "CAD", "AUD", "CHF", "CNY", "INR", "BRL"
};
public ObservableCollection<(string Value, string Label)> Themes { get; } = new()
{
("system", "System default"),
("dark", "Dark"),
("light", "Light")
};
public ObservableCollection<string> ThemeLabels { get; } = new()
{
"System default", "Dark", "Light"
};
public ObservableCollection<(string Value, string Label)> Languages { get; } = new()
{
("en", "English"),
("ar", "العربية"),
};
public ObservableCollection<string> LanguageLabels { get; } = new()
{
"English", "العربية"
};
// ComboBox selected indices (mapped to/from string values)
[ObservableProperty] private int _selectedThemeIndex = 0;
[ObservableProperty] private int _selectedLanguageIndex = 0;
partial void OnSelectedThemeIndexChanged(int value)
{
SelectedTheme = value switch { 0 => "system", 1 => "dark", 2 => "light", _ => "system" };
}
partial void OnSelectedLanguageIndexChanged(int value)
{
SelectedLanguage = value switch { 0 => "en", 1 => "ar", _ => "en" };
}
// ── Init ─────────────────────────────────────────────────
public SettingsViewModel()
{
_ = Initialize();
WeakReferenceMessenger.Default.Register<ProfileUpdated>(this, async (_, m) => { await Initialize(); });
}
public async Task Initialize()
{
DisplayName = AppData.Profile?.DisplayName ?? "";
AvatarUrl = DataRepo.General.BuildPublicUrl(AppData.Profile?.AvatarUrl) ?? "";
AvatarImage = AppData.Profile?.Avatar;
SelectedCurrency = AppData.Profile?.Currency ?? "USD";
SelectedTheme = AppData.Profile?.Theme ?? "system";
SelectedLanguage = AppData.Profile?.Language ?? "en";
// sync indices
SelectedThemeIndex = SelectedTheme switch { "dark" => 1, "light" => 2, _ => 0 };
SelectedLanguageIndex = SelectedLanguage switch { "ar" => 1, _ => 0 };
// mask email
_fullEmail = SupabaseService.Client.Auth.CurrentUser?.Email ?? "";
MaskedEmail = MaskEmail(_fullEmail);
}
private static string MaskEmail(string email)
{
if (string.IsNullOrEmpty(email)) return "";
var atIndex = email.IndexOf('@');
if (atIndex <= 2) return email; // too short to mask
var local = email[..atIndex];
var domain = email[atIndex..];
var visible = local[..2];
var masked = new string('•', Math.Min(local.Length - 2, 5));
return $"{visible}{masked}{domain}";
}
// ── Avatar commands ───────────────────────────────────────
[RelayCommand]
private async Task UploadAvatar()
{
var file = await FilePickerService.Instance.PickImageAsync();
if (file is null) return;
IsUploadingAvatar = true;
ErrorMessage = null;
SuccessMessage = null;
try
{
var localPath = file.Path.LocalPath;
var url = await DataRepo.General.UploadAvatarAsync(localPath);
AvatarUrl = url;
// persist to profile
await DataRepo.General.UpdateProfileAvatar(url);
SuccessMessage = "Avatar updated successfully.";
await Initialize();
}
catch (Exception ex)
{
ErrorMessage = "Failed to upload avatar. Please try again.";
Console.WriteLine(ex);
}
finally
{
IsUploadingAvatar = false;
}
}
[RelayCommand]
private async Task RemoveAvatar()
{
IsUploadingAvatar = true;
ErrorMessage = null;
SuccessMessage = null;
try
{
await DataRepo.General.DeleteAvatarAsync();
await DataRepo.General.UpdateProfileAvatar(null);
AvatarUrl = "";
SuccessMessage = "Avatar removed.";
await Initialize();
}
catch (Exception ex)
{
ErrorMessage = "Failed to remove avatar.";
Console.WriteLine(ex);
}
finally
{
IsUploadingAvatar = false;
}
}
// ── Save profile ─────────────────────────────────────────
[RelayCommand]
private async Task SaveProfile()
{
if (string.IsNullOrWhiteSpace(DisplayName))
{
ErrorMessage = "Display name cannot be empty.";
return;
}
IsSaving = true;
ErrorMessage = null;
SuccessMessage = null;
try
{
var updated = new Profile
{
Id = AppData.Profile.Id,
DisplayName = DisplayName.Trim(),
Currency = SelectedCurrency,
Theme = SelectedTheme,
Language = SelectedLanguage,
AvatarUrl = AppData.Profile.AvatarUrl,
Avatar = AppData.Profile.Avatar,
SavingsGoal = AppData.Profile.SavingsGoal
};
await DataRepo.General.UpdateProfile(updated);
// apply theme immediately
ThemeService.SwitchToTheme(SelectedTheme);
SuccessMessage = "Profile saved successfully.";
await Initialize();
}
catch (Exception ex)
{
ErrorMessage = "Failed to save profile. Please try again.";
Console.WriteLine(ex);
}
finally
{
IsSaving = false;
}
}
// ── Change email ─────────────────────────────────────────
[RelayCommand]
private void StartChangeEmail()
{
NewEmail = "";
EmailConfirmPassword = "";
EmailErrorMessage = null;
EmailSuccessMessage = null;
IsChangingEmail = true;
}
[RelayCommand]
private void CancelChangeEmail()
{
IsChangingEmail = false;
EmailErrorMessage = null;
EmailSuccessMessage = null;
}
[RelayCommand]
private async Task ConfirmChangeEmail()
{
EmailErrorMessage = null;
EmailSuccessMessage = null;
if (string.IsNullOrWhiteSpace(NewEmail) || !NewEmail.Contains('@'))
{
EmailErrorMessage = "Please enter a valid email address.";
return;
}
if (string.IsNullOrWhiteSpace(EmailConfirmPassword))
{
EmailErrorMessage = "Please enter your current password to confirm.";
return;
}
IsSaving = true;
try
{
// re-authenticate first to confirm password
await SupabaseService.Client.Auth.SignIn(_fullEmail, EmailConfirmPassword);
// update email — Supabase sends confirmation to the new address
await SupabaseService.Client.Auth.Update(new Supabase.Gotrue.UserAttributes
{
Email = NewEmail.Trim()
});
EmailSuccessMessage = "Confirmation sent to your new email address. Please check your inbox.";
IsChangingEmail = false;
}
catch (Exception ex)
{
EmailErrorMessage = "Failed to update email. Check your password and try again.";
Console.WriteLine(ex);
}
finally
{
IsSaving = false;
}
}
// ── Change password ──────────────────────────────────────
[RelayCommand]
private void StartChangePassword()
{
CurrentPassword = "";
NewPassword = "";
ConfirmNewPassword = "";
PasswordErrorMessage = null;
PasswordSuccessMessage = null;
IsChangingPassword = true;
}
[RelayCommand]
private void CancelChangePassword()
{
IsChangingPassword = false;
PasswordErrorMessage = null;
PasswordSuccessMessage = null;
}
[RelayCommand]
private async Task ConfirmChangePassword()
{
PasswordErrorMessage = null;
PasswordSuccessMessage = null;
if (string.IsNullOrWhiteSpace(CurrentPassword))
{
PasswordErrorMessage = "Please enter your current password.";
return;
}
if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8)
{
PasswordErrorMessage = "New password must be at least 8 characters.";
return;
}
if (NewPassword != ConfirmNewPassword)
{
PasswordErrorMessage = "Passwords do not match.";
return;
}
IsSaving = true;
try
{
// re-authenticate to confirm current password
await SupabaseService.Client.Auth.SignIn(_fullEmail, CurrentPassword);
await SupabaseService.Client.Auth.Update(new Supabase.Gotrue.UserAttributes
{
Password = NewPassword
});
PasswordSuccessMessage = "Password updated successfully.";
IsChangingPassword = false;
}
catch (Exception ex)
{
PasswordErrorMessage = "Failed to update password. Check your current password and try again.";
Console.WriteLine(ex);
}
finally
{
IsSaving = false;
}
}
// ── Sign out ─────────────────────────────────────────────
[RelayCommand]
private async Task SignOut()
{
try
{
await ((MainViewModel)parentViewModel).SignOutCommand.ExecuteAsync(null);
}
catch (Exception ex)
{
ErrorMessage = "Failed to sign out.";
Console.WriteLine(ex);
}
}
}

View File

@@ -13,6 +13,7 @@ namespace Clario.ViewModels;
public partial class TransactionFormViewModel : ViewModelBase public partial class TransactionFormViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
// ── Mode ──────────────────────────────────────────────── // ── Mode ────────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))] [ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
@@ -34,6 +35,7 @@ public partial class TransactionFormViewModel : ViewModelBase
[ObservableProperty] private string? _note; [ObservableProperty] private string? _note;
[ObservableProperty] private List<DateTime> _dates = [DateTime.Now]; [ObservableProperty] private List<DateTime> _dates = [DateTime.Now];
[ObservableProperty] private DateTime? _selectedDate;
[ObservableProperty] private string _currency = "USD"; [ObservableProperty] private string _currency = "USD";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))] [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
@@ -215,47 +217,43 @@ public partial class TransactionFormViewModel : ViewModelBase
// ── Public setup methods ───────────────────────────────── // ── Public setup methods ─────────────────────────────────
/// <summary>Call this to open the form for adding a new transaction.</summary> /// <summary>Call this to open the form for adding a new transaction.</summary>
public void SetupForAdd( public void SetupForAdd()
ObservableCollection<Category> categories,
ObservableCollection<Account> accounts)
{ {
ShowDeleteConfirm = false; ShowDeleteConfirm = false;
IsEditMode = false; IsEditMode = false;
_editingId = null; _editingId = null;
Categories = categories; Categories = AppData.Categories;
Accounts = accounts; Accounts = AppData.Accounts;
Type = "expense"; Type = "expense";
Amount = ""; Amount = "";
Description = ""; Description = "";
Note = null; Note = null;
Dates = [DateTime.Now]; Dates = [DateTime.Now];
ErrorMessage = null; ErrorMessage = null;
SelectedCategory = categories.Count > 0 ? categories[0] : null; SelectedCategory = AppData.Categories.Count > 0 ? AppData.Categories[0] : null;
SelectedAccount = accounts.Count > 0 ? accounts[0] : null; SelectedAccount = AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null;
ResultTransaction = null; ResultTransaction = null;
} }
/// <summary>Call this to open the form for editing an existing transaction.</summary> /// <summary>Call this to open the form for editing an existing transaction.</summary>
public void SetupForEdit( public void SetupForEdit(
Transaction transaction, Transaction transaction)
ObservableCollection<Category> categories,
ObservableCollection<Account> accounts)
{ {
ShowDeleteConfirm = false; ShowDeleteConfirm = false;
IsEditMode = true; IsEditMode = true;
_editingId = transaction.Id; _editingId = transaction.Id;
Categories = categories; Categories = AppData.Categories;
Accounts = accounts; Accounts = AppData.Accounts;
Type = transaction.Type; Type = transaction.Type;
Amount = transaction.Amount.ToString("0.00"); Amount = transaction.Amount.ToString("0.00");
Description = transaction.Description; Description = transaction.Description;
Note = transaction.Note; Note = transaction.Note;
Dates = [transaction.Date]; Dates = [transaction.Date];
ErrorMessage = null; ErrorMessage = null;
SelectedCategory = categories.FirstOrDefault(c => c.Id == transaction.CategoryId) SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId)
?? (categories.Count > 0 ? categories[0] : null); ?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null);
SelectedAccount = accounts.FirstOrDefault(a => a.Id == transaction.AccountId) SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId)
?? (accounts.Count > 0 ? accounts[0] : null); ?? (AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null);
ResultTransaction = transaction; ResultTransaction = transaction;
} }
} }

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Clario.Data; using Clario.Data;
using Clario.Messages; using Clario.Messages;
using Clario.Models; using Clario.Models;
@@ -11,20 +10,22 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
// ReSharper disable PossibleMultipleEnumeration
namespace Clario.ViewModels; namespace Clario.ViewModels;
public partial class TransactionsViewModel : ViewModelBase public partial class TransactionsViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
public List<Transaction> AllTransactions = new();
[ObservableProperty] private ObservableCollection<Category> _categories = new(); [ObservableProperty] private ObservableCollection<Category> _categories = new();
[ObservableProperty] private ObservableCollection<Account> _accounts = new(); [ObservableProperty] private ObservableCollection<Account> _accounts = new();
[ObservableProperty] private List<Transaction> _filteredTransactions = new(); [ObservableProperty] private List<Transaction> _filteredTransactions = new();
private int _pageSize = 25; private int _pageSize = 25;
[ObservableProperty] private int _pageSizeIndex = 0; [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; private int _currentPage = 1;
@@ -56,8 +57,8 @@ public partial class TransactionsViewModel : ViewModelBase
public List<int> PageNumbers { get; set; } public List<int> PageNumbers { get; set; }
[ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new(); [ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new();
public int TotalPages => (int)Math.Ceiling(_filteredTransactions.Count / (double)_pageSize); public int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
public bool HasNoTransactions => _filteredTransactions.Count == 0; public bool HasNoTransactions => FilteredTransactions.Count == 0;
public bool HasNextPage => CurrentPage < TotalPages; public bool HasNextPage => CurrentPage < TotalPages;
public bool HasPreviousPage => CurrentPage > 1; public bool HasPreviousPage => CurrentPage > 1;
@@ -65,6 +66,7 @@ public partial class TransactionsViewModel : ViewModelBase
[ObservableProperty] private double _totalIncome; [ObservableProperty] private double _totalIncome;
[ObservableProperty] private int _expensesCount; [ObservableProperty] private int _expensesCount;
[ObservableProperty] private int _incomeCount; [ObservableProperty] private int _incomeCount;
[ObservableProperty] private string _dateRangeLabel = "";
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private Category _selectedCategory; [ObservableProperty] private Category _selectedCategory;
@@ -84,6 +86,13 @@ public partial class TransactionsViewModel : ViewModelBase
public TransactionsViewModel() public TransactionsViewModel()
{ {
AppData.Transactions.CollectionChanged += (_, _) =>
{
InitializeCategories();
InitializeAccounts();
LoadPage(1);
};
Initialize();
} }
partial void OnPageSizeIndexChanged(int value) partial void OnPageSizeIndexChanged(int value)
@@ -121,8 +130,7 @@ public partial class TransactionsViewModel : ViewModelBase
{ {
ApplyFilters(); ApplyFilters();
if (CurrentPage != page) CurrentPage = page; if (CurrentPage != page) CurrentPage = page;
var items = _filteredTransactions var items = FilteredTransactions.Skip((page - 1) * _pageSize)
.Skip((page - 1) * _pageSize)
.Take(_pageSize); .Take(_pageSize);
OnPropertyChanged(nameof(HasNoTransactions)); OnPropertyChanged(nameof(HasNoTransactions));
@@ -130,9 +138,9 @@ public partial class TransactionsViewModel : ViewModelBase
foreach (var item in items) foreach (var item in items)
PagedTransactions.Add(item); PagedTransactions.Add(item);
PaginationSummaryText = PaginationSummaryText =
$"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, _filteredTransactions.Count))} of {_filteredTransactions.Count} transactions"; $"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, FilteredTransactions.Count))} of {FilteredTransactions.Count} transactions";
PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList(); PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList();
var numbers = GetSurrounding(PageNumbers, page, 5); var numbers = GetSurrounding(PageNumbers, page);
VisiblePageNumbers.Clear(); VisiblePageNumbers.Clear();
foreach (var number in numbers) foreach (var number in numbers)
VisiblePageNumbers.Add(number); VisiblePageNumbers.Add(number);
@@ -143,43 +151,45 @@ public partial class TransactionsViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void ApplyFilters() private void ApplyFilters()
{ {
// Console.WriteLine($"Search Text: {_searchText}"); var filtered = AppData.Transactions.Where(x =>
// Console.WriteLine($"Category: {_selectedCategory.Name}"); x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
// Console.WriteLine($"Account: {_selectedAccount.Name}"); || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
// Console.WriteLine($"Transaction Type: {_transactionType}");
var culture = new CultureInfo("en-US");
var filtered = AllTransactions.Where(x =>
x.Description.Contains(_searchText, StringComparison.OrdinalIgnoreCase)
|| x.Note.Contains(_searchText, StringComparison.OrdinalIgnoreCase));
switch (SelectedDateRangeOption) switch (SelectedDateRangeOption)
{ {
case "All Time": case "All Time":
// do nothing DateRangeLabel = "ALL TIME";
break; break;
case "Today": case "Today":
filtered = filtered.Where(x => x.Date == DateTime.Now.Date); filtered = filtered.Where(x => x.Date == DateTime.Now.Date);
DateRangeLabel = DateTime.Now.ToString("MMM d, yyyy", culture).ToUpper();
break; break;
case "This Week": case "This Week":
var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek); var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek);
var endOfWeek = startOfWeek.AddDays(6); var endOfWeek = startOfWeek.AddDays(6);
filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek); filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek);
DateRangeLabel = "THIS WEEK";
break; break;
case "This Month": case "This Month":
filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month); filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month);
DateRangeLabel = DateTime.Now.ToString("MMMM yyyy", culture).ToUpper();
break; break;
case "Last Month": case "Last Month":
var lastMonth = DateTime.Now.AddMonths(-1); var lastMonth = DateTime.Now.AddMonths(-1);
filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year); filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year);
DateRangeLabel = lastMonth.ToString("MMMM yyyy", culture).ToUpper();
break; break;
case "This Quarter": case "This Quarter":
var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3); var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3);
var endOfQuarter = startOfQuarter.AddMonths(3); var endOfQuarter = startOfQuarter.AddMonths(3);
filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter); filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter);
DateRangeLabel = $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}";
break; break;
case "This Year": case "This Year":
filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year); filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year);
DateRangeLabel = DateTime.Now.Year.ToString();
break; break;
case "Custom Range": case "Custom Range":
if (SelectedDates is not null && SelectedDates.Count > 0) if (SelectedDates is not null && SelectedDates.Count > 0)
@@ -193,24 +203,34 @@ public partial class TransactionsViewModel : ViewModelBase
var start = ordered.First(); var start = ordered.First();
var end = ordered.Last(); var end = ordered.Last();
Console.WriteLine($"first {SelectedDates.First():d} / last {SelectedDates.Last():d}");
if (SelectedDates.Count == 1) if (SelectedDates.Count == 1)
{
filtered = filtered.Where(x => x.Date.Date == start); filtered = filtered.Where(x => x.Date.Date == start);
DateRangeLabel = start.ToString("MMM dd, yyyy", culture).ToUpper();
}
else else
{
filtered = filtered.Where(x => x.Date.Date >= start && x.Date.Date <= end); 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; break;
} }
if (_selectedCategory.Name != "All Categories")
filtered = filtered.Where(x => x.CategoryId == _selectedCategory.Id);
if (_selectedAccount.Name != "All Accounts") // Calculate totals based on date-filtered transactions
filtered = filtered.Where(x => x.AccountId == _selectedAccount.Id); TotalExpenses = filtered.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.Amount));
TotalIncome = filtered.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.Amount));
if (_transactionType != "all") if (SelectedCategory.Name != "All Categories")
filtered = filtered.Where(x => x.Type == _transactionType); filtered = filtered.Where(x => x.CategoryId == SelectedCategory.Id);
if (SelectedAccount.Name != "All Accounts")
filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id);
if (TransactionType != "all")
filtered = filtered.Where(x => x.Type == TransactionType);
switch (SelectedSortOption) switch (SelectedSortOption)
{ {
@@ -297,7 +317,7 @@ public partial class TransactionsViewModel : ViewModelBase
} }
} }
public async Task Initialize() public void Initialize()
{ {
try try
{ {
@@ -320,21 +340,31 @@ public partial class TransactionsViewModel : ViewModelBase
private void InitializeCategories() private void InitializeCategories()
{ {
Categories.Insert(0, new Category() { Name = "All Categories" }); Categories.Insert(0, new Category() { Name = "All Categories" });
foreach (var appDataCategory in AppData.Categories)
{
Categories.Add(appDataCategory);
}
SelectedCategory = Categories.First(); SelectedCategory = Categories.First();
} }
private void InitializeAccounts() private void InitializeAccounts()
{ {
Accounts.Insert(0, new Account() { Name = "All Accounts" }); Accounts.Insert(0, new Account() { Name = "All Accounts" });
foreach (var appDataAccount in AppData.Accounts)
{
Accounts.Add(appDataAccount);
}
SelectedAccount = Accounts.First(); SelectedAccount = Accounts.First();
} }
private void CalculateMonthlyFinancials() private void CalculateMonthlyFinancials()
{ {
TotalExpenses = AllTransactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount)); TotalExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount));
TotalIncome = AllTransactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount)); TotalIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount));
ExpensesCount = AllTransactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month); ExpensesCount = AppData.Transactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month);
IncomeCount = AllTransactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month); IncomeCount = AppData.Transactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month);
} }
public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5) public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5)

View File

@@ -0,0 +1,328 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:cc="clr-namespace:Clario.CustomControls"
xmlns:behaviors="clr-namespace:Clario.Behaviors"
mc:Ignorable="d"
x:Class="Clario.Views.AccountFormView"
x:DataType="vm:AccountFormViewModel">
<Design.DataContext>
<vm:AccountFormViewModel />
</Design.DataContext>
<!-- ── Dim overlay ───────────────────────── -->
<Grid>
<Border Background="#70000000" />
<!-- ── Modal card ────────────────────────── -->
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="460"
BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0">
<!-- ── Header ──────────────────────── -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,24">
<Border Grid.Column="0"
CornerRadius="10"
Width="42" Height="42"
Margin="0,0,14,0">
<Border.Background>
<SolidColorBrush
Color="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedIcon, Converter={StaticResource SvgPathFromName}}"
Width="18" Height="18"
Css="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding FormTitle}"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="{Binding FormSubtitle}"
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<Button Grid.Column="2"
Background="Transparent"
BorderThickness="0"
Padding="6"
VerticalAlignment="Top"
Cursor="Hand"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="15" Height="15"
Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
<!-- ── Name ──────────────────────── -->
<TextBlock Text="NAME" Classes="label" Margin="0,0,0,6" />
<TextBox Text="{Binding Name, Mode=TwoWay}"
Watermark="e.g. Main Checking"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center"
Margin="0,0,0,16" />
<!-- ── Type ─────────────────────────── -->
<TextBlock Text="TYPE" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,16">
<ComboBox ItemsSource="{Binding AccountTypes}"
SelectedItem="{Binding SelectedType, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Padding="12,10"
FontSize="13"
HorizontalAlignment="Stretch" />
</Border>
<!-- ── Institution + Mask ──────────── -->
<Grid ColumnDefinitions="*,14,*" Margin="0,0,0,16">
<!-- Institution -->
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="INSTITUTION (OPTIONAL)" Classes="label" />
<TextBox Text="{Binding Institution, Mode=TwoWay}"
Watermark="e.g. Chase"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<!-- Mask -->
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="LAST 4 DIGITS (OPTIONAL)" Classes="label" />
<TextBox Text="{Binding Mask, Mode=TwoWay}"
Watermark="e.g. 1234"
FontSize="13"
Height="38"
Padding="12,0"
MaxLength="4"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</StackPanel>
</Grid>
<!-- ── Opening Balance + Currency ──────────── -->
<Grid ColumnDefinitions="*,14,*" Margin="0,0,0,16">
<!-- Opening Balance -->
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="OPENING BALANCE" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="12,0">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="13"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center"
Margin="0,0,6,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Text="{Binding OpeningBalance, Mode=TwoWay}"
Watermark="0.00"
FontSize="13"
Foreground="{DynamicResource TextPrimary}"
Height="38"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
</StackPanel>
<!-- Currency -->
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="CURRENCY" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<ComboBox ItemsSource="{Binding Currencies}"
SelectedItem="{Binding Currency, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Padding="12,10"
FontSize="13"
HorizontalAlignment="Stretch" />
</Border>
</StackPanel>
</Grid>
<!-- ── Credit Limit (if type is credit) ──────────── -->
<StackPanel Spacing="6" Margin="0,0,0,16" IsVisible="{Binding IsCredit}">
<TextBlock Text="CREDIT LIMIT (OPTIONAL)" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="12,0">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="13"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center"
Margin="0,0,6,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Text="{Binding CreditLimit, Mode=TwoWay}"
Watermark="0.00"
FontSize="13"
Foreground="{DynamicResource TextPrimary}"
Height="38"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
</StackPanel>
<!-- ── Opened At ──────────────────────── -->
<TextBlock Text="OPENED ON (OPTIONAL)" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,16">
<cc:DateRangePicker Classes="ghost"
SelectionMode="SingleDate"
SelectedDates="{Binding OpenedAtDates}"
HorizontalAlignment="Stretch"
Padding="12,10" />
</Border>
<!-- ── Icon + Color ──────────── -->
<Grid ColumnDefinitions="*,14,*" Margin="0,0,0,16">
<!-- Icon -->
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="ICON" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
CornerRadius="7"
Width="30" Height="30"
Margin="8,0,0,0"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush
Color="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedIcon, Converter={StaticResource SvgPathFromName}}"
Width="14" Height="14"
Css="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<ComboBox Grid.Column="1"
ItemsSource="{Binding Icons}"
SelectedItem="{Binding SelectedIcon, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Padding="8,10"
FontSize="13"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
</StackPanel>
<!-- Color -->
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="COLOR" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Height="40"
MaxWidth="194">
<ColorPicker
Color="{Binding SelectedColor,Converter={StaticResource HexToColorConverter},ConverterParameter=color}" Width="194" Height="40"
CornerRadius="{DynamicResource RadiusControl}" IsAlphaEnabled="False" IsAlphaVisible="False" IsColorPaletteVisible="False"
IsAccentColorsVisible="False">
</ColorPicker>
</Border>
</StackPanel>
</Grid>
<!-- ── Validation error ─────────────── -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,16"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- ── Actions ──────────────────────── -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding IsValid}"
Command="{Binding SaveCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="{Binding SaveButtonLabel}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Clario.Views;
public partial class AccountFormView : UserControl
{
public AccountFormView()
{
InitializeComponent();
}
}

View File

@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:views="clr-namespace:Clario.Views"
xmlns:model="clr-namespace:Clario.Models" xmlns:model="clr-namespace:Clario.Models"
x:DataType="vm:AccountsViewModel" x:DataType="vm:AccountsViewModel"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
@@ -18,7 +19,7 @@
<TextBlock Text="4 accounts" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Text="4 accounts" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="Accounts" FontSize="26" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" /> <TextBlock Text="Accounts" FontSize="26" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accented" Padding="16,9" VerticalAlignment="Center"> <Button Grid.Column="1" Classes="accented" Padding="16,9" VerticalAlignment="Center" Command="{Binding CreateAccountCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/plus.svg" Width="14" Height="14" <Svg Path="../Assets/Icons/plus.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" /> Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
@@ -81,9 +82,11 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="4"> <StackPanel Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{Binding CurrentBalance,StringFormat='$0.00'}" FontSize="15" FontWeight="Bold" <TextBlock Text="{Binding CurrentBalance,StringFormat='$0.00'}" FontSize="15" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Right" /> Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Right" />
<StackPanel Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right"
IsVisible="{Binding !isCredit}">
<Svg Width="12" Height="12"> <Svg Width="12" Height="12">
<Svg.Path> <Svg.Path>
<MultiBinding Converter="{StaticResource DecimalColorConverter}"> <MultiBinding Converter="{StaticResource DecimalColorConverter}">
@@ -123,6 +126,18 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Spacing="5" IsVisible="{Binding isCredit}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Credit Utilisation" FontSize="11"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Grid.Column="1" Text="{Binding CreditUtilizationPerc, StringFormat='0%'}" FontSize="11"
Foreground="{DynamicResource AccentYellow}"
Margin="8,0,0,0" />
</Grid>
<ProgressBar Classes="yellow"
Value="{Binding CurrentBalance, Converter={StaticResource CreditAmountConverter}}"
Minimum="0" Maximum="{Binding CreditLimit}" Width="160" Height="4" />
</StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Button> </Button>
@@ -155,7 +170,9 @@
<TextBlock Text="{Binding SelectedAccount.Name}" FontSize="14" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" /> <TextBlock Text="{Binding SelectedAccount.Name}" FontSize="14" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="{Binding SelectedAccount.Institution}" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Text="{Binding SelectedAccount.Institution}" FontSize="12" Foreground="{DynamicResource TextMuted}" />
</StackPanel> </StackPanel>
<Button Grid.Column="2" Background="Transparent" BorderThickness="0" Padding="6" VerticalAlignment="Top" Cursor="Hand"> <Button Grid.Column="2" Background="Transparent" BorderThickness="0" Padding="6" VerticalAlignment="Top" Cursor="Hand"
Command="{Binding EditAccountCommand
}" CommandParameter="{Binding SelectedAccount}">
<Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" /> <Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" />
</Button> </Button>
</Grid> </Grid>
@@ -354,7 +371,9 @@
</Button> </Button>
<!-- Delete --> <!-- Delete -->
<Button Background="#2A0D0D" BorderBrush="#3A1515" BorderThickness="1" CornerRadius="10" Padding="14,10" <Button Background="#2A0D0D" BorderBrush="#3A1515" BorderThickness="1" CornerRadius="10" Padding="14,10"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Cursor="Hand"> HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Cursor="Hand"
Command="{Binding RequestDeleteAccountCommand}" CommandParameter="{Binding SelectedAccount}"
IsEnabled="{Binding CanDeleteAccount}">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14" <Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" /> Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
@@ -369,5 +388,9 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
<Grid Grid.Row="0" Grid.RowSpan="2">
<views:DeleteAccountDialogView IsVisible="{Binding DataContext.IsDeleteDialogVisible ,ElementName=AccountsPage }"
DataContext="{Binding Path=DeleteDialog}" />
</Grid>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -1,85 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:Clario.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.Views.BudgetCardMenuView">
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="12"
Padding="6"
Width="196"
BoxShadow="0 8 32 0 #3C000000">
<StackPanel Spacing="1">
<!-- Edit -->
<Button Background="Transparent"
BorderThickness="0"
CornerRadius="8"
Padding="10,9"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Cursor="Hand">
<Button.Flyout>
<Flyout Placement="LeftEdgeAlignedTop"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:BudgetFormView />
</Flyout>
</Button.Flyout>
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgSecondary}" />
<TextBlock Text="Edit Budget" FontSize="13" Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- View Transactions -->
<Button Background="Transparent"
BorderThickness="0"
CornerRadius="8"
Padding="10,9"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/list.svg" Width="14" Height="14" Css="{DynamicResource SvgSecondary}" />
<TextBlock Text="View Transactions" FontSize="13" Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- Reset Spent -->
<Button Background="Transparent"
BorderThickness="0"
CornerRadius="8"
Padding="10,9"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/rotate-ccw.svg" Width="14" Height="14" Css="{DynamicResource SvgSecondary}" />
<TextBlock Text="Reset Spent" FontSize="13" Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Separator Margin="4,2" />
<!-- Delete -->
<Button Background="Transparent"
BorderThickness="0"
CornerRadius="8"
Padding="10,9"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="Delete Budget" FontSize="13" Foreground="#FF5E5E" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Clario.Views;
public partial class BudgetCardMenuView : UserControl
{
public BudgetCardMenuView()
{
InitializeComponent();
}
}

View File

@@ -2,226 +2,412 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:styles="clr-namespace:Clario.Theme.Styles" xmlns:vm="clr-namespace:Clario.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" xmlns:behaviors="clr-namespace:Clario.Behaviors"
x:Class="Clario.Views.BudgetFormView"> mc:Ignorable="d"
<Border Background="{DynamicResource BgSurface}" x:Class="Clario.Views.BudgetFormView"
x:DataType="vm:BudgetFormViewModel">
<Design.DataContext>
<vm:BudgetFormViewModel />
</Design.DataContext>
<!-- ── Dim overlay ───────────────────────── -->
<Grid>
<Border Background="#70000000" />
<!-- ── Modal card ────────────────────────── -->
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" BorderThickness="1"
CornerRadius="16" CornerRadius="18"
Padding="24" Padding="28"
Width="380" Width="460"
BoxShadow="0 12 40 0 #4C000000"> BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<!-- ── Header ─────────────────────────── --> <!-- ── Header ──────────────────────── -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,22"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,24">
<Border Grid.Column="0" <Border Grid.Column="0"
Background="{DynamicResource IconBgBlue}" Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10" CornerRadius="10"
Width="38" Height="38" Width="42" Height="42"
Margin="0,0,12,0"> Margin="0,0,14,0">
<Svg Path="../Assets/Icons/wallet.svg" Width="17" Height="17" Css="{DynamicResource SvgBlue}" /> <Svg Path="../Assets/Icons/wallet-cards.svg"
Width="18" Height="18"
Css="{DynamicResource SvgMuted}" />
</Border> </Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="1"> <StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<!-- REPLACE: bind to DialogTitle --> <TextBlock Text="{Binding FormTitle}"
<TextBlock Text="Edit Budget" FontSize="16"
FontSize="15" FontWeight="Bold" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" /> Foreground="{DynamicResource TextPrimary}" />
<!-- REPLACE: bind to SelectedCategory --> <TextBlock Text="{Binding FormSubtitle}"
<TextBlock Text="Food &amp; Dining"
FontSize="11" FontSize="11"
Foreground="{DynamicResource TextMuted}" /> Foreground="{DynamicResource TextMuted}" />
</StackPanel> </StackPanel>
<Button Grid.Column="2" <Button Grid.Column="2"
Background="Transparent" BorderThickness="0" Background="Transparent"
Padding="6" VerticalAlignment="Top" Cursor="Hand"> BorderThickness="0"
<Svg Path="../Assets/Icons/x.svg" Width="15" Height="15" Css="{DynamicResource SvgMuted}" /> Padding="6"
VerticalAlignment="Top"
Cursor="Hand"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="15" Height="15"
Css="{DynamicResource SvgMuted}" />
</Button> </Button>
</Grid> </Grid>
<!-- ── Category ───────────────────────── --> <!-- ── Category ───────────────────── -->
<TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" /> <TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}" <Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" BorderThickness="1"
CornerRadius="10" CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,18"> Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" <Border Grid.Column="0"
Background="{DynamicResource IconBgGreen}" CornerRadius="7"
CornerRadius="8" Width="30" Height="30"
Width="34" Height="34"
Margin="8,0,0,0" Margin="8,0,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<!-- REPLACE: icon changes with SelectedCategory --> <Border.Background>
<Svg Path="../Assets/Icons/utensils.svg" Width="15" Height="15" Css="{DynamicResource SvgGreen}" /> <SolidColorBrush
Color="{Binding SelectedCategory.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedCategory.Icon, Converter={StaticResource SvgPathFromName}}"
Width="14" Height="14"
Css="{Binding SelectedCategory.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border> </Border>
<!-- REPLACE: SelectedItem="{Binding SelectedCategory}" -->
<ComboBox Grid.Column="1" <ComboBox Grid.Column="1"
Background="Transparent" BorderThickness="0" ItemsSource="{Binding Categories}"
Padding="10,11" FontSize="13" SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"
SelectedIndex="0"> Background="Transparent"
<ComboBoxItem Content="Food &amp; Dining" /> BorderThickness="0"
<ComboBoxItem Content="Housing" /> Padding="8,10"
<ComboBoxItem Content="Transport" /> FontSize="13"
<ComboBoxItem Content="Health" /> HorizontalAlignment="Stretch" />
<ComboBoxItem Content="Leisure" />
<ComboBoxItem Content="Shopping" />
<ComboBoxItem Content="Education" />
<ComboBoxItem Content="Subscriptions" />
<ComboBoxItem Content="Other" />
</ComboBox>
</Grid> </Grid>
</Border> </Border>
<!-- ── Monthly Limit ──────────────────── --> <!-- ── Limit Amount ────────────────── -->
<TextBlock Text="MONTHLY LIMIT" Classes="label" Margin="0,0,0,6" /> <TextBlock Text="LIMIT AMOUNT" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}" <Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" BorderThickness="1"
CornerRadius="10" CornerRadius="{DynamicResource RadiusControl}"
Padding="14,0" Padding="14,0"
Margin="0,0,0,8"> Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="$" <TextBlock Grid.Column="0"
FontSize="15" FontWeight="SemiBold" Text="$"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextMuted}" Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center" Margin="0,0,4,0" /> VerticalAlignment="Center"
<!-- REPLACE: Text="{Binding LimitAmount, Mode=TwoWay}" --> Margin="0,0,8,0" />
<TextBox Grid.Column="1" Text="500" Watermark="0.00" <TextBox Grid.Column="1"
Background="Transparent" BorderThickness="0" Classes="ghost"
FontSize="15" FontWeight="SemiBold" Text="{Binding LimitAmount, Mode=TwoWay}"
Watermark="0.00"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" Foreground="{DynamicResource TextPrimary}"
Height="44" Padding="0" VerticalContentAlignment="Center" /> Height="54"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid> </Grid>
</Border> </Border>
<!-- Quick-pick amounts --> <!-- ── Period ─────────────────────── -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,18"> <TextBlock Text="PERIOD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="$100" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="$250" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
<Border Background="{DynamicResource AccentBlue}" CornerRadius="20" Padding="10,4" Cursor="Hand">
<TextBlock Text="$500" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource BgBase}" />
</Border>
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="$1,000" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
</StackPanel>
<!-- ── Rollover ───────────────────────── -->
<Border Background="{DynamicResource BgBase}" <Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" BorderThickness="1"
CornerRadius="10" CornerRadius="{DynamicResource RadiusControl}"
Padding="14,11" Padding="3"
Margin="0,0,0,18"> Margin="0,0,0,20">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,*,*">
<StackPanel Grid.Column="0" Spacing="2"> <!-- Monthly -->
<TextBlock Text="Rollover unused budget"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="Carry leftover amount into next month"
FontSize="11" Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<!-- REPLACE: IsChecked="{Binding RolloverEnabled}" -->
<ToggleSwitch Grid.Column="1" styles:ToggleSwitchExtensions.KnobWidth="16"
styles:ToggleSwitchExtensions.KnobHeight="16" VerticalAlignment="Center" OffContent="" OnContent="" />
</Grid>
</Border>
<!-- ── Alert Threshold ────────────────── -->
<TextBlock Text="ALERT THRESHOLD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Margin="0,0,0,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" Margin="14,11,0,11">
<TextBlock Text="Warn me when I reach"
FontSize="13" Foreground="{DynamicResource TextSecondary}" />
<!-- REPLACE: bind to AlertThresholdLabel -->
<TextBlock Text="80% of limit ($400)"
FontSize="11" Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<!-- Stepper -->
<Border Grid.Column="1"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="8"
Margin="8">
<StackPanel Orientation="Horizontal">
<Button Background="Transparent" BorderThickness="0" Padding="8,6" Cursor="Hand">
<Svg Path="../Assets/Icons/minus.svg" Width="12" Height="12" Css="{DynamicResource SvgMuted}" />
</Button>
<!-- REPLACE: bind to AlertThreshold -->
<TextBlock Text="80%"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" Margin="4,0" />
<Button Background="Transparent" BorderThickness="0" Padding="8,6" Cursor="Hand">
<Svg Path="../Assets/Icons/plus.svg" Width="12" Height="12" Css="{DynamicResource SvgMuted}" />
</Button>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- Threshold quick-picks -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,24">
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="60%" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="70%" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
<Border Background="{DynamicResource AccentBlue}" CornerRadius="20" Padding="10,4" Cursor="Hand">
<TextBlock Text="80%" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource BgBase}" />
</Border>
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4"
Cursor="Hand">
<TextBlock Text="90%" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</Border>
</StackPanel>
<!-- ── Actions ────────────────────────── -->
<Grid ColumnDefinitions="*,*">
<!-- REPLACE: Command="{Binding CancelCommand}" -->
<Button Grid.Column="0" <Button Grid.Column="0"
Classes="base" Classes="nav"
Margin="0,0,6,0" Padding="0,10" Classes.accented="{Binding IsMonthly}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" HorizontalContentAlignment="Center"
FontSize="13" Content="Cancel" /> CornerRadius="7"
Padding="0,8"
<!-- REPLACE: Command="{Binding SaveCommand}" --> Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="monthly">
<TextBlock Text="Monthly"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Button>
<!-- Quarterly -->
<Button Grid.Column="1" <Button Grid.Column="1"
Classes="accented" Classes="nav"
Margin="6,0,0,0" Padding="0,10" Classes.accented="{Binding IsQuarterly}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"> HorizontalContentAlignment="Center"
<StackPanel Orientation="Horizontal" Spacing="8"> CornerRadius="7"
<Svg Path="../Assets/Icons/check.svg" Width="13" Height="13" Padding="0,8"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" /> Focusable="False"
<TextBlock Text="Save" FontSize="13" FontWeight="SemiBold" Command="{Binding SetPeriodCommand}"
Foreground="{DynamicResource BgBase}" VerticalAlignment="Center" /> CommandParameter="quarterly">
</StackPanel> <TextBlock Text="Quarterly"
FontSize="13"
VerticalAlignment="Center" />
</Button>
<!-- Yearly -->
<Button Grid.Column="2"
Classes="nav"
Classes.accented="{Binding IsYearly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="yearly">
<TextBlock Text="Yearly"
FontSize="13"
VerticalAlignment="Center" />
</Button> </Button>
</Grid> </Grid>
</Border>
<!-- ── Alert Threshold ────────────── -->
<Grid ColumnDefinitions="*,Auto" Margin="0,0,0,6">
<TextBlock Grid.Column="0" Text="ALERT THRESHOLD" Classes="label" />
<TextBlock Grid.Column="1"
Text="{Binding AlertThresholdLabel}"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource AccentBlue}" />
</Grid>
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,10"
Margin="0,0,0,20">
<StackPanel Spacing="6">
<!-- IMPORTANT: Minimum and Maximum must come before Value -->
<Slider Minimum="10"
Maximum="100"
TickFrequency="5"
IsSnapToTickEnabled="True"
Value="{Binding AlertThreshold}" />
<Grid ColumnDefinitions="*,*">
<TextBlock Grid.Column="0"
Text="10%"
FontSize="10"
Foreground="{DynamicResource TextDisabled}" />
<TextBlock Grid.Column="1"
Text="100%"
FontSize="10"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Right" />
</Grid>
</StackPanel>
</Border>
<!-- ── Rollover ───────────────────── -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,10"
Margin="0,0,0,8">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
CornerRadius="8"
Width="32" Height="32"
Margin="0,0,12,0"
Background="{DynamicResource IconBgPurple}">
<Svg Path="../Assets/Icons/refresh-cw.svg"
Width="14" Height="14"
Css="{DynamicResource SvgPurple}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="Rollover"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="Carry unused budget to the next period"
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<ToggleSwitch Grid.Column="2"
IsChecked="{Binding Rollover, Mode=TwoWay}"
OnContent=""
OffContent=""
VerticalAlignment="Center" />
</Grid>
</Border>
<!-- ── Validation error ─────────────── -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,8,0,16"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- ── Delete button (edit mode only) ── -->
<Button HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="#1A0808"
BorderBrush="#3A1515"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0,10"
Margin="0,0,0,10"
IsVisible="{Binding IsEditMode}"
Command="{Binding RequestDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="Delete Budget"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FF5E5E"
VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- ── Actions ──────────────────────── -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding IsValid}"
Command="{Binding SaveCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="{Binding SaveButtonLabel}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ── Delete confirm sub-modal ──────────────── -->
<Grid IsVisible="{Binding ShowDeleteConfirm}">
<Border Background="#50000000" />
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="340"
BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0">
<!-- Icon -->
<Border Background="#2A0D0D"
CornerRadius="14"
Width="52" Height="52"
HorizontalAlignment="Center"
Margin="0,0,0,16">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="22" Height="22"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
</Border>
<!-- Title -->
<TextBlock Text="Delete Budget"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8" />
<!-- Description -->
<TextBlock Text="This action cannot be undone. The budget will be permanently removed."
FontSize="13"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Margin="0,0,0,24" />
<!-- Actions -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelDeleteCommand}" />
<Button Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="#FF5E5E"
BorderThickness="0"
CornerRadius="{DynamicResource RadiusControl}"
Command="{Binding ConfirmDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FFFFFF; }" />
<TextBlock Text="Delete"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFFF"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl> </UserControl>

View File

@@ -1,4 +1,6 @@
using Avalonia.Controls; using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Clario.Views; namespace Clario.Views;

View File

@@ -4,13 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia" xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:views="clr-namespace:Clario.Views"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:skiaSharpView="clr-namespace:LiveChartsCore.SkiaSharpView;assembly=LiveChartsCore.SkiaSharpView"
xmlns:model="clr-namespace:Clario.Models" xmlns:model="clr-namespace:Clario.Models"
x:DataType="vm:BudgetViewModel" x:DataType="vm:BudgetViewModel"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
x:Class="Clario.Views.BudgetView"> x:Class="Clario.Views.BudgetView"
x:Name="budgetControl">
<Design.DataContext> <Design.DataContext>
<vm:BudgetViewModel /> <vm:BudgetViewModel />
</Design.DataContext> </Design.DataContext>
@@ -52,7 +50,7 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<!-- REPLACE: Command="{Binding PreviousPeriodCommand}" --> <!-- REPLACE: Command="{Binding PreviousPeriodCommand}" -->
<Button Background="Transparent" <Button Background="Transparent"
Classes="nav textless" Classes="nav"
BorderThickness="0" BorderThickness="0"
Padding="10,8" Padding="10,8"
Cursor="Hand" Cursor="Hand"
@@ -70,7 +68,7 @@
Margin="4,0" /> Margin="4,0" />
<!-- REPLACE: Command="{Binding NextPeriodCommand}" --> <!-- REPLACE: Command="{Binding NextPeriodCommand}" -->
<Button Background="Transparent" <Button Background="Transparent"
Classes="nav textless" Classes="nav"
BorderThickness="0" BorderThickness="0"
Padding="10,8" Padding="10,8"
Cursor="Hand" Cursor="Hand"
@@ -85,13 +83,8 @@
<!-- Add budget button --> <!-- Add budget button -->
<!-- REPLACE: Command="{Binding AddBudgetCommand}" --> <!-- REPLACE: Command="{Binding AddBudgetCommand}" -->
<Button Classes="accented" <Button Classes="accented"
Padding="16,9"> Padding="16,9"
<Button.Flyout> Command="{Binding CreateBudgetCommand}">
<Flyout Placement="LeftEdgeAlignedTop"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:BudgetFormView />
</Flyout>
</Button.Flyout>
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/plus.svg" <Svg Path="../Assets/Icons/plus.svg"
Width="14" Height="14" Width="14" Height="14"
@@ -135,6 +128,7 @@
Classes="label" Classes="label"
Margin="0,0,0,4" /> Margin="0,0,0,4" />
<Border IsVisible="{Binding !GroupHeader}" <Border IsVisible="{Binding !GroupHeader}"
Classes="editable"
Classes.budget-card="{Binding IsOnTrack}" Classes.budget-card="{Binding IsOnTrack}"
Classes.budget-card-warning="{Binding IsWarning}" Classes.budget-card-warning="{Binding IsWarning}"
Classes.budget-card-over="{Binding IsOverBudget}" Classes.budget-card-over="{Binding IsOverBudget}"
@@ -144,7 +138,7 @@
Cursor="Hand"> Cursor="Hand">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<!-- Header row --> <!-- Header row -->
<Grid ColumnDefinitions="Auto,*,Auto,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" <Border Grid.Column="0"
CornerRadius="10" CornerRadius="10"
Width="40" Height="40" Width="40" Height="40"
@@ -154,9 +148,18 @@
Color="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=color}" Color="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" /> Opacity="0.15" />
</Border.Background> </Border.Background>
<Panel>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}" <Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Width="18" Height="18" Width="18" Height="18" Classes="hide"
Css="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" /> Css="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
<Button Classes="base reveal" CornerRadius="{DynamicResource RadiusSmall}" Width="40"
Height="40" Margin="0"
CommandParameter="{Binding .}"
Command="{Binding DataContext.EditBudgetCommand,ElementName=budgetControl}">
<Svg Path="../Assets/Icons/pencil.svg" Width="16" Height="16"
Css="{DynamicResource SvgSecondary}" />
</Button>
</Panel>
</Border> </Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2"> <StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<!-- REPLACE: bind to Budget.CategoryName --> <!-- REPLACE: bind to Budget.CategoryName -->
@@ -192,24 +195,6 @@
Foreground="{DynamicResource TextMuted}" Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Right" /> HorizontalAlignment="Right" />
</StackPanel> </StackPanel>
<!-- Edit button -->
<!-- REPLACE: Command="{Binding EditBudgetCommand}" CommandParameter="{Binding}" -->
<Button Grid.Column="3"
Background="Transparent"
BorderThickness="0"
Padding="4"
VerticalAlignment="Center"
Cursor="Hand">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:BudgetCardMenuView />
</Flyout>
</Button.Flyout>
<Svg Path="../Assets/Icons/ellipsis.svg"
Width="15" Height="15"
Css="{DynamicResource SvgMuted}" />
</Button>
</Grid> </Grid>
<!-- Progress bar + remaining --> <!-- Progress bar + remaining -->
@@ -569,7 +554,8 @@
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<!-- REPLACE: bind to SavingsGoalFormatted --> <!-- REPLACE: bind to SavingsGoalFormatted -->
<TextBlock Grid.Column="1" Text="{Binding Profile.SavingsGoal, StringFormat='$0'}" FontSize="12" FontWeight="SemiBold" <TextBlock Grid.Column="1" Text="{Binding AppData.Profile.SavingsGoal, StringFormat='$0'}" FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" /> Foreground="{DynamicResource TextPrimary}" />
</Grid> </Grid>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
@@ -581,7 +567,7 @@
</StackPanel> </StackPanel>
<!-- REPLACE: Value="{Binding SavingsGoalPercentage}" --> <!-- REPLACE: Value="{Binding SavingsGoalPercentage}" -->
<ProgressBar Classes="yellow" Value="{Binding TotalLeft}" Minimum="0" Maximum="{Binding Profile.SavingsGoal}" Height="6" /> <ProgressBar Classes="yellow" Value="{Binding TotalLeft}" Minimum="0" Maximum="{Binding AppData.Profile.SavingsGoal}" Height="6" />
<Border Background="{DynamicResource BadgeBgYellow}" <Border Background="{DynamicResource BadgeBgYellow}"
CornerRadius="10" CornerRadius="10"

View File

@@ -18,7 +18,8 @@
<!-- Top Bar --> <!-- Top Bar -->
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Classes="muted" Text="Friday, March 6, 2026" /> <!-- <TextBlock Classes="muted" Text="Friday, March 6, 2026" /> -->
<TextBlock Classes="muted" Text="{Binding DateToday}" />
<TextBlock Text="Financial Overview" FontSize="{StaticResource FontSizePageTitle}" FontWeight="Bold" <TextBlock Text="Financial Overview" FontSize="{StaticResource FontSizePageTitle}" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" /> Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</StackPanel> </StackPanel>
@@ -113,7 +114,7 @@
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Text="Spending by Category" FontSize="{StaticResource FontSizeSectionHeading}" FontWeight="SemiBold" <TextBlock Text="Spending by Category" FontSize="{StaticResource FontSizeSectionHeading}" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" /> Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="March 2026" /> <TextBlock Classes="muted" Text="{Binding SelectedChartTimPeriodSubTitle}" />
</StackPanel> </StackPanel>
<ComboBox Grid.Column="1" SelectedIndex="0" ItemsSource="{Binding ChartTimePeriods}" <ComboBox Grid.Column="1" SelectedIndex="0" ItemsSource="{Binding ChartTimePeriods}"
SelectedItem="{Binding SelectedChartTimePeriod}" Background="{DynamicResource BgHover}" SelectedItem="{Binding SelectedChartTimePeriod}" Background="{DynamicResource BgHover}"
@@ -124,7 +125,8 @@
<Panel> <Panel>
<StackPanel Spacing="20" IsVisible="{Binding HasSpendingData}"> <StackPanel Spacing="20" IsVisible="{Binding HasSpendingData}">
<lvc:CartesianChart Series="{Binding SpendingByCategoryChartSeries}" Height="250" Background="{DynamicResource BgSurface}" <lvc:CartesianChart Series="{Binding SpendingByCategoryChartSeries}" Height="250" Background="{DynamicResource BgSurface}"
LegendPosition="Hidden" TooltipPosition="Hidden" ZoomMode="None" Name="chart"> LegendPosition="Hidden" TooltipPosition="Hidden" ZoomMode="None" Name="chart"
AnimationsSpeed="00:00:00.1">
<lvc:CartesianChart.XAxes> <lvc:CartesianChart.XAxes>
<lvc:XamlAxis IsVisible="False" /> <lvc:XamlAxis IsVisible="False" />
</lvc:CartesianChart.XAxes> </lvc:CartesianChart.XAxes>

View File

@@ -0,0 +1,495 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
mc:Ignorable="d"
x:Class="Clario.Views.DeleteAccountDialogView"
x:DataType="vm:DeleteAccountDialogViewModel">
<Design.DataContext>
<vm:DeleteAccountDialogViewModel/>
</Design.DataContext>
<Grid>
<!-- Dim overlay -->
<Border Background="#70000000"/>
<!-- ═══════════════════════════════════════
STEP 1 — Simple confirm (no transactions)
═══════════════════════════════════════ -->
<Border IsVisible="{Binding IsSimpleConfirmStep}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="380"
BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0">
<!-- Icon -->
<Border Background="#2A0D0D"
CornerRadius="14"
Width="54" Height="54"
HorizontalAlignment="Center"
Margin="0,0,0,16">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="22" Height="22"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }"/>
</Border>
<!-- Title -->
<TextBlock Text="Delete Account"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8"/>
<!-- Account name badge -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
HorizontalAlignment="Center"
Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border CornerRadius="7"
Width="26" Height="26"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush Color="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15"/>
</Border.Background>
<Svg Path="{Binding Account.Icon, Converter={StaticResource SvgPathFromName}}"
Width="13" Height="13"
Css="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}"/>
</Border>
<TextBlock Text="{Binding Account.Name}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Description -->
<TextBlock Text="This account has no transactions. It will be permanently deleted and cannot be recovered."
FontSize="13"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Margin="0,0,0,24"/>
<!-- Actions -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelCommand}"/>
<Button Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="#FF5E5E"
BorderThickness="0"
CornerRadius="{DynamicResource RadiusControl}"
Command="{Binding ConfirmDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FFFFFF; }"/>
<TextBlock Text="Delete Account"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFFF"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
<!-- ═══════════════════════════════════════
STEP 1B — Has transactions warning
═══════════════════════════════════════ -->
<Border IsVisible="{Binding IsHasTransactionsStep}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentYellow}"
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="400"
BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0">
<!-- Icon -->
<Border Background="{DynamicResource BadgeBgYellow}"
CornerRadius="14"
Width="54" Height="54"
HorizontalAlignment="Center"
Margin="0,0,0,16">
<Svg Path="../Assets/Icons/triangle-alert.svg"
Width="22" Height="22"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #F5C842; }"/>
</Border>
<!-- Title -->
<TextBlock Text="Account Has Transactions"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8"/>
<!-- Account name badge -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
HorizontalAlignment="Center"
Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border CornerRadius="7"
Width="26" Height="26"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush Color="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15"/>
</Border.Background>
<Svg Path="{Binding Account.Icon, Converter={StaticResource SvgPathFromName}}"
Width="13" Height="13"
Css="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}"/>
</Border>
<TextBlock Text="{Binding Account.Name}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center"/>
<Border Background="{DynamicResource BadgeBgYellow}"
CornerRadius="6"
Padding="6,2">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding Account.TransactionsCount}"
FontSize="11"
FontWeight="SemiBold"
Foreground="{DynamicResource AccentYellow}"/>
<TextBlock Text="transactions"
FontSize="11"
Foreground="{DynamicResource AccentYellow}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Description -->
<TextBlock FontSize="13"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Margin="0,0,0,24">
<Run Text="This account has linked transactions. Before deleting, you must migrate them to another account. The transactions will be re-linked and balances will be recalculated."/>
</TextBlock>
<!-- Actions -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelCommand}"/>
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding GoToMigrateStepCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/arrow-right-left.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }"/>
<TextBlock Text="Migrate &amp; Delete"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
<!-- ═══════════════════════════════════════
STEP 2 — Pick target account + confirm
═══════════════════════════════════════ -->
<Border IsVisible="{Binding IsMigrateStep}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="420"
BoxShadow="0 24 72 0 #60000000">
<StackPanel Spacing="0">
<!-- Header -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,22">
<Button Grid.Column="0"
Background="Transparent"
BorderThickness="0"
Padding="4"
VerticalAlignment="Center"
Cursor="Hand"
Command="{Binding BackToWarningCommand}">
<Svg Path="../Assets/Icons/arrow-left.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"/>
</Button>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="2">
<TextBlock Text="Migrate Transactions"
FontSize="15"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"/>
<TextBlock Text="Choose where to move the transactions"
FontSize="11"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center"/>
</StackPanel>
<Button Grid.Column="2"
Background="Transparent"
BorderThickness="0"
Padding="4"
VerticalAlignment="Center"
Cursor="Hand"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"/>
</Button>
</Grid>
<!-- From → To visual -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="12"
Padding="14,12"
Margin="0,0,0,18">
<Grid ColumnDefinitions="*,Auto,*">
<!-- From account -->
<StackPanel Grid.Column="0" Spacing="4" HorizontalAlignment="Center">
<TextBlock Text="FROM" Classes="label" HorizontalAlignment="Center"/>
<Border CornerRadius="8"
Width="38" Height="38"
HorizontalAlignment="Center">
<Border.Background>
<SolidColorBrush Color="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15"/>
</Border.Background>
<Svg Path="{Binding Account.Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding Account.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}"/>
</Border>
<TextBlock Text="{Binding Account.Name}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextAlignment="Center"/>
<Border Background="{DynamicResource BadgeBgRed}"
CornerRadius="6"
Padding="6,2"
HorizontalAlignment="Center">
<TextBlock FontSize="10"
Foreground="{DynamicResource AccentRed}"
HorizontalAlignment="Center">
<Run Text="{Binding Account.TransactionsCount}"/>
<Run Text=" transactions"/>
</TextBlock>
</Border>
</StackPanel>
<!-- Arrow -->
<Svg Grid.Column="1"
Path="../Assets/Icons/arrow-right.svg"
Width="18" Height="18"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="8,0"/>
<!-- To account -->
<StackPanel Grid.Column="2" Spacing="4" HorizontalAlignment="Center">
<TextBlock Text="TO" Classes="label" HorizontalAlignment="Center"/>
<Border CornerRadius="8"
Width="38" Height="38"
HorizontalAlignment="Center">
<Border.Background>
<SolidColorBrush Color="{Binding TargetAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15"/>
</Border.Background>
<Svg Path="{Binding TargetAccount.Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding TargetAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}"/>
</Border>
<TextBlock Text="{Binding TargetAccount.Name}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextAlignment="Center"/>
<Border Background="{DynamicResource IconBgGreen}"
CornerRadius="6"
Padding="6,2"
HorizontalAlignment="Center">
<TextBlock Text="Target"
FontSize="10"
Foreground="{DynamicResource AccentGreen}"
HorizontalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- Target account selector -->
<TextBlock Text="SELECT TARGET ACCOUNT" Classes="label" Margin="0,0,0,6"/>
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,18">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
CornerRadius="7"
Width="30" Height="30"
Margin="10,0,0,0"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush Color="{Binding TargetAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15"/>
</Border.Background>
<Svg Path="{Binding TargetAccount.Icon, Converter={StaticResource SvgPathFromName}}"
Width="14" Height="14"
Css="{Binding TargetAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}"/>
</Border>
<ComboBox Grid.Column="1"
ItemsSource="{Binding AvailableAccounts}"
SelectedItem="{Binding TargetAccount, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
Background="Transparent"
BorderThickness="0"
Padding="8,10"
FontSize="13"
HorizontalAlignment="Stretch"/>
</Grid>
</Border>
<!-- Warning info -->
<Border Background="{DynamicResource BadgeBgYellow}"
BorderBrush="{DynamicResource AccentYellow}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="8">
<Svg Grid.Column="0"
Path="../Assets/Icons/info.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #F5C842; }"
VerticalAlignment="Top"
Margin="0,1,0,0"/>
<TextBlock Grid.Column="1"
FontSize="12"
Foreground="{DynamicResource AccentYellow}"
TextWrapping="Wrap">
<Run Text="All transactions will be moved to"/>
<Run Text=" "/>
<Run Text="{Binding TargetAccount.Name}" FontWeight="SemiBold"/>
<Run Text=". Balances will be recalculated. This cannot be undone."/>
</TextBlock>
</Grid>
</Border>
<!-- Error -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,16"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }"/>
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- Actions -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
Content="Cancel"
Command="{Binding CancelCommand}"/>
<Button Margin="6,0,0,0"
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="#FF5E5E"
BorderThickness="0"
CornerRadius="{DynamicResource RadiusControl}"
IsEnabled="{Binding CanMigrateAndDelete}"
Command="{Binding MigrateAndDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FFFFFF; }"/>
<TextBlock Text="Migrate &amp; Delete"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFFF"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Clario.Views;
public partial class DeleteAccountDialogView : UserControl
{
public DeleteAccountDialogView()
{
InitializeComponent();
}
}

View File

@@ -103,12 +103,23 @@
Padding="12,10"> Padding="12,10">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10"> <Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10">
<Border Grid.Column="0" Background="{DynamicResource BorderAccent}" CornerRadius="{StaticResource RadiusPill}" Width="34" <Panel Grid.Column="0">
Height="34">
<Border CornerRadius="40"
ClipToBounds="True"
Width="34"
Height="34"
IsVisible="{Binding Profile.HasAvatar}">
<Image Source="{Binding Profile.Avatar}"
Stretch="UniformToFill" />
</Border>
<Border Background="{DynamicResource BorderAccent}" CornerRadius="{StaticResource RadiusPill}" Width="34"
Height="34" IsVisible="{Binding !Profile.HasAvatar}">
<TextBlock Text="N" FontSize="{StaticResource FontSizeAmount}" FontWeight="Bold" <TextBlock Text="N" FontSize="{StaticResource FontSizeAmount}" FontWeight="Bold"
Foreground="{DynamicResource AccentBlue}" HorizontalAlignment="Center" Foreground="{DynamicResource AccentBlue}" HorizontalAlignment="Center"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Border> </Border>
</Panel>
<TextBlock Grid.Column="1" Text="{Binding Profile.DisplayName}" TextTrimming="CharacterEllipsis" <TextBlock Grid.Column="1" Text="{Binding Profile.DisplayName}" TextTrimming="CharacterEllipsis"
FontSize="{StaticResource FontSizeBody}" FontSize="{StaticResource FontSizeBody}"
FontWeight="SemiBold" FontWeight="SemiBold"
@@ -158,7 +169,7 @@
<Button Classes="nav" HorizontalAlignment="Stretch" Classes.active="{Binding isOnBudget}" Command="{Binding GoToBudgetCommand}"> <Button Classes="nav" HorizontalAlignment="Stretch" Classes.active="{Binding isOnBudget}" Command="{Binding GoToBudgetCommand}">
<StackPanel Orientation="Horizontal" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="12">
<Svg Path="../Assets/Icons/wallet.svg" Height="14" Width="14" /> <Svg Path="../Assets/Icons/wallet.svg" Height="14" Width="14" />
<TextBlock Text="Budget" FontSize="{StaticResource FontSizeBody}" VerticalAlignment="Center" /> <TextBlock Text="Budgets" FontSize="{StaticResource FontSizeBody}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</Button> </Button>
<TextBlock Classes="label" Text="REPORTS" Margin="12,20,0,10" /> <TextBlock Classes="label" Text="REPORTS" Margin="12,20,0,10" />
@@ -169,7 +180,7 @@
</StackPanel> </StackPanel>
</Button> </Button>
<TextBlock Classes="label" Text="SYSTEM" Margin="12,20,0,10" /> <TextBlock Classes="label" Text="SYSTEM" Margin="12,20,0,10" />
<Button Classes="nav" HorizontalAlignment="Stretch"> <Button Classes="nav" HorizontalAlignment="Stretch" Classes.active="{Binding isOnSettings}" Command="{Binding GoToSettingsCommand}">
<StackPanel Orientation="Horizontal" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="12">
<Svg Path="../Assets/Icons/settings.svg" Height="14" Width="14" /> <Svg Path="../Assets/Icons/settings.svg" Height="14" Width="14" />
<TextBlock Text="Settings" FontSize="{StaticResource FontSizeBody}" VerticalAlignment="Center" /> <TextBlock Text="Settings" FontSize="{StaticResource FontSizeBody}" VerticalAlignment="Center" />
@@ -178,13 +189,20 @@
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</Border> </Border>
<Border Grid.Column="0" Background="#70000000" IsVisible="{Binding IsTransactionFormVisible}" /> <Border Grid.Column="0" Grid.ColumnSpan="2" Background="#70000000" IsVisible="{Binding IsDimmed}" />
<Grid Grid.Column="1"> <Grid Grid.Column="1">
<ContentControl Content="{Binding CurrentView}" /> <ContentControl Content="{Binding CurrentView}" />
<views:TransactionFormView <views:TransactionFormView
DataContext="{Binding TransactionFormViewModel}" DataContext="{Binding TransactionFormViewModel}"
IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}"> IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}" />
</views:TransactionFormView> <views:AccountFormView
DataContext="{Binding AccountFormViewModel}"
IsVisible="{Binding DataContext.IsAccountFormVisible,ElementName=MainControl}" />
<views:BudgetFormView
DataContext="{Binding BudgetFormViewModel}"
IsVisible="{Binding DataContext.IsBudgetFormVisible, ElementName=MainControl}" />
</Grid> </Grid>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -0,0 +1,531 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
x:Class="Clario.Views.SettingsView"
x:DataType="vm:SettingsViewModel">
<Design.DataContext>
<vm:SettingsViewModel />
</Design.DataContext>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="32,28,32,48"
Spacing="0"
MaxWidth="720">
<!-- ── Page header ─────────────────────────── -->
<StackPanel Margin="0,0,0,28">
<TextBlock Text="Settings"
FontSize="26"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="Manage your account and preferences"
FontSize="13"
Foreground="{DynamicResource TextMuted}"
Margin="0,4,0,0" />
</StackPanel>
<!-- ── Global success / error banner ─────────── -->
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,14"
IsVisible="{Binding HasSuccess}">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/circle-check.svg"
Width="14" Height="14"
Css="{DynamicResource SvgGreen}"
VerticalAlignment="Center"
Margin="0,0,10,0" />
<TextBlock Grid.Column="1"
Text="{Binding SuccessMessage}"
FontSize="13"
Foreground="{DynamicResource AccentGreen}"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,14"
IsVisible="{Binding HasError}">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/circle-alert.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }"
VerticalAlignment="Center"
Margin="0,0,10,0" />
<TextBlock Grid.Column="1"
Text="{Binding ErrorMessage}"
FontSize="13"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</Grid>
</Border>
<!-- ══════════════════════════════════════════════
SECTION: Profile
══════════════════════════════════════════════ -->
<TextBlock Text="PROFILE"
Classes="label"
Margin="0,0,0,10" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="16"
Padding="22"
Margin="0,0,0,24">
<StackPanel Spacing="20">
<!-- Avatar -->
<Grid ColumnDefinitions="Auto,*">
<StackPanel Grid.Column="0"
Spacing="8"
Margin="0,0,20,0">
<!-- Avatar circle -->
<Panel Width="80" Height="80"
HorizontalAlignment="Center">
<!-- Image (if has avatar) -->
<Border CornerRadius="40"
ClipToBounds="True"
Width="80" Height="80"
IsVisible="{Binding HasAvatar}">
<Image Source="{Binding AvatarImage}"
Stretch="UniformToFill" />
</Border>
<!-- Initials fallback -->
<Border CornerRadius="40"
Width="80" Height="80"
Background="{DynamicResource BorderAccent}"
IsVisible="{Binding !HasAvatar}">
<TextBlock Text="{Binding DisplayName[0]}"
FontSize="28"
FontWeight="Bold"
Foreground="{DynamicResource AccentBlue}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<!-- Upload spinner overlay -->
<Border CornerRadius="40"
Width="80" Height="80"
Background="#80000000"
IsVisible="{Binding IsUploadingAvatar}">
<TextBlock Text="..."
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Panel>
<!-- Avatar actions -->
<StackPanel Spacing="4">
<Button Classes="base"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,7"
FontSize="12"
IsEnabled="{Binding !IsUploadingAvatar}"
Command="{Binding UploadAvatarCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/upload.svg"
Width="12" Height="12"
Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Upload" FontSize="12" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,4"
FontSize="11"
Foreground="{DynamicResource AccentRed}"
IsVisible="{Binding HasAvatar}"
IsEnabled="{Binding !IsUploadingAvatar}"
Command="{Binding RemoveAvatarCommand}"
Content="Remove" />
</StackPanel>
</StackPanel>
<!-- Profile fields -->
<StackPanel Grid.Column="1" Spacing="16">
<!-- Display name -->
<StackPanel Spacing="6">
<TextBlock Text="DISPLAY NAME" Classes="label" />
<TextBox Text="{Binding DisplayName, Mode=TwoWay}"
Watermark="Your name"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<!-- Currency + Theme -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="CURRENCY" Classes="label" />
<ComboBox ItemsSource="{Binding Currencies}"
SelectedItem="{Binding SelectedCurrency, Mode=TwoWay}"
HorizontalAlignment="Stretch"
Padding="10,8"
FontSize="13" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="THEME" Classes="label" />
<ComboBox ItemsSource="{Binding ThemeLabels}"
SelectedIndex="{Binding SelectedThemeIndex, Mode=TwoWay}"
HorizontalAlignment="Stretch"
Padding="10,8"
FontSize="13" />
</StackPanel>
</Grid>
<!-- Language -->
<StackPanel Spacing="6">
<TextBlock Text="LANGUAGE" Classes="label" />
<ComboBox ItemsSource="{Binding LanguageLabels}"
SelectedIndex="{Binding SelectedLanguageIndex, Mode=TwoWay}"
HorizontalAlignment="Stretch"
Padding="10,8"
FontSize="13" />
</StackPanel>
</StackPanel>
</Grid>
<Separator />
<!-- Save button -->
<Button Classes="accented"
HorizontalAlignment="Right"
Padding="20,9"
IsEnabled="{Binding !IsSaving}"
Command="{Binding SaveProfileCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<Panel>
<TextBlock Text="{Binding IsSaving, Converter={StaticResource BoolToStringConverter}, ConverterParameter='Saving...|Save Changes'}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</Panel>
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- ══════════════════════════════════════════════
SECTION: Account Security
══════════════════════════════════════════════ -->
<TextBlock Text="ACCOUNT &amp; SECURITY"
Classes="label"
Margin="0,0,0,10" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="16"
Padding="0"
Margin="0,0,0,24">
<StackPanel Spacing="0">
<!-- ── Email row ───────────────────────────── -->
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0,0,1"
Padding="20,0">
<Panel>
<!-- Normal email display -->
<Grid IsVisible="{Binding !IsChangingEmail}"
ColumnDefinitions="*,Auto"
MinHeight="58">
<StackPanel Grid.Column="0"
VerticalAlignment="Center"
Spacing="2">
<TextBlock Text="EMAIL ADDRESS"
Classes="label" />
<TextBlock Text="{Binding MaskedEmail}"
FontSize="13"
Foreground="{DynamicResource TextPrimary}"
FontWeight="SemiBold" />
</StackPanel>
<Button Grid.Column="1"
Background="Transparent"
BorderThickness="0"
Padding="0"
Cursor="Hand"
VerticalAlignment="Center"
Command="{Binding StartChangeEmailCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/pencil.svg"
Width="13" Height="13"
Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Change"
FontSize="12"
Foreground="{DynamicResource AccentBlue}" />
</StackPanel>
</Button>
</Grid>
<!-- Change email form -->
<StackPanel IsVisible="{Binding IsChangingEmail}"
Spacing="12"
Margin="0,16,0,16">
<TextBlock Text="CHANGE EMAIL ADDRESS"
Classes="label" />
<!-- Email success / error -->
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
IsVisible="{Binding HasEmailSuccess}">
<TextBlock Text="{Binding EmailSuccessMessage}"
FontSize="12"
Foreground="{DynamicResource AccentGreen}"
TextWrapping="Wrap" />
</Border>
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
IsVisible="{Binding HasEmailError}">
<TextBlock Text="{Binding EmailErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
TextWrapping="Wrap" />
</Border>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="NEW EMAIL" Classes="label" />
<TextBox Text="{Binding NewEmail, Mode=TwoWay}"
Watermark="new@email.com"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="CONFIRM WITH PASSWORD" Classes="label" />
<TextBox Text="{Binding EmailConfirmPassword, Mode=TwoWay}"
Watermark="Current password"
PasswordChar="•"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Right">
<Button Classes="base"
Padding="16,8"
FontSize="13"
Content="Cancel"
Command="{Binding CancelChangeEmailCommand}" />
<Button Classes="accented"
Padding="16,8"
IsEnabled="{Binding !IsSaving}"
Command="{Binding ConfirmChangeEmailCommand}">
<TextBlock Text="Update Email"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" />
</Button>
</StackPanel>
</StackPanel>
</Panel>
</Border>
<!-- ── Password row ───────────────────────── -->
<Border Padding="20,0">
<!-- Normal password display -->
<Panel>
<Grid IsVisible="{Binding !IsChangingPassword}"
ColumnDefinitions="*,Auto"
MinHeight="58">
<StackPanel Grid.Column="0"
VerticalAlignment="Center"
Spacing="2">
<TextBlock Text="PASSWORD"
Classes="label" />
<TextBlock Text="••••••••••••"
FontSize="16"
Foreground="{DynamicResource TextMuted}"
LetterSpacing="2" />
</StackPanel>
<Button Grid.Column="1"
Background="Transparent"
BorderThickness="0"
Padding="0"
Cursor="Hand"
VerticalAlignment="Center"
Command="{Binding StartChangePasswordCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/pencil.svg"
Width="13" Height="13"
Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Change"
FontSize="12"
Foreground="{DynamicResource AccentBlue}" />
</StackPanel>
</Button>
</Grid>
<!-- Change password form -->
<StackPanel IsVisible="{Binding IsChangingPassword}"
Spacing="12"
Margin="0,16,0,16">
<TextBlock Text="CHANGE PASSWORD" Classes="label" />
<!-- Password success / error -->
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
IsVisible="{Binding HasPasswordSuccess}">
<TextBlock Text="{Binding PasswordSuccessMessage}"
FontSize="12"
Foreground="{DynamicResource AccentGreen}"
TextWrapping="Wrap" />
</Border>
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
IsVisible="{Binding HasPasswordError}">
<TextBlock Text="{Binding PasswordErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
TextWrapping="Wrap" />
</Border>
<StackPanel Spacing="6">
<TextBlock Text="CURRENT PASSWORD" Classes="label" />
<TextBox Text="{Binding CurrentPassword, Mode=TwoWay}"
Watermark="Enter current password"
PasswordChar="•"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="NEW PASSWORD" Classes="label" />
<TextBox Text="{Binding NewPassword, Mode=TwoWay}"
Watermark="Min. 8 characters"
PasswordChar="•"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="CONFIRM NEW PASSWORD" Classes="label" />
<TextBox Text="{Binding ConfirmNewPassword, Mode=TwoWay}"
Watermark="Repeat new password"
PasswordChar="•"
FontSize="13"
Height="38"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Right">
<Button Classes="base"
Padding="16,8"
FontSize="13"
Content="Cancel"
Command="{Binding CancelChangePasswordCommand}" />
<Button Classes="accented"
Padding="16,8"
IsEnabled="{Binding !IsSaving}"
Command="{Binding ConfirmChangePasswordCommand}">
<TextBlock Text="Update Password"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" />
</Button>
</StackPanel>
</StackPanel>
</Panel>
</Border>
</StackPanel>
</Border>
<!-- ══════════════════════════════════════════════
SECTION: Danger zone
══════════════════════════════════════════════ -->
<TextBlock Text="SESSION"
Classes="label"
Margin="0,0,0,10" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="16"
Padding="20">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"
VerticalAlignment="Center"
Spacing="2">
<TextBlock Text="Sign Out"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="You will be returned to the login screen."
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<Button Grid.Column="1"
Background="#2A0D0D"
BorderBrush="#3A1515"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="16,9"
Command="{Binding SignOutCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/log-out.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="Sign Out"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FF5E5E"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Clario.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}

View File

@@ -257,6 +257,7 @@
Classes="ghost" Classes="ghost"
SelectionMode="SingleDate" SelectionMode="SingleDate"
SelectedDates="{Binding Dates}" SelectedDates="{Binding Dates}"
SelectedDate="{Binding}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Padding="12,10" /> Padding="12,10" />
<Button Grid.Column="1" <Button Grid.Column="1"

View File

@@ -30,7 +30,7 @@
<!-- Period header ─ <!-- Period header ─
REPLACE: bind TextBlock texts to SelectedPeriodLabel REPLACE: bind TextBlock texts to SelectedPeriodLabel
--> -->
<TextBlock Text="MARCH 2026" <TextBlock Text="{Binding DateRangeLabel}"
Classes="label" Classes="label"
Margin="0,0,0,4" /> Margin="0,0,0,4" />
<TextBlock Text="Summary" <TextBlock Text="Summary"

View File

@@ -7,6 +7,7 @@
<!-- Avalonia packages --> <!-- Avalonia packages -->
<!-- Important: keep version in sync! --> <!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="11.3.6" /> <PackageVersion Include="Avalonia" Version="11.3.6" />
<PackageVersion Include="Avalonia.Controls.ColorPicker" Version="11.3.6" />
<PackageVersion Include="Avalonia.Svg.Skia" Version="11.3.0" /> <PackageVersion Include="Avalonia.Svg.Skia" Version="11.3.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.6" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.6" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.6" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.6" />
@@ -25,6 +26,7 @@
<PackageVersion Include="Xaml.Behaviors.Interactions" Version="11.3.6.6" /> <PackageVersion Include="Xaml.Behaviors.Interactions" Version="11.3.6.6" />
<PackageVersion Include="Xaml.Behaviors.Interactivity" Version="11.3.6.6" /> <PackageVersion Include="Xaml.Behaviors.Interactivity" Version="11.3.6.6" />
<PackageVersion Include="SkiaSharp" Version="3.116.1" /> <PackageVersion Include="SkiaSharp" Version="3.116.1" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.116.1" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.116.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,16 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"includedFrameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.25"
}
],
"configProperties": {
"MVVMTOOLKIT_ENABLE_INOTIFYPROPERTYCHANGING_SUPPORT": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
publish/linux-x64/ExCSS.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More