14 Commits

Author SHA1 Message Date
99ce4b8e55 a
All checks were successful
Build Linux / build (push) Successful in 1m10s
2026-04-01 21:46:25 +03:00
bdf52e82af 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
a8244ec0de changing action content 2026-03-28 18:57:20 +03:00
2affd56e38 explicitly set skiasharp version correctly this time 2026-03-28 18:28:41 +03:00
6714cccf1d explicitly set skiasharp version 2026-03-28 18:21:54 +03:00
06575fb224 fixed upload section i think 2026-03-28 18:12:48 +03:00
68c19a9adf seperated github and gitea workflows 2026-03-28 18:04:11 +03:00
ebf7aec77c fixed action 2026-03-28 18:01:24 +03:00
5c6a5fb41d fixed action 2026-03-28 18:00:51 +03:00
3754c67449 fixed action 2026-03-28 17:59:27 +03:00
582d6b5663 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.github/workflows/build-linux.yml
2026-03-28 17:56:10 +03:00
fc5c8d7b51 fixed path 2026-03-28 17:53:12 +03:00
Nouredeen Ghazal
0f8d0867ad Add packaging step for Linux build as tar.gz 2026-03-28 15:03:39 +03:00
Nouredeen Ghazal
0c782fd9b4 Update build workflow to publish Clario.Desktop
fixed path
2026-03-28 15:01:21 +03:00
68 changed files with 4452 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:
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
run: dotnet publish Clario/Clario.csproj \
--configuration Release \
--runtime linux-x64 \
run: |
dotnet publish Clario.Desktop/Clario.Desktop.csproj \
-r linux-x64 \
-c Release \
--self-contained true \
--output ./publish/linux \
-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
uses: actions/upload-artifact@v4

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 304 B

View File

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

After

Width:  |  Height:  |  Size: 334 B

View File

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

After

Width:  |  Height:  |  Size: 411 B

View File

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

After

Width:  |  Height:  |  Size: 333 B

View File

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

After

Width:  |  Height:  |  Size: 437 B

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@
CornerRadius="16"
Height="80"
HorizontalAlignment="Center" Margin="0 0 0 10">
<Image Source="../Assets/logo-textmark.png"/>
<Image Source="../Assets/logo-textmark.png" />
</Border>
<!-- REPLACE: app name -->
<StackPanel Spacing="4" HorizontalAlignment="Center">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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