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 a8244ec0de
commit bdf52e82af
61 changed files with 4377 additions and 719 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@@ -8,15 +8,18 @@
<ItemGroup>
<PackageReference Include="Avalonia.Browser"/>
<PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<PackageReference Include="Supabase" />
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia"/>
<PackageReference Include="Deadpikle.AvaloniaProgressRing"/>
<PackageReference Include="FluentAvalonia.ProgressRing"/>
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia"/>
<PackageReference Include="SkiaSharp"/>
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly"/>
<PackageReference Include="Supabase"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Clario\Clario.csproj" />
<ProjectReference Include="..\Clario\Clario.csproj"/>
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ public partial class App : Application
}
IsMobile = ApplicationLifetime is ISingleViewApplicationLifetime;
var culture = new CultureInfo("en-US");
CultureInfo.DefaultThreadCurrentCulture = culture;
@@ -60,8 +60,9 @@ public partial class App : Application
{
await SupabaseService.Client.Auth.RetrieveSessionAsync();
}
catch
catch (Exception e)
{
Console.WriteLine($"[Auth] RetrieveSession failed: {e.Message}");
}
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>
<PackageReference Include="Avalonia"/>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Avalonia.Themes.Fluent"/>
<PackageReference Include="Avalonia.Fonts.Inter"/>
@@ -30,6 +31,7 @@
<PackageReference Include="Xaml.Behaviors.Interactions" />
<PackageReference Include="Xaml.Behaviors.Interactivity" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" />
</ItemGroup>
@@ -38,5 +40,8 @@
<DependentUpon>MobileMainView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\AccountFormView.axaml.cs">
<DependentUpon>AccountFormView.axaml</DependentUpon>
</Compile>
</ItemGroup>
</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.Globalization;
using Avalonia.Controls.Converters;
using Avalonia.Data.Converters;
using Avalonia.Media;
@@ -21,6 +22,18 @@ public class HexToColorConverter : IValueConverter
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: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">
<Setter Property="MinHeight" Value="15" />
<Setter Property="MinWidth" Value="50" />
@@ -14,8 +84,6 @@
<Setter Property="Template">
<ControlTemplate>
<Grid>
<!-- Trigger button -->
<Button x:Name="PART_Button"
HorizontalAlignment="Stretch"
Background="{TemplateBinding Background}"
@@ -48,7 +116,6 @@
</Grid>
</Button>
<!-- Popup -->
<Popup x:Name="PART_Popup"
PlacementTarget="{Binding #PART_Button}"
Placement="Bottom"
@@ -65,21 +132,50 @@
BorderThickness="0" />
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter>
</Style>
<!-- pointerover -->
<Style Selector="local|DateRangePicker:pointerover /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}" />
</Style>
<!-- pressed -->
<Style Selector="local|DateRangePicker:pressed /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
</Style>
</Styles>
<!-- ============================================================ -->
<!-- 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>

View File

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

View File

@@ -1,49 +1,71 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media.Imaging;
using Clario.Models;
using Clario.Models.GeneralModels;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Supabase.Postgrest;
using FileOptions = Supabase.Storage.FileOptions;
namespace Clario.Data;
public class GeneralDataRepo
public record ProfileUpdated();
public partial class GeneralDataRepo : ObservableObject
{
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; }
[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();
public async Task<Profile?> FetchProfileInfo()
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)
{
if (Profile is not null) return Profile;
_ = 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();
if (profile.Models.Count == 0) return null;
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);
}
catch (Exception e)
{
Console.WriteLine(e);
return;
var bytes = await _HttpClient.GetByteArrayAsync(url);
var stream = new MemoryStream(bytes);
Profile.Avatar = new Bitmap(stream);
}
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();
Transactions = transactions.Models;
Transactions = new ObservableCollection<Transaction>(transactions.Models);
return transactions.Models;
}
@@ -51,11 +73,18 @@ public class GeneralDataRepo
{
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)
{
Console.WriteLine(e);
return;
}
}
@@ -63,7 +92,13 @@ public class GeneralDataRepo
{
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)
{
@@ -76,6 +111,9 @@ public class GeneralDataRepo
try
{
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)
{
@@ -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();
Categories = categories.Models;
Categories = new ObservableCollection<Category>(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();
Accounts = accounts.Models;
Accounts = new ObservableCollection<Account>(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();
Budgets = budgets.Models;
Budgets = new ObservableCollection<Budget>(budgets.Models);
return budgets.Models;
}
public async Task<List<Budget>> FetchProcessedBudgets(DateTime CurrentPeriod)
{
var categories = await FetchCategories();
var transactions = await FetchTransactions();
var budgets = await FetchBudgets();
var budgets = Budgets;
var outputList = new List<Budget>();
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())
{
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();
budget.Spent = budgetTransactions.Sum(x => x.Amount);
budget.TransactionsCount = budgetTransactions.Count;
break;
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();
budget.Spent = quarterTransactions.Sum(x => x.Amount);
budget.TransactionsCount = quarterTransactions.Count;
break;
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.TransactionsCount = yearTransactions.Count;
break;
}
OnPropertyChanged(nameof(budget.IsOnTrack));
OnPropertyChanged(nameof(budget.IsWarning));
OnPropertyChanged(nameof(budget.IsOverBudget));
}
@@ -173,4 +213,239 @@ public class GeneralDataRepo
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:vm="clr-namespace:Clario.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:views="clr-namespace:Clario.Views"
xmlns:model="clr-namespace:Clario.Models"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.BudgetViewMobile"
@@ -255,12 +254,6 @@
BorderThickness="0"
Padding="4"
VerticalAlignment="Center">
<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}" />
@@ -434,7 +427,7 @@
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<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}" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
@@ -447,7 +440,7 @@
<ProgressBar Classes="yellow"
Value="{Binding TotalLeft}"
Minimum="0"
Maximum="{Binding Profile.SavingsGoal}"
Maximum="{Binding AppData.Profile.SavingsGoal}"
Height="6" />
<Border Background="{DynamicResource BadgeBgYellow}"

View File

@@ -30,7 +30,7 @@ public class Account : BaseModel
[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; }
@@ -45,5 +45,7 @@ public class Account : BaseModel
[JsonIgnore] public decimal TotalExpenseThisMonth { get; set; }
[JsonIgnore] public decimal MonthlyIncrease { 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;
}

View File

@@ -29,13 +29,13 @@ public class Budget : BaseModel
[JsonIgnore] public Category? Category { 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 double PercentageUsed => LimitAmount > 0 ? Math.Round((double)(Spent / LimitAmount), 2) : 0;
[JsonIgnore] public bool IsOverBudget => Spent > LimitAmount;
[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 AmountFormatted => $"of ${LimitAmount:N0}";

View File

@@ -1,4 +1,6 @@
using System;
using Avalonia.Media.Imaging;
using Newtonsoft.Json;
using Supabase.Postgrest.Attributes;
using Supabase.Postgrest.Models;
@@ -10,6 +12,8 @@ public class Profile : BaseModel
[PrimaryKey("id", false)] public Guid Id { get; set; }
[Column("display_name")] public string DisplayName { 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("theme")] public string Theme { 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; }
private Guid? _categoryId;
[Column("category_id")]
public Guid? CategoryId
{
get => _categoryId;
set
{
_categoryId = value;
Category = DataRepo.General.FetchCategories().Result.FirstOrDefault(x => x.Id == value);
}
}
[Column("category_id")] public Guid? CategoryId { 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,22 +9,18 @@ public class FileSessionStorage : ISessionStorage
public void Save(string json)
{
// Console.WriteLine($"Saving session to {_path}");
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, json);
}
public string? Load()
{
if (!File.Exists(_path))
{
return null;
}
if (!File.Exists(_path)) return null;
var json = File.ReadAllText(_path);
return json;
}
public void Delete()
{

View File

@@ -11,8 +11,9 @@
</Border>
</Design.PreviewWith>
<StyleInclude Source="Styles/ToggleSwitchStyles.axaml" />
<!-- <StyleInclude Source="Styles/CalenderItemStyles.axaml" /> -->
<StyleInclude Source="Styles/ColorPickerStyles.axaml" />
<StyleInclude Source="Styles/CalendarStyles.axaml" />
<StyleInclude Source="Styles/SliderStyles.axaml" />
<StyleInclude Source="../CustomControls/DateRangePicker.axaml" />
<Styles.Resources>
<ResourceDictionary>
@@ -1045,5 +1046,56 @@
<Setter Property="Opacity" Value="0.5" />
</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>

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.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -13,18 +12,25 @@ namespace Clario.ViewModels;
public partial class AccountsViewModel : ViewModelBase
{
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 decimal _totalBalance = 0;
[ObservableProperty] private decimal _totalBalance;
[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()
{
AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); };
Initialize();
}
public async Task Initialize()
public void Initialize()
{
FetchAndProcessAccountInfo();
GroupAccounts();
@@ -33,9 +39,9 @@ public partial class AccountsViewModel : ViewModelBase
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.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);
@@ -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()
{
var accountTypes = new Dictionary<string, string>()
var accountTypes = new List<string>()
{
{ "checking", "Cash & Checking" },
{ "savings", "Savings" },
{ "credit", "Credit" },
{ "investment", "Investments" }
"Cash",
"Checking",
"Savings",
"Credit",
"Investment",
"Other"
};
VisibleAccounts.Clear();
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())
{
var header = new Account { Name = type.Value.ToUpper(), GroupHeader = true };
var header = new Account { Name = type.ToUpper(), GroupHeader = true };
VisibleAccounts.Add(header);
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]
@@ -86,6 +122,7 @@ public partial class AccountsViewModel : ViewModelBase
{
if (parentViewModel is MainViewModel mainViewModel)
{
if (SelectedAccount is null) return;
var vm = mainViewModel._transactionsViewModel;
vm.SelectedAccount = vm.Accounts.First(x => x.Id == SelectedAccount.Id);
vm.LoadPageCommand.Execute(1);

View File

@@ -40,11 +40,11 @@ public partial class AuthViewModel : ViewModelBase
private void setDefaults()
{
FirstName = "nouredeen";
LastName = "ghazal";
Email = "nouredeen.ghazal42@gmail.com";
Password = "Nour1Clario";
ConfirmPassword = "Nour1Clario";
FirstName = "clario";
LastName = "testing";
Email = "Clario@testing.com";
Password = "1234ABCD6767";
ConfirmPassword = "1234ABCD6767";
ThemeService.SwitchToTheme("system");
}
@@ -62,7 +62,7 @@ public partial class AuthViewModel : ViewModelBase
await SupabaseService.Client.Auth.SignIn(_email, _password);
var user = SupabaseService.Client.Auth.CurrentUser;
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
@@ -99,7 +99,6 @@ public partial class AuthViewModel : ViewModelBase
await SupabaseService.Client.Auth.SetSession(session.AccessToken, session.RefreshToken);
var user = session.User;
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
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 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using Clario.Models.GeneralModels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LiveChartsCore;
@@ -20,17 +18,15 @@ namespace Clario.ViewModels;
public partial class BudgetViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
[ObservableProperty] private Profile? _profile;
public required List<Budget> Budgets = new();
public GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private ObservableCollection<Budget> _visibleBudgets = new();
public required List<Category> Categories = new();
public required List<Transaction> Transactions = new();
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextPeriodCommand), nameof(PreviousPeriodCommand))]
private DateTime _currentPeriod = DateTime.Now.Date;
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");
[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 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!"
: $"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 _approachingCount;
@@ -60,13 +56,17 @@ public partial class BudgetViewModel : ViewModelBase
private int PeriodDaysLeft => PeriodLength - PeriodDaysPassed;
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()
{
AppData.Budgets.CollectionChanged += async (_, _) => { await Initialize(); };
AppData.Transactions.CollectionChanged += async (_, _) => { await Initialize(); };
_ = Initialize();
}
public async Task Initialize()
private async Task Initialize()
{
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()
{
var categories = Categories;
var transactions = Transactions;
var tempCategorySpendingBreakdown = new List<(Category category, double[] spent)>();
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) &&
x.Date.Month == CurrentPeriod.Month && x.Date.Year == CurrentPeriod.Year)
.Sum(x => x.Amount);
@@ -115,11 +125,31 @@ public partial class BudgetViewModel : ViewModelBase
{
VisibleBudgets.Clear();
VisibleBudgets = new ObservableCollection<Budget>(await DataRepo.General.FetchProcessedBudgets(CurrentPeriod));
_onTrackCount = VisibleBudgets.Count(x => x.IsOnTrack);
_approachingCount = VisibleBudgets.Count(x => x.IsWarning);
_overBudgetCount = VisibleBudgets.Count(x => x.IsOverBudget);
_onTrackCount = VisibleBudgets.Count(x => x is { IsOnTrack: true, GroupHeader: false });
_approachingCount = VisibleBudgets.Count(x => x is { IsWarning: true, GroupHeader: false });
_overBudgetCount = VisibleBudgets.Count(x => x is { IsOverBudget: true, GroupHeader: false });
TotalBudgeted = VisibleBudgets.Sum(x => x.LimitAmount);
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))]

View File

@@ -18,10 +18,8 @@ namespace Clario.ViewModels;
public partial class DashboardViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
public required List<Transaction> Transactions = new();
public required List<Category> Categories = new();
public required List<Budget> Budgets = new();
public required List<Account> Accounts = new();
public GeneralDataRepo AppData => DataRepo.General;
// public required List<Account> Accounts = new();
[ObservableProperty] private ObservableCollection<ColumnChartData> _spendingByCategoryChartData = new();
[ObservableProperty] private ISeries[] _spendingByCategoryChartSeries = new ISeries[] { };
@@ -31,19 +29,37 @@ public partial class DashboardViewModel : ViewModelBase
[ObservableProperty] private decimal _totalNetworth;
[ObservableProperty] private decimal _monthlyIncome;
private decimal _monthlyIncomeChange;
private bool _hasLastMonthIncome;
public int MaxChartWidth => SpendingByCategoryChartData.Count * 150;
public string MonthlyIncomeChangeFormatted => _monthlyIncomeChange >= 0
? "↑" + _monthlyIncomeChange.ToString("0.0%")
: "↓" + _monthlyIncomeChange.ToString("0.0%");
public string MonthlyIncomeChangeFormatted
{
get
{
if (!_hasLastMonthIncome)
return MonthlyIncome > 0 ? "NEW" : "—";
return _monthlyIncomeChange >= 0
? "↑ " + _monthlyIncomeChange.ToString("0.0%")
: "↓ " + _monthlyIncomeChange.ToString("0.0%");
}
}
[ObservableProperty] private decimal _monthlyExpenses;
private decimal _monthlyExpensesChange;
private bool _hasLastMonthExpenses;
public string MonthlyExpenseChangeFormatted => _monthlyExpensesChange >= 0
? "↑" + _monthlyExpensesChange.ToString("0.0%")
: "↓" + _monthlyExpensesChange.ToString("0.0%");
public string MonthlyExpenseChangeFormatted
{
get
{
if (!_hasLastMonthExpenses)
return MonthlyExpenses > 0 ? "NEW" : "—";
return _monthlyExpensesChange >= 0
? "↑ " + _monthlyExpensesChange.ToString("0.0%")
: "↓ " + _monthlyExpensesChange.ToString("0.0%");
}
}
public string AccountsSubtitle =>
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 _selectedChartTimPeriodSubTitle = DateTime.Now.ToString("MMMM yyyy");
[ObservableProperty] private string _dateToday = DateTime.Now.ToString("dddd, MMMM d, yyyy");
partial void OnSelectedChartTimePeriodChanged(string value)
{
@@ -73,11 +91,25 @@ public partial class DashboardViewModel : ViewModelBase
_ => 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);
}
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()
@@ -88,36 +120,44 @@ public partial class DashboardViewModel : ViewModelBase
[RelayCommand]
private void UpdateUserOverview()
{
var thisMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
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));
CalculateMonthlyValues();
UpdateSpendingByCategoryChart();
_ = UpdateBudgetTracker();
UpdateRecentTransactions();
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]
private void ViewAllTransactions()
{
@@ -137,10 +177,10 @@ public partial class DashboardViewModel : ViewModelBase
{
var tempList = new List<ColumnChartData>();
foreach (var category in Categories)
foreach (var category in AppData.Categories)
{
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)
{
@@ -196,20 +236,20 @@ public partial class DashboardViewModel : ViewModelBase
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));
}
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);
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));
}

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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
@@ -12,26 +11,33 @@ using Clario.Models.GeneralModels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Clario.Services;
using CommunityToolkit.Mvvm.Messaging;
namespace Clario.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private DashboardViewModel _dashboardViewModel;
public TransactionsViewModel _transactionsViewModel;
private AccountsViewModel _accountsViewModel;
private BudgetViewModel _budgetViewModel;
[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();
private DashboardViewModel _dashboardViewModel = null!;
public TransactionsViewModel _transactionsViewModel = null!;
private AccountsViewModel _accountsViewModel = null!;
private BudgetViewModel _budgetViewModel = null!;
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 _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;
[ObservableProperty] private bool _isDarkTheme;
@@ -39,6 +45,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
Console.WriteLine("main vm loaded");
WeakReferenceMessenger.Default.Register<ProfileUpdated>(this, (_, m) => { Profile = AppData.Profile; });
CurrentView = new LoadingViewModel();
_ = InitializeApp();
}
@@ -48,72 +55,71 @@ public partial class MainViewModel : ViewModelBase
{
try
{
var profilesTask = DataRepo.General.FetchProfileInfo();
var categoriesTask = DataRepo.General.FetchCategories();
var accountsTask = DataRepo.General.FetchAccounts();
var transactionsTask = DataRepo.General.FetchTransactions();
var budgetsTask = DataRepo.General.FetchBudgets();
await Task.Run(async () =>
{
var profilesTask = DataRepo.General.FetchProfileInfo(forceRefresh: true);
var categoriesTask = DataRepo.General.FetchCategories();
var transactionsTask = DataRepo.General.FetchTransactions();
var accountsTask = DataRepo.General.FetchAccounts();
var budgetsTask = DataRepo.General.FetchBudgets();
await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask);
await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask);
Profile = profilesTask.Result;
_categories = categoriesTask.Result;
_accounts = accountsTask.Result;
_transactions = transactionsTask.Result;
_budgets = budgetsTask.Result;
Profile = profilesTask.Result;
Console.WriteLine("fetched all data");
DataRepo.General.LinkTransactionCategories();
Console.WriteLine("fetched all data");
});
_dashboardViewModel = new DashboardViewModel()
{
parentViewModel = this,
Transactions = _transactions,
Categories = _categories,
Accounts = _accounts,
Budgets = _budgets
parentViewModel = this
};
_dashboardViewModel.initialize();
CurrentView = _dashboardViewModel;
Console.WriteLine("initialized DashboardViewModel");
_transactionsViewModel = new TransactionsViewModel()
{
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))
parentViewModel = this
};
await _transactionsViewModel.Initialize();
Console.WriteLine("initialized TransactionsViewModel");
_accountsViewModel = new AccountsViewModel()
{
parentViewModel = this,
Accounts = _accounts,
Transactions = _transactions
parentViewModel = this
};
await _accountsViewModel.Initialize();
Console.WriteLine("initialized AccountsViewModel");
_budgetViewModel = new BudgetViewModel()
{
parentViewModel = this,
Profile = Profile,
Budgets = _budgets,
Categories = _categories,
Transactions = _transactions
parentViewModel = this
};
await _budgetViewModel.Initialize();
Console.WriteLine("initialized BudgetViewModel");
SettingsViewModel = new SettingsViewModel()
{
parentViewModel = this
};
Console.WriteLine("initialized SettingsViewModel");
TransactionFormViewModel = new TransactionFormViewModel()
{
parentViewModel = this
};
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;
ThemeService.SwitchToTheme(Profile?.Theme ?? "system");
ThemeService.SwitchToTheme(AppData.Profile?.Theme ?? "system");
}
catch (Exception e)
{
@@ -124,42 +130,15 @@ public partial class MainViewModel : ViewModelBase
[RelayCommand]
public void OpenAddTransaction()
{
if (IsTransactionFormVisible) return;
if (IsDimmed) return;
try
{
TransactionFormViewModel.SetupForAdd(
new ObservableCollection<Category>(_categories),
new ObservableCollection<Account>(_accounts)
);
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();
};
TransactionFormViewModel.SetupForAdd();
TransactionFormViewModel.OnSaved = CloseTransactionForm;
TransactionFormViewModel.OnCancelled = CloseTransactionForm;
TransactionFormViewModel.OnDeleted = CloseTransactionForm;
IsTransactionFormVisible = true;
IsDimmed = true;
}
catch (Exception e)
{
@@ -171,41 +150,111 @@ public partial class MainViewModel : ViewModelBase
[RelayCommand]
public void OpenEditTransaction(Transaction transaction)
{
TransactionFormViewModel.SetupForEdit(
transaction,
new ObservableCollection<Category>(_categories),
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();
};
if (IsDimmed) return;
TransactionFormViewModel.SetupForEdit(transaction);
TransactionFormViewModel.OnSaved = CloseTransactionForm;
TransactionFormViewModel.OnCancelled = CloseTransactionForm;
TransactionFormViewModel.OnDeleted = () =>
{
if (TransactionFormViewModel.ResultTransaction is { } resultTransaction)
{
_transactionsViewModel.AllTransactions.Remove(resultTransaction);
_transactionsViewModel.LoadPageCommand.Execute(1);
}
CloseTransactionForm();
};
TransactionFormViewModel.OnDeleted = CloseTransactionForm;
IsTransactionFormVisible = true;
IsDimmed = true;
}
private void CloseTransactionForm()
{
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]
@@ -239,13 +288,19 @@ public partial class MainViewModel : ViewModelBase
CurrentView = _budgetViewModel;
}
[RelayCommand]
private void GoToSettings()
{
CurrentView = _settingsViewModel;
}
[RelayCommand]
private async Task SignOut()
{
await SupabaseService.Client.Auth.SignOut();
var user = SupabaseService.Client.Auth.CurrentUser;
switch (Application.Current.ApplicationLifetime)
switch (Application.Current?.ApplicationLifetime)
{
case IClassicDesktopStyleApplicationLifetime desktop:
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 isOnAccounts => CurrentView is AccountsViewModel;
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 required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
// ── Mode ────────────────────────────────────────────────
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
@@ -34,6 +35,7 @@ public partial class TransactionFormViewModel : ViewModelBase
[ObservableProperty] private string? _note;
[ObservableProperty] private List<DateTime> _dates = [DateTime.Now];
[ObservableProperty] private DateTime? _selectedDate;
[ObservableProperty] private string _currency = "USD";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
@@ -75,7 +77,7 @@ public partial class TransactionFormViewModel : ViewModelBase
public Transaction? ResultTransaction { get; set; }
// ── Commands ────────────────────────────────────────────
partial void OnSelectedCategoryChanged(Category? value)
{
if (value.Type == Type) return;
@@ -215,47 +217,43 @@ public partial class TransactionFormViewModel : ViewModelBase
// ── Public setup methods ─────────────────────────────────
/// <summary>Call this to open the form for adding a new transaction.</summary>
public void SetupForAdd(
ObservableCollection<Category> categories,
ObservableCollection<Account> accounts)
public void SetupForAdd()
{
ShowDeleteConfirm = false;
IsEditMode = false;
_editingId = null;
Categories = categories;
Accounts = accounts;
Categories = AppData.Categories;
Accounts = AppData.Accounts;
Type = "expense";
Amount = "";
Description = "";
Note = null;
Dates = [DateTime.Now];
ErrorMessage = null;
SelectedCategory = categories.Count > 0 ? categories[0] : null;
SelectedAccount = accounts.Count > 0 ? accounts[0] : null;
SelectedCategory = AppData.Categories.Count > 0 ? AppData.Categories[0] : null;
SelectedAccount = AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null;
ResultTransaction = null;
}
/// <summary>Call this to open the form for editing an existing transaction.</summary>
public void SetupForEdit(
Transaction transaction,
ObservableCollection<Category> categories,
ObservableCollection<Account> accounts)
Transaction transaction)
{
ShowDeleteConfirm = false;
IsEditMode = true;
_editingId = transaction.Id;
Categories = categories;
Accounts = accounts;
Categories = AppData.Categories;
Accounts = AppData.Accounts;
Type = transaction.Type;
Amount = transaction.Amount.ToString("0.00");
Description = transaction.Description;
Note = transaction.Note;
Dates = [transaction.Date];
ErrorMessage = null;
SelectedCategory = categories.FirstOrDefault(c => c.Id == transaction.CategoryId)
?? (categories.Count > 0 ? categories[0] : null);
SelectedAccount = accounts.FirstOrDefault(a => a.Id == transaction.AccountId)
?? (accounts.Count > 0 ? accounts[0] : null);
SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId)
?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null);
SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId)
?? (AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null);
ResultTransaction = transaction;
}
}

View File

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

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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:views="clr-namespace:Clario.Views"
xmlns:model="clr-namespace:Clario.Models"
x:DataType="vm:AccountsViewModel"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
@@ -18,7 +19,7 @@
<TextBlock Text="4 accounts" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="Accounts" FontSize="26" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</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">
<Svg Path="../Assets/Icons/plus.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
@@ -81,9 +82,11 @@
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{Binding CurrentBalance,StringFormat='$0.00'}" FontSize="15" FontWeight="Bold"
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.Path>
<MultiBinding Converter="{StaticResource DecimalColorConverter}">
@@ -123,6 +126,18 @@
</TextBlock>
</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>
</Grid>
</Button>
@@ -155,7 +170,9 @@
<TextBlock Text="{Binding SelectedAccount.Name}" FontSize="14" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="{Binding SelectedAccount.Institution}" FontSize="12" Foreground="{DynamicResource TextMuted}" />
</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}" />
</Button>
</Grid>
@@ -354,7 +371,9 @@
</Button>
<!-- Delete -->
<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">
<Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
@@ -369,5 +388,9 @@
</StackPanel>
</ScrollViewer>
</Grid>
<Grid Grid.Row="0" Grid.RowSpan="2">
<views:DeleteAccountDialogView IsVisible="{Binding DataContext.IsDeleteDialogVisible ,ElementName=AccountsPage }"
DataContext="{Binding Path=DeleteDialog}" />
</Grid>
</Grid>
</UserControl>

View File

@@ -17,7 +17,7 @@
<!-- <Border Background="{DynamicResource AccentBlue}" VerticalAlignment="Top" HorizontalAlignment="Left" Height="400" Width="400" Padding="10"> -->
<!-- -->
<!-- </Border> -->
<!-- Center card -->
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
@@ -38,7 +38,7 @@
CornerRadius="16"
Height="80"
HorizontalAlignment="Center" Margin="0 0 0 10">
<Image Source="../Assets/logo-textmark.png"/>
<Image Source="../Assets/logo-textmark.png" />
</Border>
<!-- REPLACE: app name -->
<StackPanel Spacing="4" HorizontalAlignment="Center">

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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:styles="clr-namespace:Clario.Theme.Styles"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.Views.BudgetFormView">
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="16"
Padding="24"
Width="380"
BoxShadow="0 12 40 0 #4C000000">
<StackPanel Spacing="0">
xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:behaviors="clr-namespace:Clario.Behaviors"
mc:Ignorable="d"
x:Class="Clario.Views.BudgetFormView"
x:DataType="vm:BudgetFormViewModel">
<Design.DataContext>
<vm:BudgetFormViewModel />
</Design.DataContext>
<!-- ── Header ─────────────────────────── -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,22">
<Border Grid.Column="0"
Background="{DynamicResource IconBgBlue}"
CornerRadius="10"
Width="38" Height="38"
Margin="0,0,12,0">
<Svg Path="../Assets/Icons/wallet.svg" Width="17" Height="17" Css="{DynamicResource SvgBlue}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="1">
<!-- REPLACE: bind to DialogTitle -->
<TextBlock Text="Edit Budget"
FontSize="15" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<!-- REPLACE: bind to SelectedCategory -->
<TextBlock Text="Food &amp; Dining"
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<Button Grid.Column="2"
Background="Transparent" BorderThickness="0"
Padding="6" VerticalAlignment="Top" Cursor="Hand">
<Svg Path="../Assets/Icons/x.svg" Width="15" Height="15" Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
<!-- ── Dim overlay ───────────────────────── -->
<Grid>
<Border Background="#70000000" />
<!-- ── Category ───────────────────────── -->
<TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Margin="0,0,0,18">
<Grid ColumnDefinitions="Auto,*">
<!-- ── 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"
Background="{DynamicResource IconBgGreen}"
CornerRadius="8"
Width="34" Height="34"
Margin="8,0,0,0"
VerticalAlignment="Center">
<!-- REPLACE: icon changes with SelectedCategory -->
<Svg Path="../Assets/Icons/utensils.svg" Width="15" Height="15" Css="{DynamicResource SvgGreen}" />
</Border>
<!-- REPLACE: SelectedItem="{Binding SelectedCategory}" -->
<ComboBox Grid.Column="1"
Background="Transparent" BorderThickness="0"
Padding="10,11" FontSize="13"
HorizontalAlignment="Stretch"
SelectedIndex="0">
<ComboBoxItem Content="Food &amp; Dining" />
<ComboBoxItem Content="Housing" />
<ComboBoxItem Content="Transport" />
<ComboBoxItem Content="Health" />
<ComboBoxItem Content="Leisure" />
<ComboBoxItem Content="Shopping" />
<ComboBoxItem Content="Education" />
<ComboBoxItem Content="Subscriptions" />
<ComboBoxItem Content="Other" />
</ComboBox>
</Grid>
</Border>
<!-- ── Monthly Limit ──────────────────── -->
<TextBlock Text="MONTHLY LIMIT" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="14,0"
Margin="0,0,0,8">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="$"
FontSize="15" FontWeight="SemiBold"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center" Margin="0,0,4,0" />
<!-- REPLACE: Text="{Binding LimitAmount, Mode=TwoWay}" -->
<TextBox Grid.Column="1" Text="500" Watermark="0.00"
Background="Transparent" BorderThickness="0"
FontSize="15" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
Height="44" Padding="0" VerticalContentAlignment="Center" />
</Grid>
</Border>
<!-- Quick-pick amounts -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,18">
<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}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="14,11"
Margin="0,0,0,18">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<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>
CornerRadius="10"
Width="42" Height="42"
Margin="0,0,14,0">
<Svg Path="../Assets/Icons/wallet-cards.svg"
Width="18" Height="18"
Css="{DynamicResource SvgMuted}" />
</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>
</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}" />
<!-- ── Category ───────────────────── -->
<TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,20">
<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 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>
<ComboBox Grid.Column="1"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
Background="Transparent"
BorderThickness="0"
Padding="8,10"
FontSize="13"
HorizontalAlignment="Stretch" />
</Grid>
</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"
Classes="base"
Margin="0,0,6,0" Padding="0,10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13" Content="Cancel" />
<!-- ── Limit Amount ────────────────── -->
<TextBlock Text="LIMIT AMOUNT" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,0"
Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Text="{Binding LimitAmount, Mode=TwoWay}"
Watermark="0.00"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Height="54"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
<!-- REPLACE: Command="{Binding SaveCommand}" -->
<Button Grid.Column="1"
Classes="accented"
Margin="6,0,0,0" Padding="0,10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center">
<!-- ── Period ─────────────────────── -->
<TextBlock Text="PERIOD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,20">
<Grid ColumnDefinitions="*,*,*">
<!-- Monthly -->
<Button Grid.Column="0"
Classes="nav"
Classes.accented="{Binding IsMonthly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="monthly">
<TextBlock Text="Monthly"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Button>
<!-- Quarterly -->
<Button Grid.Column="1"
Classes="nav"
Classes.accented="{Binding IsQuarterly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="quarterly">
<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>
</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/check.svg" Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Save" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" VerticalAlignment="Center" />
<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>
</Grid>
</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>
<!-- ── 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>

View File

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

View File

@@ -4,13 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
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"
x:DataType="vm:BudgetViewModel"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
x:Class="Clario.Views.BudgetView">
x:Class="Clario.Views.BudgetView"
x:Name="budgetControl">
<Design.DataContext>
<vm:BudgetViewModel />
</Design.DataContext>
@@ -52,7 +50,7 @@
<StackPanel Orientation="Horizontal">
<!-- REPLACE: Command="{Binding PreviousPeriodCommand}" -->
<Button Background="Transparent"
Classes="nav textless"
Classes="nav"
BorderThickness="0"
Padding="10,8"
Cursor="Hand"
@@ -70,7 +68,7 @@
Margin="4,0" />
<!-- REPLACE: Command="{Binding NextPeriodCommand}" -->
<Button Background="Transparent"
Classes="nav textless"
Classes="nav"
BorderThickness="0"
Padding="10,8"
Cursor="Hand"
@@ -85,13 +83,8 @@
<!-- Add budget button -->
<!-- REPLACE: Command="{Binding AddBudgetCommand}" -->
<Button Classes="accented"
Padding="16,9">
<Button.Flyout>
<Flyout Placement="LeftEdgeAlignedTop"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:BudgetFormView />
</Flyout>
</Button.Flyout>
Padding="16,9"
Command="{Binding CreateBudgetCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/plus.svg"
Width="14" Height="14"
@@ -135,6 +128,7 @@
Classes="label"
Margin="0,0,0,4" />
<Border IsVisible="{Binding !GroupHeader}"
Classes="editable"
Classes.budget-card="{Binding IsOnTrack}"
Classes.budget-card-warning="{Binding IsWarning}"
Classes.budget-card-over="{Binding IsOverBudget}"
@@ -144,7 +138,7 @@
Cursor="Hand">
<StackPanel Spacing="14">
<!-- Header row -->
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
CornerRadius="10"
Width="40" Height="40"
@@ -154,9 +148,18 @@
Color="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Width="18" Height="18"
Css="{Binding Category.Color,Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
<Panel>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Width="18" Height="18" Classes="hide"
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>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<!-- REPLACE: bind to Budget.CategoryName -->
@@ -192,24 +195,6 @@
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Right" />
</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>
<!-- Progress bar + remaining -->
@@ -569,7 +554,8 @@
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<!-- 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}" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
@@ -581,7 +567,7 @@
</StackPanel>
<!-- 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}"
CornerRadius="10"

View File

@@ -18,7 +18,8 @@
<!-- Top Bar -->
<Grid ColumnDefinitions="*,Auto">
<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"
Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</StackPanel>
@@ -113,7 +114,7 @@
<StackPanel Grid.Column="0">
<TextBlock Text="Spending by Category" FontSize="{StaticResource FontSizeSectionHeading}" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="March 2026" />
<TextBlock Classes="muted" Text="{Binding SelectedChartTimPeriodSubTitle}" />
</StackPanel>
<ComboBox Grid.Column="1" SelectedIndex="0" ItemsSource="{Binding ChartTimePeriods}"
SelectedItem="{Binding SelectedChartTimePeriod}" Background="{DynamicResource BgHover}"
@@ -124,7 +125,8 @@
<Panel>
<StackPanel Spacing="20" IsVisible="{Binding HasSpendingData}">
<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:XamlAxis IsVisible="False" />
</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">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10">
<Border Grid.Column="0" Background="{DynamicResource BorderAccent}" CornerRadius="{StaticResource RadiusPill}" Width="34"
Height="34">
<TextBlock Text="N" FontSize="{StaticResource FontSizeAmount}" FontWeight="Bold"
Foreground="{DynamicResource AccentBlue}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Panel Grid.Column="0">
<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"
Foreground="{DynamicResource AccentBlue}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Panel>
<TextBlock Grid.Column="1" Text="{Binding Profile.DisplayName}" TextTrimming="CharacterEllipsis"
FontSize="{StaticResource FontSizeBody}"
FontWeight="SemiBold"
@@ -158,7 +169,7 @@
<Button Classes="nav" HorizontalAlignment="Stretch" Classes.active="{Binding isOnBudget}" Command="{Binding GoToBudgetCommand}">
<StackPanel Orientation="Horizontal" Spacing="12">
<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>
</Button>
<TextBlock Classes="label" Text="REPORTS" Margin="12,20,0,10" />
@@ -169,7 +180,7 @@
</StackPanel>
</Button>
<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">
<Svg Path="../Assets/Icons/settings.svg" Height="14" Width="14" />
<TextBlock Text="Settings" FontSize="{StaticResource FontSizeBody}" VerticalAlignment="Center" />
@@ -178,13 +189,20 @@
</StackPanel>
</DockPanel>
</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">
<ContentControl Content="{Binding CurrentView}" />
<views:TransactionFormView
DataContext="{Binding TransactionFormViewModel}"
IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}">
</views:TransactionFormView>
IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}" />
<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>

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"
SelectionMode="SingleDate"
SelectedDates="{Binding Dates}"
SelectedDate="{Binding}"
HorizontalAlignment="Stretch"
Padding="12,10" />
<Button Grid.Column="1"

View File

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

View File

@@ -7,6 +7,7 @@
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<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.Themes.Fluent" 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.Interactivity" Version="11.3.6.6" />
<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" />
</ItemGroup>
</Project>