22 Commits

Author SHA1 Message Date
98ad2461d8 a 2026-04-01 21:44:41 +03:00
1dce7d64a2 Added Budgets Create/Update/Delete, Account Create/Update/Delete, and fully added settings tab, and refactored a lot of the data logic 2026-04-01 21:34:36 +03:00
52e6ca4f62 changing action content 2026-03-28 18:57:20 +03:00
cc6c37d70f explicitly set skiasharp version correctly this time
All checks were successful
Build Linux / build (push) Successful in 58s
2026-03-28 18:28:41 +03:00
0a57408cc5 explicitly set skiasharp version 2026-03-28 18:21:54 +03:00
de40b0112a fixed upload section i think 2026-03-28 18:12:48 +03:00
558f6ea8d3 seperated github and gitea workflows 2026-03-28 18:04:11 +03:00
8af38a7f24 fixed action 2026-03-28 18:01:24 +03:00
b497fcf8c6 fixed action 2026-03-28 18:00:51 +03:00
180d2f0fff fixed action 2026-03-28 17:59:27 +03:00
5b94f48522 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.github/workflows/build-linux.yml
2026-03-28 17:56:10 +03:00
29fb04e783 fixed path 2026-03-28 17:53:12 +03:00
Nouredeen Ghazal
e0333e8279 Add packaging step for Linux build as tar.gz 2026-03-28 15:03:39 +03:00
Nouredeen Ghazal
209ff9626f Update build workflow to publish Clario.Desktop
fixed path
2026-03-28 15:01:21 +03:00
acfdf89ade - Fixed DateRangePicker when mode is singledate
- added Transaction Creation/Editing/Deletion
- added confirmation for transaction deletion
- added app icon
2026-03-28 14:56:54 +03:00
56d8653a02 fixed path 2026-03-28 14:44:34 +03:00
a563554597 build-linux.yml workflow added 2026-03-28 14:39:05 +03:00
81fc3c9c34 Restore Point 2026-03-26 20:42:15 +03:00
e5c15e9b83 budget-view, dashboard-view displayed correct, transactions-form finalized 2026-03-26 20:28:23 +03:00
76979b2cb0 Add gitignore 2026-03-19 04:07:27 +03:00
a6c8f3b3ed Initial Commit 2026-03-19 04:06:15 +03:00
Nouredeen Ghazal
1548339205 Initial commit 2026-03-19 04:02:17 +03:00
68 changed files with 4450 additions and 732 deletions

View File

@@ -0,0 +1,41 @@
name: Build Linux
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Publish
run: |
dotnet publish Clario.Desktop/Clario.Desktop.csproj \
-r linux-x64 \
-c Release \
--self-contained true \
-p:PublishSingleFile=false \
-o ./publish/linux-x64
- name: Package as tar.gz
run: tar -czf Clario-linux-x64.tar.gz -C ./publish/linux-x64 .
- name: Upload artifact
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: Clario-linux-x64
path: ./Clario-linux-x64.tar.gz
retention-days: 7

View File

@@ -19,20 +19,18 @@ jobs:
with: with:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore Clario/Clario.csproj
- name: Build
run: dotnet build Clario/Clario.csproj --configuration Release --no-restore
- name: Publish - name: Publish
run: dotnet publish Clario/Clario.csproj \ run: |
--configuration Release \ dotnet publish Clario.Desktop/Clario.Desktop.csproj \
--runtime linux-x64 \ -r linux-x64 \
--self-contained true \ -c Release \
--output ./publish/linux \ --self-contained true \
-p:PublishSingleFile=true \ -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -o ./publish/linux-x64
- name: Package as tar.gz
run: tar -czf Clario-linux-x64.tar.gz -C ./publish/linux-x64 .
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ public partial class App : Application
} }
IsMobile = ApplicationLifetime is ISingleViewApplicationLifetime; IsMobile = ApplicationLifetime is ISingleViewApplicationLifetime;
var culture = new CultureInfo("en-US"); var culture = new CultureInfo("en-US");
CultureInfo.DefaultThreadCurrentCulture = culture; CultureInfo.DefaultThreadCurrentCulture = culture;
@@ -60,8 +60,9 @@ public partial class App : Application
{ {
await SupabaseService.Client.Auth.RetrieveSessionAsync(); await SupabaseService.Client.Auth.RetrieveSessionAsync();
} }
catch catch (Exception e)
{ {
Console.WriteLine($"[Auth] RetrieveSession failed: {e.Message}");
} }
var user = SupabaseService.Client.Auth.CurrentUser; var user = SupabaseService.Client.Auth.CurrentUser;

View File

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

After

Width:  |  Height:  |  Size: 304 B

View File

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

After

Width:  |  Height:  |  Size: 334 B

View File

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

After

Width:  |  Height:  |  Size: 411 B

View File

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

After

Width:  |  Height:  |  Size: 333 B

View File

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

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,6 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M126.626 365.882C123.313 372.544 126.012 380.678 132.887 383.521C170.367 399.024 211.887 402.425 251.579 393.049C291.272 383.673 326.884 362.052 353.47 331.415C358.346 325.796 357.122 317.314 351.18 312.838L317.364 287.368C311.422 282.892 303.026 284.142 297.937 289.568C281.239 307.373 259.604 319.962 235.658 325.619C211.713 331.275 186.733 329.698 163.835 321.246C156.857 318.67 148.79 321.309 145.477 327.971L126.626 365.882Z" fill="#D9D9D9"/>
<path d="M36.1852 130.258C29.3501 127.322 21.3816 130.472 18.9273 137.495C4.15218 179.781 4.42187 226.037 19.9494 268.347C35.477 310.657 65.1913 346.101 103.809 368.783C110.224 372.55 118.337 369.796 121.65 363.134L140.501 325.223C143.814 318.561 141.05 310.533 134.786 306.521C111.958 291.9 94.4015 270.153 84.9763 244.471C75.5511 218.789 74.8708 190.846 82.82 164.924C85.0015 157.811 81.9166 149.902 75.0814 146.966L36.1852 130.258Z" fill="#D9D9D9"/>
<path d="M219.24 16.3331C219.888 8.92088 214.403 2.33719 206.965 2.20454C170.831 1.56007 135.136 11.0294 103.966 29.6733C72.7955 48.3173 47.5647 75.2901 31.0342 107.435C27.6317 114.052 30.8347 122.001 37.6698 124.937L76.5661 141.645C83.4012 144.581 91.2596 141.373 94.9148 134.892C105.519 116.092 120.864 100.295 139.517 89.1377C158.17 77.9805 179.345 71.9343 200.921 71.4863C208.358 71.3318 214.902 65.9253 215.55 58.5131L219.24 16.3331Z" fill="#D9D9D9"/>
<path d="M353.471 88.3147C359.447 83.8846 360.736 75.4123 355.903 69.7563C341.041 52.3641 323.197 37.7107 303.173 26.5042C283.15 15.2977 261.329 7.75132 238.736 4.18055C231.388 3.0193 224.843 8.55036 224.195 15.9625L220.506 58.1426C219.857 65.5548 225.363 72.0157 232.66 73.4596C245.497 75.9993 257.881 80.55 269.349 86.9681C280.816 93.3862 291.173 101.563 300.051 111.177C305.099 116.642 313.484 117.955 319.461 113.525L353.471 88.3147Z" fill="#D9D9D9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,11 @@
<svg width="599" height="200" viewBox="0 0 599 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M189.81 191.95V46.75H220.41V191.95H189.81Z" fill="white"/>
<path d="M282.558 193.95C273.625 193.95 265.625 191.75 258.558 187.35C251.625 182.95 246.092 176.95 241.958 169.35C237.958 161.75 235.958 153.083 235.958 143.35C235.958 133.617 237.958 124.95 241.958 117.35C246.092 109.75 251.625 103.75 258.558 99.35C265.625 94.95 273.625 92.75 282.558 92.75C289.092 92.75 294.958 94.0167 300.159 96.55C305.492 99.0833 309.825 102.617 313.159 107.15C316.492 111.55 318.359 116.617 318.758 122.35V164.35C318.359 170.083 316.492 175.217 313.159 179.75C309.958 184.15 305.692 187.617 300.358 190.15C295.025 192.683 289.092 193.95 282.558 193.95ZM288.758 166.35C295.292 166.35 300.559 164.217 304.559 159.95C308.559 155.55 310.559 150.017 310.559 143.35C310.559 138.817 309.625 134.817 307.758 131.35C306.025 127.883 303.492 125.217 300.159 123.35C296.958 121.35 293.225 120.35 288.958 120.35C284.692 120.35 280.892 121.35 277.558 123.35C274.358 125.217 271.758 127.883 269.758 131.35C267.892 134.817 266.958 138.817 266.958 143.35C266.958 147.75 267.892 151.683 269.758 155.15C271.625 158.617 274.225 161.35 277.558 163.35C280.892 165.35 284.625 166.35 288.758 166.35ZM309.359 191.95V165.75L313.959 142.15L309.359 118.55V94.75H339.358V191.95H309.359Z" fill="white"/>
<path d="M360.904 191.95V94.75H391.504V191.95H360.904ZM391.504 138.55L378.704 128.55C381.237 117.217 385.504 108.417 391.504 102.15C397.504 95.8833 405.837 92.75 416.504 92.75C421.17 92.75 425.237 93.4833 428.704 94.95C432.304 96.2833 435.437 98.4167 438.104 101.35L419.904 124.35C418.57 122.883 416.904 121.75 414.904 120.95C412.904 120.15 410.637 119.75 408.104 119.75C403.037 119.75 398.97 121.35 395.904 124.55C392.97 127.617 391.504 132.283 391.504 138.55Z" fill="white"/>
<path d="M446.255 191.95V94.75H476.855V191.95H446.255ZM461.655 81.35C456.855 81.35 452.855 79.75 449.655 76.55C446.589 73.2167 445.055 69.2167 445.055 64.55C445.055 59.75 446.589 55.75 449.655 52.55C452.855 49.35 456.855 47.75 461.655 47.75C466.455 47.75 470.389 49.35 473.455 52.55C476.522 55.75 478.055 59.75 478.055 64.55C478.055 69.2167 476.522 73.2167 473.455 76.55C470.389 79.75 466.455 81.35 461.655 81.35Z" fill="white"/>
<path d="M545.204 194.15C535.204 194.15 526.137 191.95 518.004 187.55C510.004 183.017 503.67 176.883 499.004 169.15C494.337 161.417 492.004 152.75 492.004 143.15C492.004 133.55 494.337 124.95 499.004 117.35C503.67 109.75 510.004 103.75 518.004 99.35C526.004 94.8167 535.07 92.55 545.204 92.55C555.337 92.55 564.404 94.75 572.404 99.15C580.404 103.55 586.737 109.617 591.404 117.35C596.07 124.95 598.404 133.55 598.404 143.15C598.404 152.75 596.07 161.417 591.404 169.15C586.737 176.883 580.404 183.017 572.404 187.55C564.404 191.95 555.337 194.15 545.204 194.15ZM545.204 166.35C549.604 166.35 553.47 165.417 556.804 163.55C560.137 161.55 562.67 158.817 564.404 155.35C566.27 151.75 567.204 147.683 567.204 143.15C567.204 138.617 566.27 134.683 564.404 131.35C562.537 127.883 559.937 125.217 556.604 123.35C553.404 121.35 549.604 120.35 545.204 120.35C540.937 120.35 537.137 121.35 533.804 123.35C530.47 125.217 527.87 127.883 526.004 131.35C524.137 134.817 523.204 138.817 523.204 143.35C523.204 147.75 524.137 151.75 526.004 155.35C527.87 158.817 530.47 161.55 533.804 163.55C537.137 165.417 540.937 166.35 545.204 166.35Z" fill="white"/>
<path d="M58.3942 177.927C56.8103 181.056 58.1009 184.876 61.3877 186.211C79.3085 193.492 99.1607 195.089 118.139 190.686C137.118 186.282 154.145 176.128 166.857 161.741C169.188 159.102 168.603 155.118 165.762 153.016L149.593 141.055C146.752 138.953 142.738 139.54 140.304 142.088C132.32 150.45 121.976 156.362 110.527 159.018C99.0775 161.675 87.1337 160.934 76.1854 156.965C72.8486 155.755 68.9915 156.995 67.4076 160.123L58.3942 177.927Z" fill="white"/>
<path d="M13.4482 64.2591C10.1788 62.8547 6.36734 64.3615 5.19343 67.7212C-1.87383 87.9474 -1.74484 110.073 5.68233 130.31C13.1095 150.548 27.3224 167.502 45.7942 178.351C48.8625 180.153 52.7433 178.836 54.3278 175.649L63.3447 157.515C64.9292 154.329 63.6073 150.489 60.6108 148.57C49.6917 141.577 41.2943 131.174 36.786 118.89C32.2777 106.606 31.9523 93.2397 35.7546 80.841C36.7981 77.4385 35.3225 73.6553 32.0531 72.2509L13.4482 64.2591Z" fill="white"/>
<path d="M101.59 6.83964C101.899 3.2592 99.2892 0.0789703 95.751 0.0148925C78.5612 -0.296416 61.5802 4.27773 46.7519 13.2836C31.9237 22.2895 19.9208 35.3186 12.0569 50.8462C10.4383 54.0423 11.962 57.882 15.2136 59.3002L33.7174 67.371C36.969 68.7892 40.7074 67.2396 42.4462 64.1093C47.4908 55.028 54.7906 47.3972 63.6644 42.0077C72.5383 36.6182 82.6114 33.6976 92.8756 33.4812C96.4136 33.4066 99.5267 30.795 99.8352 27.2146L101.59 6.83964Z" fill="white"/>
<path d="M167.422 40.8672C170.27 38.76 170.884 34.7301 168.581 32.0398C161.498 23.7672 152.994 16.7972 143.452 11.4668C133.909 6.13639 123.51 2.54693 112.743 0.848476C109.241 0.296127 106.122 2.92699 105.813 6.45263L104.055 26.5157C103.746 30.0414 106.37 33.1145 109.847 33.8013C115.965 35.0094 121.867 37.1739 127.332 40.2267C132.797 43.2795 137.732 47.1688 141.964 51.7416C144.369 54.3412 148.366 54.9659 151.214 52.8587L167.422 40.8672Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,76 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Clario.CustomControls"> xmlns:local="clr-namespace:Clario.CustomControls">
<Styles.Resources>
<!--
Full ControlTheme replacement for CalendarButton.
This is necessary because in Avalonia 11 the Fluent theme owns
the internal template parts at ControlTheme priority, making
external /template/ style selectors unreliable for CalendarButton.
Replacing the entire ControlTheme is the only reliable approach.
-->
<ControlTheme x:Key="{x:Type CalendarButton}" TargetType="CalendarButton">
<Setter Property="MinWidth" Value="40" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondary}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="Root"
Background="{TemplateBinding Background}"
CornerRadius="6"
Padding="4">
<ContentControl x:Name="Content"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}"
FontSize="{TemplateBinding FontSize}"
FontWeight="{TemplateBinding FontWeight}" />
</Border>
</ControlTemplate>
</Setter>
<!-- Hover -->
<Style Selector="^:pointerover /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
</Style>
<Style Selector="^:pointerover /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
<!-- Pressed -->
<Style Selector="^:pressed /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
</Style>
<!-- Selected (current month/year) -->
<Style Selector="^:selected /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderAccent}" />
</Style>
<Style Selector="^:selected /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}" />
</Style>
<!-- Selected + hover -->
<Style Selector="^:selected:pointerover /template/ Border#Root">
<Setter Property="Background" Value="{DynamicResource BorderAccent}" />
</Style>
<!-- Inactive (out-of-range months/years) -->
<Style Selector="^:inactive /template/ ContentControl#Content">
<Setter Property="Foreground" Value="{DynamicResource TextMuted}" />
</Style>
</ControlTheme>
</Styles.Resources>
<!-- ============================================================ -->
<!-- DateRangePicker control template -->
<!-- ============================================================ -->
<Style Selector="local|DateRangePicker"> <Style Selector="local|DateRangePicker">
<Setter Property="MinHeight" Value="15" /> <Setter Property="MinHeight" Value="15" />
<Setter Property="MinWidth" Value="50" /> <Setter Property="MinWidth" Value="50" />
@@ -14,8 +84,6 @@
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<Grid> <Grid>
<!-- Trigger button -->
<Button x:Name="PART_Button" <Button x:Name="PART_Button"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Background="{TemplateBinding Background}" Background="{TemplateBinding Background}"
@@ -48,7 +116,6 @@
</Grid> </Grid>
</Button> </Button>
<!-- Popup -->
<Popup x:Name="PART_Popup" <Popup x:Name="PART_Popup"
PlacementTarget="{Binding #PART_Button}" PlacementTarget="{Binding #PART_Button}"
Placement="Bottom" Placement="Bottom"
@@ -65,21 +132,50 @@
BorderThickness="0" /> BorderThickness="0" />
</Border> </Border>
</Popup> </Popup>
</Grid> </Grid>
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
</Style> </Style>
<!-- pointerover -->
<Style Selector="local|DateRangePicker:pointerover /template/ Button#PART_Button"> <Style Selector="local|DateRangePicker:pointerover /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BgHover}" /> <Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}" /> <Setter Property="BorderBrush" Value="{DynamicResource BorderAccent}" />
</Style> </Style>
<!-- pressed -->
<Style Selector="local|DateRangePicker:pressed /template/ Button#PART_Button"> <Style Selector="local|DateRangePicker:pressed /template/ Button#PART_Button">
<Setter Property="Background" Value="{DynamicResource BorderSubtle}" /> <Setter Property="Background" Value="{DynamicResource BorderSubtle}" />
</Style> </Style>
</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;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree;
using Calendar = Avalonia.Controls.Calendar; using Calendar = Avalonia.Controls.Calendar;
namespace Clario.CustomControls; namespace Clario.CustomControls;
@@ -23,19 +25,28 @@ public class DateRangePicker : TemplatedControl
set => SetValue(SelectionModeProperty, value); set => SetValue(SelectionModeProperty, value);
} }
public static readonly StyledProperty<IList<DateTime>> SelectedDatesProperty = // FIX: Use DirectProperty to avoid shared-instance default and get proper TwoWay support
AvaloniaProperty.Register<DateRangePicker, IList<DateTime>>( private IList<DateTime> _selectedDates = new List<DateTime>();
nameof(SelectedDates), new List<DateTime>());
public static readonly DirectProperty<DateRangePicker, IList<DateTime>> SelectedDatesProperty =
AvaloniaProperty.RegisterDirect<DateRangePicker, IList<DateTime>>(
nameof(SelectedDates),
o => o.SelectedDates,
(o, v) => o.SelectedDates = v,
defaultBindingMode: BindingMode.TwoWay);
public IList<DateTime> SelectedDates public IList<DateTime> SelectedDates
{ {
get => GetValue(SelectedDatesProperty); get => _selectedDates;
set => SetValue(SelectedDatesProperty, value); set => SetAndRaise(SelectedDatesProperty, ref _selectedDates, value);
} }
// FIX: Add defaultBindingMode: TwoWay so changes propagate back to the ViewModel
public static readonly StyledProperty<DateTime?> SelectedDateProperty = public static readonly StyledProperty<DateTime?> SelectedDateProperty =
AvaloniaProperty.Register<DateRangePicker, DateTime?>( AvaloniaProperty.Register<DateRangePicker, DateTime?>(
nameof(SelectedDate), null); nameof(SelectedDate),
defaultValue: null,
defaultBindingMode: BindingMode.TwoWay);
public DateTime? SelectedDate public DateTime? SelectedDate
{ {
@@ -58,7 +69,6 @@ public class DateRangePicker : TemplatedControl
private Popup? _popup; private Popup? _popup;
private Calendar? _calendar; private Calendar? _calendar;
private bool _isSyncing = false; private bool _isSyncing = false;
@@ -66,12 +76,12 @@ public class DateRangePicker : TemplatedControl
{ {
base.OnApplyTemplate(e); base.OnApplyTemplate(e);
if (_button != null) _button.Click -= OnButtonClick; if (_button != null) _button.Click -= OnButtonClick;
if (_calendar != null) if (_calendar != null)
{ {
_calendar.SelectedDatesChanged -= OnCalendarDatesChanged; _calendar.SelectedDatesChanged -= OnCalendarDatesChanged;
_calendar.RemoveHandler(PointerReleasedEvent, OnCalendarPointerReleased); _calendar.RemoveHandler(PointerReleasedEvent, OnCalendarPointerReleased);
// _calendar.RemoveHandler(Button.ClickEvent, OnCalendarInternalClick); // add this
} }
_button = e.NameScope.Find<Button>("PART_Button"); _button = e.NameScope.Find<Button>("PART_Button");
@@ -85,10 +95,9 @@ public class DateRangePicker : TemplatedControl
{ {
_calendar.AllowTapRangeSelection = true; _calendar.AllowTapRangeSelection = true;
_calendar.SelectedDatesChanged += OnCalendarDatesChanged; _calendar.SelectedDatesChanged += OnCalendarDatesChanged;
_calendar.AddHandler(PointerReleasedEvent, OnCalendarPointerReleased, RoutingStrategies.Tunnel); _calendar.AddHandler(PointerReleasedEvent, OnCalendarPointerReleased, RoutingStrategies.Tunnel);
// _calendar.AddHandler(Button.ClickEvent, OnCalendarInternalClick, RoutingStrategies.Tunnel);
SyncToCalendar(); SyncToCalendar();
} }
@@ -96,27 +105,32 @@ public class DateRangePicker : TemplatedControl
UpdateDisplayText(); UpdateDisplayText();
} }
private void OnCalendarInternalClick(object? sender, RoutedEventArgs e)
{
e.Handled = true;
}
private void OnCalendarPointerReleased(object? sender, PointerReleasedEventArgs e) private void OnCalendarPointerReleased(object? sender, PointerReleasedEventArgs e)
{ {
if (_calendar!.SelectionMode != CalendarSelectionMode.SingleDate) return; if (_calendar!.SelectionMode != CalendarSelectionMode.SingleDate) return;
if (_isSyncing) return; if (_isSyncing) return;
if (_popup is null || !_popup.IsOpen) return; if (_popup is null || !_popup.IsOpen) return;
// FIX: Ignore clicks on the nav buttons/header — only react to day cell clicks
if (e.Source is not Control source) return;
if (source.TemplatedParent is CalendarDayButton == false &&
source.FindAncestorOfType<CalendarDayButton>() is null)
return;
var newDates = _calendar!.SelectedDates.OrderBy(d => d).ToList(); var newDates = _calendar!.SelectedDates.OrderBy(d => d).ToList();
_isSyncing = true; _isSyncing = true;
try try
{ {
SelectedDates = newDates; SelectedDates = newDates;
SelectedDate = newDates.Count > 0 ? newDates[0] : null; SelectedDate = newDates.Count > 0 ? newDates[0] : null;
UpdateDisplayText(); UpdateDisplayText();
bool shouldClose = SelectionMode switch bool shouldClose = SelectionMode switch
{ {
CalendarSelectionMode.SingleDate => newDates.Count >= 1, CalendarSelectionMode.SingleDate => newDates.Count >= 1,
@@ -133,12 +147,10 @@ public class DateRangePicker : TemplatedControl
} }
} }
private void OnButtonClick(object? sender, RoutedEventArgs e) private void OnButtonClick(object? sender, RoutedEventArgs e)
{ {
if (_popup is null) return; if (_popup is null) return;
SyncToCalendar(); SyncToCalendar();
_popup.IsOpen = true; _popup.IsOpen = true;
} }
@@ -157,13 +169,10 @@ public class DateRangePicker : TemplatedControl
try try
{ {
SelectedDates = newDates; SelectedDates = newDates;
SelectedDate = newDates.Count > 0 ? newDates[0] : null; SelectedDate = newDates.Count > 0 ? newDates[0] : null;
UpdateDisplayText(); UpdateDisplayText();
bool shouldClose = SelectionMode switch bool shouldClose = SelectionMode switch
{ {
CalendarSelectionMode.SingleDate => newDates.Count >= 1, CalendarSelectionMode.SingleDate => newDates.Count >= 1,
@@ -180,7 +189,6 @@ public class DateRangePicker : TemplatedControl
} }
} }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
@@ -220,7 +228,6 @@ public class DateRangePicker : TemplatedControl
} }
} }
private void SyncToCalendar() private void SyncToCalendar()
{ {
if (_calendar is null || _isSyncing) return; if (_calendar is null || _isSyncing) return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Clario.Data; using Clario.Data;
using Clario.Messages; using Clario.Messages;
using Clario.Models; using Clario.Models;
@@ -11,20 +10,22 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
// ReSharper disable PossibleMultipleEnumeration
namespace Clario.ViewModels; namespace Clario.ViewModels;
public partial class TransactionsViewModel : ViewModelBase public partial class TransactionsViewModel : ViewModelBase
{ {
public required ViewModelBase parentViewModel; public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
public List<Transaction> AllTransactions = new();
[ObservableProperty] private ObservableCollection<Category> _categories = new(); [ObservableProperty] private ObservableCollection<Category> _categories = new();
[ObservableProperty] private ObservableCollection<Account> _accounts = new(); [ObservableProperty] private ObservableCollection<Account> _accounts = new();
[ObservableProperty] private List<Transaction> _filteredTransactions = new(); [ObservableProperty] private List<Transaction> _filteredTransactions = new();
private int _pageSize = 25; private int _pageSize = 25;
[ObservableProperty] private int _pageSizeIndex = 0; [ObservableProperty] private int _pageSizeIndex;
[ObservableProperty] [NotifyPropertyChangedFor(nameof(TotalPages))] [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))] [ObservableProperty] [NotifyPropertyChangedFor(nameof(TotalPages))] [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))]
private int _currentPage = 1; private int _currentPage = 1;
@@ -56,8 +57,8 @@ public partial class TransactionsViewModel : ViewModelBase
public List<int> PageNumbers { get; set; } public List<int> PageNumbers { get; set; }
[ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new(); [ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new();
public int TotalPages => (int)Math.Ceiling(_filteredTransactions.Count / (double)_pageSize); public int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
public bool HasNoTransactions => _filteredTransactions.Count == 0; public bool HasNoTransactions => FilteredTransactions.Count == 0;
public bool HasNextPage => CurrentPage < TotalPages; public bool HasNextPage => CurrentPage < TotalPages;
public bool HasPreviousPage => CurrentPage > 1; public bool HasPreviousPage => CurrentPage > 1;
@@ -65,6 +66,7 @@ public partial class TransactionsViewModel : ViewModelBase
[ObservableProperty] private double _totalIncome; [ObservableProperty] private double _totalIncome;
[ObservableProperty] private int _expensesCount; [ObservableProperty] private int _expensesCount;
[ObservableProperty] private int _incomeCount; [ObservableProperty] private int _incomeCount;
[ObservableProperty] private string _dateRangeLabel = "";
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private Category _selectedCategory; [ObservableProperty] private Category _selectedCategory;
@@ -84,6 +86,13 @@ public partial class TransactionsViewModel : ViewModelBase
public TransactionsViewModel() public TransactionsViewModel()
{ {
AppData.Transactions.CollectionChanged += (_, _) =>
{
InitializeCategories();
InitializeAccounts();
LoadPage(1);
};
Initialize();
} }
partial void OnPageSizeIndexChanged(int value) partial void OnPageSizeIndexChanged(int value)
@@ -121,8 +130,7 @@ public partial class TransactionsViewModel : ViewModelBase
{ {
ApplyFilters(); ApplyFilters();
if (CurrentPage != page) CurrentPage = page; if (CurrentPage != page) CurrentPage = page;
var items = _filteredTransactions var items = FilteredTransactions.Skip((page - 1) * _pageSize)
.Skip((page - 1) * _pageSize)
.Take(_pageSize); .Take(_pageSize);
OnPropertyChanged(nameof(HasNoTransactions)); OnPropertyChanged(nameof(HasNoTransactions));
@@ -130,9 +138,9 @@ public partial class TransactionsViewModel : ViewModelBase
foreach (var item in items) foreach (var item in items)
PagedTransactions.Add(item); PagedTransactions.Add(item);
PaginationSummaryText = PaginationSummaryText =
$"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, _filteredTransactions.Count))} of {_filteredTransactions.Count} transactions"; $"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, FilteredTransactions.Count))} of {FilteredTransactions.Count} transactions";
PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList(); PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList();
var numbers = GetSurrounding(PageNumbers, page, 5); var numbers = GetSurrounding(PageNumbers, page);
VisiblePageNumbers.Clear(); VisiblePageNumbers.Clear();
foreach (var number in numbers) foreach (var number in numbers)
VisiblePageNumbers.Add(number); VisiblePageNumbers.Add(number);
@@ -143,43 +151,45 @@ public partial class TransactionsViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void ApplyFilters() private void ApplyFilters()
{ {
// Console.WriteLine($"Search Text: {_searchText}"); var filtered = AppData.Transactions.Where(x =>
// Console.WriteLine($"Category: {_selectedCategory.Name}"); x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
// Console.WriteLine($"Account: {_selectedAccount.Name}"); || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
// Console.WriteLine($"Transaction Type: {_transactionType}");
var culture = new CultureInfo("en-US");
var filtered = AllTransactions.Where(x =>
x.Description.Contains(_searchText, StringComparison.OrdinalIgnoreCase)
|| x.Note.Contains(_searchText, StringComparison.OrdinalIgnoreCase));
switch (SelectedDateRangeOption) switch (SelectedDateRangeOption)
{ {
case "All Time": case "All Time":
// do nothing DateRangeLabel = "ALL TIME";
break; break;
case "Today": case "Today":
filtered = filtered.Where(x => x.Date == DateTime.Now.Date); filtered = filtered.Where(x => x.Date == DateTime.Now.Date);
DateRangeLabel = DateTime.Now.ToString("MMM d, yyyy", culture).ToUpper();
break; break;
case "This Week": case "This Week":
var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek); var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek);
var endOfWeek = startOfWeek.AddDays(6); var endOfWeek = startOfWeek.AddDays(6);
filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek); filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek);
DateRangeLabel = "THIS WEEK";
break; break;
case "This Month": case "This Month":
filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month); filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month);
DateRangeLabel = DateTime.Now.ToString("MMMM yyyy", culture).ToUpper();
break; break;
case "Last Month": case "Last Month":
var lastMonth = DateTime.Now.AddMonths(-1); var lastMonth = DateTime.Now.AddMonths(-1);
filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year); filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year);
DateRangeLabel = lastMonth.ToString("MMMM yyyy", culture).ToUpper();
break; break;
case "This Quarter": case "This Quarter":
var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3); var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3);
var endOfQuarter = startOfQuarter.AddMonths(3); var endOfQuarter = startOfQuarter.AddMonths(3);
filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter); filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter);
DateRangeLabel = $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}";
break; break;
case "This Year": case "This Year":
filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year); filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year);
DateRangeLabel = DateTime.Now.Year.ToString();
break; break;
case "Custom Range": case "Custom Range":
if (SelectedDates is not null && SelectedDates.Count > 0) if (SelectedDates is not null && SelectedDates.Count > 0)
@@ -192,25 +202,35 @@ public partial class TransactionsViewModel : ViewModelBase
var start = ordered.First(); var start = ordered.First();
var end = ordered.Last(); var end = ordered.Last();
Console.WriteLine($"first {SelectedDates.First():d} / last {SelectedDates.Last():d}");
if (SelectedDates.Count == 1) if (SelectedDates.Count == 1)
{
filtered = filtered.Where(x => x.Date.Date == start); filtered = filtered.Where(x => x.Date.Date == start);
DateRangeLabel = start.ToString("MMM dd, yyyy", culture).ToUpper();
}
else else
{
filtered = filtered.Where(x => x.Date.Date >= start && x.Date.Date <= end); filtered = filtered.Where(x => x.Date.Date >= start && x.Date.Date <= end);
DateRangeLabel = $"{start.ToString("MMM dd", culture)} - {end.ToString("MMM dd, yyyy", culture)}".ToUpper();
}
} }
break; break;
} }
if (_selectedCategory.Name != "All Categories")
filtered = filtered.Where(x => x.CategoryId == _selectedCategory.Id);
if (_selectedAccount.Name != "All Accounts") // Calculate totals based on date-filtered transactions
filtered = filtered.Where(x => x.AccountId == _selectedAccount.Id); TotalExpenses = filtered.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.Amount));
TotalIncome = filtered.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.Amount));
if (_transactionType != "all") if (SelectedCategory.Name != "All Categories")
filtered = filtered.Where(x => x.Type == _transactionType); filtered = filtered.Where(x => x.CategoryId == SelectedCategory.Id);
if (SelectedAccount.Name != "All Accounts")
filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id);
if (TransactionType != "all")
filtered = filtered.Where(x => x.Type == TransactionType);
switch (SelectedSortOption) switch (SelectedSortOption)
{ {
@@ -297,7 +317,7 @@ public partial class TransactionsViewModel : ViewModelBase
} }
} }
public async Task Initialize() public void Initialize()
{ {
try try
{ {
@@ -320,21 +340,31 @@ public partial class TransactionsViewModel : ViewModelBase
private void InitializeCategories() private void InitializeCategories()
{ {
Categories.Insert(0, new Category() { Name = "All Categories" }); Categories.Insert(0, new Category() { Name = "All Categories" });
foreach (var appDataCategory in AppData.Categories)
{
Categories.Add(appDataCategory);
}
SelectedCategory = Categories.First(); SelectedCategory = Categories.First();
} }
private void InitializeAccounts() private void InitializeAccounts()
{ {
Accounts.Insert(0, new Account() { Name = "All Accounts" }); Accounts.Insert(0, new Account() { Name = "All Accounts" });
foreach (var appDataAccount in AppData.Accounts)
{
Accounts.Add(appDataAccount);
}
SelectedAccount = Accounts.First(); SelectedAccount = Accounts.First();
} }
private void CalculateMonthlyFinancials() private void CalculateMonthlyFinancials()
{ {
TotalExpenses = AllTransactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount)); TotalExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount));
TotalIncome = AllTransactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount)); TotalIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.Amount));
ExpensesCount = AllTransactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month); ExpensesCount = AppData.Transactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month);
IncomeCount = AllTransactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month); IncomeCount = AppData.Transactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month);
} }
public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5) public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5)
@@ -351,13 +381,13 @@ public partial class TransactionsViewModel : ViewModelBase
return list.GetRange(start, end - start); return list.GetRange(start, end - start);
} }
[RelayCommand] [RelayCommand]
private void CreateTransaction() private void CreateTransaction()
{ {
((MainViewModel)parentViewModel).OpenAddTransaction(); ((MainViewModel)parentViewModel).OpenAddTransaction();
} }
[RelayCommand] [RelayCommand]
private void EditTransaction(Transaction transaction) 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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels" xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:views="clr-namespace:Clario.Views"
xmlns:model="clr-namespace:Clario.Models" xmlns:model="clr-namespace:Clario.Models"
x:DataType="vm:AccountsViewModel" x:DataType="vm:AccountsViewModel"
mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="1180" d:DesignHeight="800"
@@ -18,7 +19,7 @@
<TextBlock Text="4 accounts" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Text="4 accounts" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="Accounts" FontSize="26" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" /> <TextBlock Text="Accounts" FontSize="26" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accented" Padding="16,9" VerticalAlignment="Center"> <Button Grid.Column="1" Classes="accented" Padding="16,9" VerticalAlignment="Center" Command="{Binding CreateAccountCommand}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/plus.svg" Width="14" Height="14" <Svg Path="../Assets/Icons/plus.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" /> Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
@@ -81,9 +82,11 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="4"> <StackPanel Grid.Column="2" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{Binding CurrentBalance,StringFormat='$0.00'}" FontSize="15" FontWeight="Bold" <TextBlock Text="{Binding CurrentBalance,StringFormat='$0.00'}" FontSize="15" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Right" /> Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Right" />
<StackPanel Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" Spacing="4" HorizontalAlignment="Right"
IsVisible="{Binding !isCredit}">
<Svg Width="12" Height="12"> <Svg Width="12" Height="12">
<Svg.Path> <Svg.Path>
<MultiBinding Converter="{StaticResource DecimalColorConverter}"> <MultiBinding Converter="{StaticResource DecimalColorConverter}">
@@ -123,6 +126,18 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Spacing="5" IsVisible="{Binding isCredit}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Credit Utilisation" FontSize="11"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Grid.Column="1" Text="{Binding CreditUtilizationPerc, StringFormat='0%'}" FontSize="11"
Foreground="{DynamicResource AccentYellow}"
Margin="8,0,0,0" />
</Grid>
<ProgressBar Classes="yellow"
Value="{Binding CurrentBalance, Converter={StaticResource CreditAmountConverter}}"
Minimum="0" Maximum="{Binding CreditLimit}" Width="160" Height="4" />
</StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Button> </Button>
@@ -155,7 +170,9 @@
<TextBlock Text="{Binding SelectedAccount.Name}" FontSize="14" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" /> <TextBlock Text="{Binding SelectedAccount.Name}" FontSize="14" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="{Binding SelectedAccount.Institution}" FontSize="12" Foreground="{DynamicResource TextMuted}" /> <TextBlock Text="{Binding SelectedAccount.Institution}" FontSize="12" Foreground="{DynamicResource TextMuted}" />
</StackPanel> </StackPanel>
<Button Grid.Column="2" Background="Transparent" BorderThickness="0" Padding="6" VerticalAlignment="Top" Cursor="Hand"> <Button Grid.Column="2" Background="Transparent" BorderThickness="0" Padding="6" VerticalAlignment="Top" Cursor="Hand"
Command="{Binding EditAccountCommand
}" CommandParameter="{Binding SelectedAccount}">
<Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" /> <Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" />
</Button> </Button>
</Grid> </Grid>
@@ -354,7 +371,9 @@
</Button> </Button>
<!-- Delete --> <!-- Delete -->
<Button Background="#2A0D0D" BorderBrush="#3A1515" BorderThickness="1" CornerRadius="10" Padding="14,10" <Button Background="#2A0D0D" BorderBrush="#3A1515" BorderThickness="1" CornerRadius="10" Padding="14,10"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Cursor="Hand"> HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Cursor="Hand"
Command="{Binding RequestDeleteAccountCommand}" CommandParameter="{Binding SelectedAccount}"
IsEnabled="{Binding CanDeleteAccount}">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14" <Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" /> Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
@@ -369,5 +388,9 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
<Grid Grid.Row="0" Grid.RowSpan="2">
<views:DeleteAccountDialogView IsVisible="{Binding DataContext.IsDeleteDialogVisible ,ElementName=AccountsPage }"
DataContext="{Binding Path=DeleteDialog}" />
</Grid>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -17,7 +17,7 @@
<!-- <Border Background="{DynamicResource AccentBlue}" VerticalAlignment="Top" HorizontalAlignment="Left" Height="400" Width="400" Padding="10"> --> <!-- <Border Background="{DynamicResource AccentBlue}" VerticalAlignment="Top" HorizontalAlignment="Left" Height="400" Width="400" Padding="10"> -->
<!-- --> <!-- -->
<!-- </Border> --> <!-- </Border> -->
<!-- Center card --> <!-- Center card -->
<Border HorizontalAlignment="Center" <Border HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -38,7 +38,7 @@
CornerRadius="16" CornerRadius="16"
Height="80" Height="80"
HorizontalAlignment="Center" Margin="0 0 0 10"> HorizontalAlignment="Center" Margin="0 0 0 10">
<Image Source="../Assets/logo-textmark.png"/> <Image Source="../Assets/logo-textmark.png" />
</Border> </Border>
<!-- REPLACE: app name --> <!-- REPLACE: app name -->
<StackPanel Spacing="4" HorizontalAlignment="Center"> <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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:styles="clr-namespace:Clario.Theme.Styles" xmlns:vm="clr-namespace:Clario.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" xmlns:behaviors="clr-namespace:Clario.Behaviors"
x:Class="Clario.Views.BudgetFormView"> mc:Ignorable="d"
<Border Background="{DynamicResource BgSurface}" x:Class="Clario.Views.BudgetFormView"
BorderBrush="{DynamicResource BorderSubtle}" x:DataType="vm:BudgetFormViewModel">
BorderThickness="1" <Design.DataContext>
CornerRadius="16" <vm:BudgetFormViewModel />
Padding="24" </Design.DataContext>
Width="380"
BoxShadow="0 12 40 0 #4C000000">
<StackPanel Spacing="0">
<!-- ── Header ─────────────────────────── --> <!-- ── Dim overlay ───────────────────────── -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,22"> <Grid>
<Border Grid.Column="0" <Border Background="#70000000" />
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>
<!-- ── Category ───────────────────────── --> <!-- ── Modal card ────────────────────────── -->
<TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" /> <Border HorizontalAlignment="Center"
<Border Background="{DynamicResource BgBase}" VerticalAlignment="Center"
BorderBrush="{DynamicResource BorderSubtle}" Background="{DynamicResource BgSurface}"
BorderThickness="1" BorderBrush="{DynamicResource BorderSubtle}"
CornerRadius="10" BorderThickness="1"
Margin="0,0,0,18"> CornerRadius="18"
<Grid ColumnDefinitions="Auto,*"> 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" <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}" Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}" BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1" BorderThickness="1"
CornerRadius="8" CornerRadius="10"
Margin="8"> Width="42" Height="42"
<StackPanel Orientation="Horizontal"> Margin="0,0,14,0">
<Button Background="Transparent" BorderThickness="0" Padding="8,6" Cursor="Hand"> <Svg Path="../Assets/Icons/wallet-cards.svg"
<Svg Path="../Assets/Icons/minus.svg" Width="12" Height="12" Css="{DynamicResource SvgMuted}" /> Width="18" Height="18"
</Button> Css="{DynamicResource SvgMuted}" />
<!-- REPLACE: bind to AlertThreshold -->
<TextBlock Text="80%"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" Margin="4,0" />
<Button Background="Transparent" BorderThickness="0" Padding="8,6" Cursor="Hand">
<Svg Path="../Assets/Icons/plus.svg" Width="12" Height="12" Css="{DynamicResource SvgMuted}" />
</Button>
</StackPanel>
</Border> </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> </Grid>
</Border>
<!-- Threshold quick-picks --> <!-- ── Category ───────────────────── -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,24"> <TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="20" Padding="10,4" <Border Background="{DynamicResource BgBase}"
Cursor="Hand"> BorderBrush="{DynamicResource BorderSubtle}"
<TextBlock Text="60%" FontSize="11" Foreground="{DynamicResource TextMuted}" /> 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>
<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 ────────────────────────── --> <!-- ── Limit Amount ────────────────── -->
<Grid ColumnDefinitions="*,*"> <TextBlock Text="LIMIT AMOUNT" Classes="label" Margin="0,0,0,6" />
<!-- REPLACE: Command="{Binding CancelCommand}" --> <Border Background="{DynamicResource BgBase}"
<Button Grid.Column="0" BorderBrush="{DynamicResource BorderSubtle}"
Classes="base" BorderThickness="1"
Margin="0,0,6,0" Padding="0,10" CornerRadius="{DynamicResource RadiusControl}"
HorizontalAlignment="Stretch" Padding="14,0"
HorizontalContentAlignment="Center" Margin="0,0,0,20">
FontSize="13" Content="Cancel" /> <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}" --> <!-- ── Period ─────────────────────── -->
<Button Grid.Column="1" <TextBlock Text="PERIOD" Classes="label" Margin="0,0,0,6" />
Classes="accented" <Border Background="{DynamicResource BgBase}"
Margin="6,0,0,0" Padding="0,10" BorderBrush="{DynamicResource BorderSubtle}"
HorizontalAlignment="Stretch" BorderThickness="1"
HorizontalContentAlignment="Center"> 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"> <StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg" Width="13" Height="13" <Svg Path="../Assets/Icons/circle-alert.svg"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" /> Width="13" Height="13"
<TextBlock Text="Save" FontSize="13" FontWeight="SemiBold" Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
Foreground="{DynamicResource BgBase}" VerticalAlignment="Center" /> <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> </StackPanel>
</Button> </Button>
</Grid>
</StackPanel> <!-- ── Actions ──────────────────────── -->
</Border> <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> </UserControl>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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