4 Commits
v0.5.0 ... main

Author SHA1 Message Date
d6efa72745 updated README.md 2026-04-11 00:42:53 +03:00
2ce47ee305 updated README.md 2026-04-11 00:40:14 +03:00
90b2abd587 stuff
Some checks failed
Build Linux / build (push) Failing after 23s
2026-04-09 23:56:34 +03:00
61ff949c19 Add analytics page, auth error handling, and period navigation fix
Some checks failed
Build Linux / build (push) Failing after 24s
Features
Analytics Page: Full-featured analytics dashboard with KPI cards, cash flow trend chart, net worth progression, spending patterns by day-of-week, top spending categories, and income sources breakdown. Includes PDF export via QuestPDF for selected periods. Implemented on both desktop and mobile (simplified).
Auth Error Handling: Map Supabase GotrueException errors to AuthError enum with user-friendly messages for login and signup. Display errors in sign-in and sign-up panels.
Dynamic Transaction/Account Counts: Replace hardcoded "46 transactions" and "4 accounts" text with FilteredTransactionCount and ActiveAccountCount properties bound to actual data.

Fixes
Budget Period Navigation: Fix year-aware date comparison in CanGoToPreviousPeriod and CanGoToNextPeriod. Previously only compared months, preventing navigation before January of current year.

Changes
AnalyticsViewModel: Period selector, KPI calculations, chart data builders (cash flow, net worth, day-of-week, top categories, income sources), PDF export
PdfExportService: QuestPDF report generation with print-optimized styling
AuthViewModel: Error display with GotrueException mapping
BudgetViewModel: Year-aware period navigation
TransactionsViewModel: FilteredTransactionCount property
AccountsViewModel: ActiveAccountCount property
MainViewModel: Analytics navigation and AnalyticsViewModel integration
Views: Analytics button wired, error messages displayed, count bindings updated
2026-04-05 23:08:34 +03:00
136 changed files with 10432 additions and 1373 deletions

View File

@@ -3,7 +3,11 @@
"allow": [
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(chmod +x \"/c/Users/Nouredeen/.claude/scripts/context-bar.sh\")",
"Bash(dotnet build:*)"
"Bash(dotnet build:*)",
"WebFetch(domain:git.nouredeen.dev)",
"WebFetch(domain:supabase.com)",
"Bash(grep:*)",
"Bash(cmd:*)"
]
},
"spinnerTipsEnabled": true

View File

@@ -1,41 +0,0 @@
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

@@ -1,40 +0,0 @@
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=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
with:
name: Clario-linux-x64
path: ./publish/linux
retention-days: 7

5
.gitignore vendored
View File

@@ -7,4 +7,7 @@ obj/
./Clario/CLAUDE_CONTEXT.md
publish/
*.tar.gz
Clario/devsettings.json
Clario/devsettings.json
.env
TODO.md
clario.keystore

View File

@@ -20,6 +20,7 @@
<PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />

View File

@@ -1,21 +1,50 @@
using Android.App;
using System;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using Avalonia;
using Avalonia.Android;
using Clario;
namespace Clario.Android;
[Activity(
Label = "Clario.Android",
Label = "Clario",
Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
[IntentFilter(
new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "clario",
DataHost = "auth")]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override void OnCreate(Bundle? savedInstanceState)
{
// Capture deep link before Avalonia initializes
var uri = Intent?.DataString;
if (uri?.StartsWith("clario://", StringComparison.OrdinalIgnoreCase) == true)
App.PendingDeepLink = uri;
base.OnCreate(savedInstanceState);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
// Called when app is already running (SingleTop) and link is opened again
var uri = intent?.DataString;
if (uri?.StartsWith("clario://", StringComparison.OrdinalIgnoreCase) == true)
_ = App.HandleDeepLink(uri);
}
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont();
}
}
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Deadpikle.AvaloniaProgressRing"/>
<PackageReference Include="FluentAvalonia.ProgressRing"/>
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia"/>
<PackageReference Include="QuestPDF" />
<PackageReference Include="SkiaSharp"/>
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly"/>

View File

@@ -22,6 +22,7 @@
<PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<PackageReference Include="Supabase" />

View File

@@ -2,7 +2,7 @@
"GeneralSettings": {
"NetProjectPath": "Clario.Desktop.csproj",
"ApplicationName": "Clario",
"Version": "0.4.0",
"Version": "0.6.0",
"PackageName": {
"$type": "msbuild",
"property": "AssemblyName"
@@ -13,11 +13,11 @@
}
},
"LinuxSettings": {
"AppIcon": "../Clario/Assets/Logo.png",
"AppIcon": "../Clario/Assets/AppIcons/logo-icon-primary-transparent.ico",
"CreateBinSymlink": "True"
},
"Win32Settings": {
"InstallerIcon": "../Clario/Assets/Clario-Logo.svg",
"InstallerIcon": "../Clario/Assets/AppIcons/logo-icon-primary-transparent.ico",
"Company": "Clario",
"IncludeUninstaller": "True"
},

View File

@@ -1,19 +1,25 @@
using System;
using System;
using System.Linq;
using Avalonia;
using Clario;
namespace Clario.Desktop;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Capture deep link passed as command-line arg by Windows protocol handler
var deepLink = args.FirstOrDefault(a =>
a.StartsWith("clario://", StringComparison.OrdinalIgnoreCase));
if (deepLink != null)
App.PendingDeepLink = deepLink;
// Register clario:// URL scheme on Windows (idempotent)
RegisterUrlScheme();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
@@ -22,4 +28,23 @@ sealed class Program
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
private static void RegisterUrlScheme()
{
if (!OperatingSystem.IsWindows()) return;
try
{
var exe = Environment.ProcessPath
?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (exe is null) return;
using var key = Microsoft.Win32.Registry.CurrentUser
.CreateSubKey(@"SOFTWARE\Classes\clario");
key.SetValue("", "URL:Clario Protocol");
key.SetValue("URL Protocol", "");
using var cmd = key.CreateSubKey(@"shell\open\command");
cmd.SetValue("", $"\"{exe}\" \"%1\"");
}
catch { /* ignore — no registry write access in sandboxed environments */ }
}
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="SkiaSharp" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />

View File

@@ -36,5 +36,15 @@
<StyleInclude Source="../Theme/AppTheme.axaml" />
<StyleInclude Source="avares://AvaloniaProgressRing/Styles/ProgressRing.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude Source="avares://FluentAvalonia.ProgressRing/Styling/Controls/ProgressRing.axaml" />
<!-- Must come after ColorPicker Fluent.xaml to override Width="64" setter -->
<Styles>
<Style Selector="ColorPicker">
<Setter Property="Width" Value="NaN" />
</Style>
<Style Selector="ColorPicker /template/ DropDownButton">
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</Styles>
</Application.Styles>
</Application>

View File

@@ -1,5 +1,6 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
@@ -17,6 +18,47 @@ public partial class App : Application
{
public static bool IsMobile { get; private set; }
/// <summary>Set before OnFrameworkInitializationCompleted runs (from Program.cs or MainActivity).</summary>
public static string? PendingDeepLink { get; set; }
/// <summary>Called from MainActivity.OnNewIntent when app is already running.</summary>
public static async Task HandleDeepLink(string deepLink)
{
var (accessToken, refreshToken, type) = ParseDeepLinkFragment(deepLink);
if (type != "recovery" || accessToken is null) return;
try { await SupabaseService.Client.Auth.SetSession(accessToken, refreshToken); } catch { }
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
var vm = new ResetPasswordViewModel();
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow!.DataContext = vm;
else if (Current?.ApplicationLifetime is ISingleViewApplicationLifetime sv)
sv.MainView!.DataContext = vm;
});
}
private static (string? accessToken, string? refreshToken, string? type) ParseDeepLinkFragment(string url)
{
var hash = url.IndexOf('#');
if (hash < 0) return default;
string? at = null, rt = null, type = null;
foreach (var part in url[(hash + 1)..].Split('&'))
{
var eq = part.IndexOf('=');
if (eq < 0) continue;
var val = Uri.UnescapeDataString(part[(eq + 1)..]);
switch (part[..eq])
{
case "access_token": at = val; break;
case "refresh_token": rt = val; break;
case "type": type = val; break;
}
}
return (at, rt, type);
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -73,19 +115,32 @@ public partial class App : Application
ThemeService.SwitchToTheme(profile.Theme);
}
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
// Check for deep link from password reset email
ViewModelBase targetViewModel;
if (PendingDeepLink is { } deepLink && deepLink.Contains("type=recovery"))
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
var (accessToken, refreshToken, _) = ParseDeepLinkFragment(deepLink);
if (accessToken is not null)
{
try { await SupabaseService.Client.Auth.SetSession(accessToken, refreshToken); } catch { }
}
PendingDeepLink = null;
targetViewModel = new ResetPasswordViewModel();
}
else
{
targetViewModel = user is not null ? new MainViewModel() : new AuthViewModel();
}
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow!.DataContext = targetViewModel;
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
DebugLogger.Log("ANDROID PATH HIT");
singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
singleViewPlatform.MainView!.DataContext = targetViewModel;
}
}

View File

@@ -0,0 +1,16 @@
<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"
>
<circle cx="18.5" cy="17.5" r="3.5" />
<circle cx="5.5" cy="17.5" r="3.5" />
<circle cx="15" cy="5" r="1" />
<path d="M12 17.5V14l-3-3 4-3 2 3h2" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,14 @@
<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"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@@ -0,0 +1,19 @@
<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"
>
<path d="M8 6v6" />
<path d="M15 6v6" />
<path d="M2 12h19.6" />
<path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3" />
<circle cx="7" cy="18" r="2" />
<path d="M9 18h5" />
<circle cx="16" cy="18" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@@ -0,0 +1,14 @@
<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"
>
<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" />
<circle cx="12" cy="13" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -0,0 +1,16 @@
<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"
>
<path d="M10 2v2" />
<path d="M14 2v2" />
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
<path d="M6 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1,17 @@
<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"
>
<path d="M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z" />
<path d="m2.5 21.5 1.4-1.4" />
<path d="m20.1 3.9 1.4-1.4" />
<path d="M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z" />
<path d="m9.6 14.4 4.8-4.8" />
</svg>

After

Width:  |  Height:  |  Size: 620 B

View File

@@ -0,0 +1,20 @@
<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"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M7 3v18" />
<path d="M3 7.5h4" />
<path d="M3 12h18" />
<path d="M3 16.5h4" />
<path d="M17 3v18" />
<path d="M17 7.5h4" />
<path d="M17 16.5h4" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -0,0 +1,16 @@
<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"
>
<path d="M12 7v14" />
<path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8" />
<path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5" />
<rect x="3" y="7" width="18" height="4" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@@ -0,0 +1,15 @@
<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"
>
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
<path d="M22 10v6" />
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@@ -0,0 +1,13 @@
<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"
>
<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1,14 @@
<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"
>
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" />
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1,15 @@
<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"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,16 @@
<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"
>
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
<path d="M12 22V12" />
<polyline points="3.29 7 12 12 20.71 7" />
<path d="m7.5 4.27 9 5.15" />
</svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -0,0 +1,17 @@
<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"
>
<path d="m12 14-1 1" />
<path d="m13.75 18.25-1.25 1.42" />
<path d="M17.775 5.654a15.68 15.68 0 0 0-12.121 12.12" />
<path d="M18.8 9.3a1 1 0 0 0 2.1 7.7" />
<path d="M21.964 20.732a1 1 0 0 1-1.232 1.232l-18-5a1 1 0 0 1-.695-1.232A19.68 19.68 0 0 1 15.732 2.037a1 1 0 0 1 1.232.695z" />
</svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1,13 @@
<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"
>
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,17 @@
<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"
>
<circle cx="6" cy="6" r="3" />
<path d="M8.12 8.12 12 12" />
<path d="M20 4 8.12 15.88" />
<circle cx="6" cy="18" r="3" />
<path d="M14.8 14.8 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,13 @@
<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"
>
<path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1,14 @@
<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"
>
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,17 @@
<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"
>
<path d="M11 2v2" />
<path d="M5 2v2" />
<path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1" />
<path d="M8 15a6 6 0 0 0 12 0v-3" />
<circle cx="20" cy="10" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -0,0 +1,18 @@
<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"
>
<path d="M8 3.1V7a4 4 0 0 0 8 0V3.1" />
<path d="m9 15-1-1" />
<path d="m15 15 1-1" />
<path d="M9 19c-2.8 0-5-2.2-5-5v-4a8 8 0 0 1 16 0v4c0 2.8-2.2 5-5 5Z" />
<path d="m8 19-2 3" />
<path d="m16 19 2 3" />
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,14 @@
<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"
>
<path d="m17 2-5 5-5-5" />
<rect width="20" height="15" x="2" y="7" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,16 @@
<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"
>
<path d="M8 22h8" />
<path d="M7 10h10" />
<path d="M12 15v7" />
<path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,13 @@
<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"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>net8.0;net8.0-android</TargetFrameworks>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@@ -13,8 +13,8 @@
<ItemGroup>
<PackageReference Include="Avalonia"/>
<PackageReference Include="Avalonia.Controls.ColorPicker" />
<PackageReference Include="Avalonia.Svg.Skia" />
<PackageReference Include="Avalonia.Controls.ColorPicker"/>
<PackageReference Include="Avalonia.Svg.Skia"/>
<PackageReference Include="Avalonia.Themes.Fluent"/>
<PackageReference Include="Avalonia.Fonts.Inter"/>
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
@@ -23,31 +23,48 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm"/>
<PackageReference Include="Deadpikle.AvaloniaProgressRing" />
<PackageReference Include="FluentAvalonia.ProgressRing" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
<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" />
<PackageReference Include="Deadpikle.AvaloniaProgressRing"/>
<PackageReference Include="FluentAvalonia.ProgressRing"/>
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia"/>
<PackageReference Include="QuestPDF" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly"/>
<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>
<Compile Update="MobileViews\MainAppMobile.axaml.cs">
<DependentUpon>MobileMainView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\AccountFormView.axaml.cs">
<DependentUpon>AccountFormView.axaml</DependentUpon>
</Compile>
<Compile Update="MobileViews\MainAppMobile.axaml.cs">
<DependentUpon>MobileMainView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\AccountFormView.axaml.cs">
<DependentUpon>AccountFormView.axaml</DependentUpon>
</Compile>
<Compile Update="Views\CategoryFormView.axaml.cs">
<DependentUpon>CategoryFormView.axaml</DependentUpon>
</Compile>
<Compile Update="Views\AnalyticsView.axaml.cs">
<DependentUpon>AnalyticsView.axaml</DependentUpon>
</Compile>
<Compile Update="Views\DashboardSkeletonView.axaml.cs">
<DependentUpon>DashboardSkeletonView.axaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<None Update="devsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<None Update="devsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup Condition="$(TargetFramework.Contains('-android')) and '$(Configuration)' == 'Release'">
<AndroidKeyStore>true</AndroidKeyStore>
<AndroidSigningKeyStore>clario.keystore</AndroidSigningKeyStore>
<AndroidSigningKeyAlias>clario</AndroidSigningKeyAlias>
<AndroidSigningKeyPass>env:ANDROID_SIGNING_PASSWORD</AndroidSigningKeyPass>
<AndroidSigningStorePass>env:ANDROID_SIGNING_PASSWORD</AndroidSigningStorePass>
</PropertyGroup>
</Project>

View File

@@ -9,7 +9,7 @@
the internal template parts at ControlTheme priority, making
external /template/ style selectors unreliable for CalendarButton.
Replacing the entire ControlTheme is the only reliable approach.
-->
-->
<ControlTheme x:Key="{x:Type CalendarButton}" TargetType="CalendarButton">
<Setter Property="MinWidth" Value="40" />
<Setter Property="MinHeight" Value="40" />
@@ -69,7 +69,7 @@
</Styles.Resources>
<!-- ============================================================ -->
<!-- DateRangePicker control template -->
<!-- DateRangePicker control template -->
<!-- ============================================================ -->
<Style Selector="local|DateRangePicker">
@@ -86,6 +86,7 @@
<Grid>
<Button x:Name="PART_Button"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Background="{TemplateBinding Background}"
Foreground="{TemplateBinding Foreground}"
BorderBrush="{TemplateBinding BorderBrush}"
@@ -147,7 +148,7 @@
</Style>
<!-- ============================================================ -->
<!-- CalendarItem: nav header buttons (prev / title / next) -->
<!-- CalendarItem: nav header buttons (prev / title / next) -->
<!-- ============================================================ -->
<Style Selector="CalendarItem /template/ Button#PART_HeaderButton">

View File

@@ -25,7 +25,6 @@ public class DateRangePicker : TemplatedControl
set => SetValue(SelectionModeProperty, value);
}
// 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 =
@@ -41,7 +40,6 @@ public class DateRangePicker : TemplatedControl
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),
@@ -116,7 +114,6 @@ public class DateRangePicker : TemplatedControl
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)

View File

@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Clario.Models;
using Clario.Models.GeneralModels;
using Clario.Services;
@@ -15,6 +16,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Clario.Messages;
using CommunityToolkit.Mvvm.Messaging;
using Supabase.Postgrest;
using Supabase.Realtime.PostgresChanges;
using Constants = Supabase.Realtime.Constants;
using FileOptions = Supabase.Storage.FileOptions;
namespace Clario.Data;
@@ -129,20 +132,187 @@ public partial class GeneralDataRepo : ObservableObject
}
}
public async Task InsertTransfer(Guid fromAccountId, Guid toAccountId, decimal amount, DateTime date, string? note)
{
try
{
var userId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id!);
var pairId = Guid.NewGuid();
var fromCurrency = Accounts.FirstOrDefault(a => a.Id == fromAccountId)?.Currency ?? "";
var toCurrency = Accounts.FirstOrDefault(a => a.Id == toAccountId)?.Currency ?? "";
var toAmount = ConvertAmount(amount, fromCurrency, toCurrency);
var outTx = new Transaction
{
Id = Guid.NewGuid(),
UserId = userId,
AccountId = fromAccountId,
Type = "transfer_out",
Amount = amount,
Description = "Transfer",
Note = note?.Trim(),
Date = date,
TransferPairId = pairId,
};
var inTx = new Transaction
{
Id = Guid.NewGuid(),
UserId = userId,
AccountId = toAccountId,
Type = "transfer_in",
Amount = toAmount,
Description = "Transfer",
Note = note?.Trim(),
Date = date,
TransferPairId = pairId,
};
var outResult = await SupabaseService.Client.From<Transaction>().Insert(outTx);
var inResult = await SupabaseService.Client.From<Transaction>().Insert(inTx);
if (outResult.Models.Count >= 1)
{
var enriched = LinkTransactionCategories(outResult.Models[0]);
LinkTransactionAccounts(enriched);
Transactions.Add(enriched);
}
if (inResult.Models.Count >= 1)
{
var enriched = LinkTransactionCategories(inResult.Models[0]);
LinkTransactionAccounts(enriched);
Transactions.Add(enriched);
}
// Re-enrich both so AccountDisplayText can reference the counterpart (from/to)
LinkTransactionAccounts();
}
catch (Exception e)
{
DebugLogger.Log(e);
throw;
}
}
public async Task UpdateTransfer(Guid transferPairId, Guid fromAccountId, Guid toAccountId, decimal amount, DateTime date, string? note)
{
try
{
var pair = Transactions.Where(t => t.TransferPairId == transferPairId).ToList();
var fromCurrency = Accounts.FirstOrDefault(a => a.Id == fromAccountId)?.Currency ?? "";
var toCurrency = Accounts.FirstOrDefault(a => a.Id == toAccountId)?.Currency ?? "";
var toAmount = ConvertAmount(amount, fromCurrency, toCurrency);
foreach (var tx in pair)
{
tx.AccountId = tx.Type == "transfer_out" ? fromAccountId : toAccountId;
tx.Amount = tx.Type == "transfer_in" ? toAmount : amount;
tx.Date = date;
tx.Note = note?.Trim();
var result = await SupabaseService.Client.From<Transaction>().Update(tx);
if (result.Model is null) continue;
var index = Transactions.IndexOf(tx);
if (index != -1)
{
var enriched = LinkTransactionCategories(result.Model);
LinkTransactionAccounts(enriched);
Transactions[index] = enriched;
}
}
LinkTransactionAccounts();
}
catch (Exception e)
{
DebugLogger.Log(e);
throw;
}
}
public async Task DeleteTransfer(Guid transferPairId)
{
try
{
await SupabaseService.Client.From<Transaction>()
.Where(x => x.TransferPairId == transferPairId)
.Delete();
var pair = Transactions.Where(t => t.TransferPairId == transferPairId).ToList();
foreach (var tx in pair)
Transactions.Remove(tx);
}
catch (Exception e)
{
DebugLogger.Log(e);
throw;
}
}
public async Task<List<Category>> FetchCategories(bool forceRefresh = false)
{
if (Categories.Count != 0 && !forceRefresh) return Categories.ToList();
var categories = await SupabaseService.Client.From<Category>().Get();
Categories = new ObservableCollection<Category>(categories.Models);
return categories.Models;
}
public async Task<Category?> InsertCategory(Category category)
{
try
{
var result = await SupabaseService.Client.From<Category>()
.Insert(category, new QueryOptions() { Returning = QueryOptions.ReturnType.Representation });
if (result.Model is null) return null;
Categories.Add(result.Model);
return result.Model;
}
catch (Exception e)
{
DebugLogger.Log(e);
return null;
}
}
public async Task UpdateCategory(Category category)
{
try
{
var result = await SupabaseService.Client.From<Category>().Update(category);
if (result.Model is null) return;
var item = Categories.FirstOrDefault(x => x.Id == result.Model.Id);
if (item is null) return;
var index = Categories.IndexOf(item);
if (index != -1) Categories[index] = result.Model;
}
catch (Exception e)
{
DebugLogger.Log(e);
}
}
public async Task DeleteCategory(Guid id)
{
try
{
await SupabaseService.Client.From<Category>().Where(x => x.Id == id).Delete();
var item = Categories.FirstOrDefault(x => x.Id == id);
if (item is null) return;
Categories.Remove(item);
}
catch (Exception e)
{
DebugLogger.Log(e);
throw;
}
}
public async Task<List<Account>> FetchAccounts(bool forceRefresh = false)
{
if (Accounts.Count != 0 && !forceRefresh) return Accounts.ToList();
var accounts = await SupabaseService.Client.From<Account>().Get();
Accounts = new ObservableCollection<Account>(accounts.Models);
return accounts.Models.OrderBy(x=>x.IsPrimary).ThenBy(x=>x.CreatedAt).ToList();
return accounts.Models.OrderBy(x => x.IsPrimary).ThenBy(x => x.CreatedAt).ToList();
}
public async Task<List<Budget>> FetchBudgets(bool forceRefresh = false)
@@ -294,7 +464,7 @@ public partial class GeneralDataRepo : ObservableObject
var balance = accountResult.OpeningBalance +
transactionsResult.Sum(t =>
t.Type == "income" ? t.Amount : -t.Amount);
t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount);
accountResult.CurrentBalance = balance;
await SupabaseService.Client
@@ -399,6 +569,20 @@ public partial class GeneralDataRepo : ObservableObject
WeakReferenceMessenger.Default.Send(new RatesRefreshed());
}
/// Converts <paramref name="amount"/> from <paramref name="fromCurrency"/> to
/// <paramref name="toCurrency"/> using the current live rates.
/// Falls back to <paramref name="amount"/> unchanged when currencies match or rates are missing.
private static decimal ConvertAmount(decimal amount, string fromCurrency, string toCurrency)
{
if (string.IsNullOrEmpty(fromCurrency) || string.IsNullOrEmpty(toCurrency)) return amount;
if (fromCurrency.Equals(toCurrency, StringComparison.OrdinalIgnoreCase)) return amount;
if (!CurrencyService.LiveRates.TryGetValue(fromCurrency, out var fromRate)) return amount;
if (!CurrencyService.LiveRates.TryGetValue(toCurrency, out var toRate) || toRate == 0) return amount;
// fromRate = 1 fromCurrency in primary; toRate = 1 toCurrency in primary
// amount * fromRate / toRate = amount converted to toCurrency
return Math.Round(amount * fromRate / toRate, 6);
}
public void LinkTransactionCategories()
{
foreach (var transaction in Transactions)
@@ -441,6 +625,19 @@ public partial class GeneralDataRepo : ObservableObject
tx.OriginalAmountFormatted = tx.IsMultiCurrency
? $"{CurrencyService.GetSymbol(accountCurrency)}{tx.Amount:N2}"
: string.Empty;
if (tx.IsTransfer && tx.TransferPairId.HasValue)
{
var counterpart = Transactions.FirstOrDefault(t => t.TransferPairId == tx.TransferPairId && t.Id != tx.Id);
var counterpartAccount = counterpart is not null ? Accounts.FirstOrDefault(a => a.Id == counterpart.AccountId) : null;
var fromName = tx.IsTransferOut ? (account?.Name ?? "?") : (counterpartAccount?.Name ?? "?");
var toName = tx.IsTransferOut ? (counterpartAccount?.Name ?? "?") : (account?.Name ?? "?");
tx.AccountDisplayText = $"{fromName} → {toName}";
}
else
{
tx.AccountDisplayText = account?.Name ?? "";
}
}
public async Task UpdateSavingsGoal(decimal? goal)
@@ -528,4 +725,186 @@ public partial class GeneralDataRepo : ObservableObject
if (avatarUrl.StartsWith("http")) return avatarUrl;
return $"{PublicBaseUrl}/{avatarUrl}";
}
public void StartRealtimeSync()
{
if (SupabaseService.Client.Auth.CurrentUser?.Id is null) return;
DebugLogger.Log("[Realtime] StartRealtimeSync: registering listeners");
// Transactions
_ = SupabaseService.Client.From<Transaction>().On(PostgresChangesOptions.ListenType.Inserts, (_, c) =>
{
var insertedTransaction = c.Model<Transaction>();
if (insertedTransaction is null) { DebugLogger.Log("[Realtime] Transaction INSERT: model was null"); return; }
DebugLogger.Log($"[Realtime] Transaction INSERT: {insertedTransaction.Id} ({insertedTransaction.Description})");
Dispatcher.UIThread.Post(() =>
{
if (Transactions.Any(x => x.Id == insertedTransaction.Id)) { DebugLogger.Log($"[Realtime] Transaction INSERT: skipped duplicate {insertedTransaction.Id}"); return; }
LinkTransactionCategories(insertedTransaction);
LinkTransactionAccounts(insertedTransaction);
Transactions.Add(insertedTransaction);
DebugLogger.Log($"[Realtime] Transaction INSERT: added to collection");
});
});
_ = SupabaseService.Client.From<Transaction>().On(PostgresChangesOptions.ListenType.Updates, (_, c) =>
{
var updatedTransaction = c.Model<Transaction>();
if (updatedTransaction is null) { DebugLogger.Log("[Realtime] Transaction UPDATE: model was null"); return; }
DebugLogger.Log($"[Realtime] Transaction UPDATE: {updatedTransaction.Id} ({updatedTransaction.Description})");
Dispatcher.UIThread.Post(() =>
{
var idx = Transactions.ToList().FindIndex(x => x.Id == updatedTransaction.Id);
if (idx == -1) { DebugLogger.Log($"[Realtime] Transaction UPDATE: id {updatedTransaction.Id} not found in collection"); return; }
LinkTransactionCategories(updatedTransaction);
LinkTransactionAccounts(updatedTransaction);
Transactions[idx] = updatedTransaction;
DebugLogger.Log($"[Realtime] Transaction UPDATE: replaced at index {idx}");
});
});
_ = SupabaseService.Client.From<Transaction>().On(PostgresChangesOptions.ListenType.Deletes, (_, c) =>
{
var deletedTransaction = c.OldModel<Transaction>();
if (deletedTransaction is null) { DebugLogger.Log("[Realtime] Transaction DELETE: old model was null"); return; }
DebugLogger.Log($"[Realtime] Transaction DELETE: {deletedTransaction.Id}");
Dispatcher.UIThread.Post(() =>
{
var item = Transactions.FirstOrDefault(x => x.Id == deletedTransaction.Id);
if (item is not null) { Transactions.Remove(item); DebugLogger.Log($"[Realtime] Transaction DELETE: removed {deletedTransaction.Id}"); }
else DebugLogger.Log($"[Realtime] Transaction DELETE: id {deletedTransaction.Id} not found (already removed locally)");
});
});
// Accounts
_ = SupabaseService.Client.From<Account>().On(PostgresChangesOptions.ListenType.Inserts, (_, c) =>
{
var insertedAccount = c.Model<Account>();
if (insertedAccount is null) { DebugLogger.Log("[Realtime] Account INSERT: model was null"); return; }
DebugLogger.Log($"[Realtime] Account INSERT: {insertedAccount.Id} ({insertedAccount.Name})");
Dispatcher.UIThread.Post(() =>
{
if (Accounts.Any(x => x.Id == insertedAccount.Id)) { DebugLogger.Log($"[Realtime] Account INSERT: skipped duplicate {insertedAccount.Id}"); return; }
Accounts.Add(insertedAccount);
DebugLogger.Log($"[Realtime] Account INSERT: added to collection");
});
});
_ = SupabaseService.Client.From<Account>().On(PostgresChangesOptions.ListenType.Updates, (_, c) =>
{
var updatedAccount = c.Model<Account>();
if (updatedAccount is null) { DebugLogger.Log("[Realtime] Account UPDATE: model was null"); return; }
DebugLogger.Log($"[Realtime] Account UPDATE: {updatedAccount.Id} ({updatedAccount.Name})");
Dispatcher.UIThread.Post(() =>
{
var idx = Accounts.ToList().FindIndex(x => x.Id == updatedAccount.Id);
if (idx != -1) { Accounts[idx] = updatedAccount; DebugLogger.Log($"[Realtime] Account UPDATE: replaced at index {idx}"); }
else DebugLogger.Log($"[Realtime] Account UPDATE: id {updatedAccount.Id} not found in collection");
});
});
_ = SupabaseService.Client.From<Account>().On(PostgresChangesOptions.ListenType.Deletes, (_, c) =>
{
var deletedAccount = c.OldModel<Account>();
if (deletedAccount is null) { DebugLogger.Log("[Realtime] Account DELETE: old model was null"); return; }
DebugLogger.Log($"[Realtime] Account DELETE: {deletedAccount.Id}");
Dispatcher.UIThread.Post(() =>
{
var item = Accounts.FirstOrDefault(x => x.Id == deletedAccount.Id);
if (item is not null) { Accounts.Remove(item); DebugLogger.Log($"[Realtime] Account DELETE: removed {deletedAccount.Id}"); }
else DebugLogger.Log($"[Realtime] Account DELETE: id {deletedAccount.Id} not found (already removed locally)");
});
});
// Budgets
_ = SupabaseService.Client.From<Budget>().On(PostgresChangesOptions.ListenType.Inserts, (_, c) =>
{
var insertedBudget = c.Model<Budget>();
if (insertedBudget is null) { DebugLogger.Log("[Realtime] Budget INSERT: model was null"); return; }
DebugLogger.Log($"[Realtime] Budget INSERT: {insertedBudget.Id}");
Dispatcher.UIThread.Post(() =>
{
if (Budgets.Any(x => x.Id == insertedBudget.Id)) { DebugLogger.Log($"[Realtime] Budget INSERT: skipped duplicate {insertedBudget.Id}"); return; }
Budgets.Add(insertedBudget);
DebugLogger.Log($"[Realtime] Budget INSERT: added to collection");
});
});
_ = SupabaseService.Client.From<Budget>().On(PostgresChangesOptions.ListenType.Updates, (_, c) =>
{
var updatedBudget = c.Model<Budget>();
if (updatedBudget is null) { DebugLogger.Log("[Realtime] Budget UPDATE: model was null"); return; }
DebugLogger.Log($"[Realtime] Budget UPDATE: {updatedBudget.Id}");
Dispatcher.UIThread.Post(() =>
{
var idx = Budgets.ToList().FindIndex(x => x.Id == updatedBudget.Id);
if (idx != -1) { Budgets[idx] = updatedBudget; DebugLogger.Log($"[Realtime] Budget UPDATE: replaced at index {idx}"); }
else DebugLogger.Log($"[Realtime] Budget UPDATE: id {updatedBudget.Id} not found in collection");
});
});
_ = SupabaseService.Client.From<Budget>().On(PostgresChangesOptions.ListenType.Deletes, (_, c) =>
{
var deletedBudget = c.OldModel<Budget>();
if (deletedBudget is null) { DebugLogger.Log("[Realtime] Budget DELETE: old model was null"); return; }
DebugLogger.Log($"[Realtime] Budget DELETE: {deletedBudget.Id}");
Dispatcher.UIThread.Post(() =>
{
var item = Budgets.FirstOrDefault(x => x.Id == deletedBudget.Id);
if (item is not null) { Budgets.Remove(item); DebugLogger.Log($"[Realtime] Budget DELETE: removed {deletedBudget.Id}"); }
else DebugLogger.Log($"[Realtime] Budget DELETE: id {deletedBudget.Id} not found (already removed locally)");
});
});
// Categories
_ = SupabaseService.Client.From<Category>().On(PostgresChangesOptions.ListenType.Inserts, (_, c) =>
{
var insertedCategory = c.Model<Category>();
if (insertedCategory is null) { DebugLogger.Log("[Realtime] Category INSERT: model was null"); return; }
DebugLogger.Log($"[Realtime] Category INSERT: {insertedCategory.Id} ({insertedCategory.Name})");
Dispatcher.UIThread.Post(() =>
{
if (Categories.Any(x => x.Id == insertedCategory.Id)) { DebugLogger.Log($"[Realtime] Category INSERT: skipped duplicate {insertedCategory.Id}"); return; }
Categories.Add(insertedCategory);
DebugLogger.Log($"[Realtime] Category INSERT: added to collection");
});
});
_ = SupabaseService.Client.From<Category>().On(PostgresChangesOptions.ListenType.Updates, (_, c) =>
{
var UpdatedCategory = c.Model<Category>();
if (UpdatedCategory is null) { DebugLogger.Log("[Realtime] Category UPDATE: model was null"); return; }
DebugLogger.Log($"[Realtime] Category UPDATE: {UpdatedCategory.Id} ({UpdatedCategory.Name})");
Dispatcher.UIThread.Post(() =>
{
var idx = Categories.ToList().FindIndex(x => x.Id == UpdatedCategory.Id);
if (idx != -1) { Categories[idx] = UpdatedCategory; DebugLogger.Log($"[Realtime] Category UPDATE: replaced at index {idx}"); }
else DebugLogger.Log($"[Realtime] Category UPDATE: id {UpdatedCategory.Id} not found in collection");
});
});
_ = SupabaseService.Client.From<Category>().On(PostgresChangesOptions.ListenType.Deletes, (_, c) =>
{
var deletedCategory = c.OldModel<Category>();
if (deletedCategory is null) { DebugLogger.Log("[Realtime] Category DELETE: old model was null"); return; }
DebugLogger.Log($"[Realtime] Category DELETE: {deletedCategory.Id}");
Dispatcher.UIThread.Post(() =>
{
var item = Categories.FirstOrDefault(x => x.Id == deletedCategory.Id);
if (item is not null) { Categories.Remove(item); DebugLogger.Log($"[Realtime] Category DELETE: removed {deletedCategory.Id}"); }
else DebugLogger.Log($"[Realtime] Category DELETE: id {deletedCategory.Id} not found (already removed locally)");
});
});
// Profile
_ = SupabaseService.Client.From<Profile>().On(PostgresChangesOptions.ListenType.Updates, (_, c) =>
{
var updatedProfile = c.Model<Profile>();
if (updatedProfile is null) { DebugLogger.Log("[Realtime] Profile UPDATE: model was null"); return; }
DebugLogger.Log($"[Realtime] Profile UPDATE: {updatedProfile.Id} ({updatedProfile.DisplayName})");
Dispatcher.UIThread.Post(() => Profile = updatedProfile);
});
DebugLogger.Log("[Realtime] all listeners registered");
}
}

14
Clario/Enums/AuthError.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace Clario.Enums;
public enum AuthError
{
InvalidCredentials,
EmailAlreadyExists,
EmailNotConfirmed,
WeakPassword,
InvalidEmail,
SignupDisabled,
RateLimited,
SessionExpired,
Unknown
}

View File

@@ -0,0 +1,408 @@
<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" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.AccountFormViewMobile"
x:DataType="vm:AccountFormViewModel"
x:Name="AccountFormRoot"
Classes="mobile">
<Design.DataContext>
<vm:AccountFormViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto"
Background="{DynamicResource BgBase}">
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
Margin="16,16,16,0">
<!-- Close -->
<Button Grid.Column="0"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="20"
Width="36" Height="36"
Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
<!-- Title -->
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock Text="{Binding FormTitle}"
FontSize="15"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center" />
<TextBlock Text="{Binding FormSubtitle}"
FontSize="11"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center" />
</StackPanel>
<!-- Placeholder to keep title centered -->
<Border Grid.Column="2" Width="36" />
</Grid>
<!-- Scrollable form -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Margin="0,16,0,0">
<StackPanel Margin="16,0,16,16" Spacing="0">
<!-- Icon preview + Name -->
<Grid ColumnDefinitions="Auto,*" Margin="0,0,0,20">
<Border Grid.Column="0"
CornerRadius="14"
Width="52" Height="52"
Margin="0,0,14,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="22" Height="22"
Css="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<TextBox Grid.Column="1"
Text="{Binding Name, Mode=TwoWay}"
Watermark="Account name"
FontSize="20"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Background="Transparent"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource BorderSubtle}"
Padding="0,0,0,8"
VerticalContentAlignment="Bottom"
CornerRadius="0" />
</Grid>
<!-- 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,20">
<ComboBox ItemsSource="{Binding AccountTypes}"
SelectedItem="{Binding SelectedType, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Padding="14,12"
FontSize="15"
HorizontalAlignment="Stretch" />
</Border>
<!-- Institution -->
<TextBlock Text="INSTITUTION (OPTIONAL)" Classes="label" Margin="0,0,0,6" />
<TextBox Text="{Binding Institution, Mode=TwoWay}"
Watermark="e.g. Chase"
FontSize="15"
Height="48"
Padding="14,0"
VerticalContentAlignment="Center"
Margin="0,0,0,20" />
<!-- Last 4 digits -->
<TextBlock Text="LAST 4 DIGITS (OPTIONAL)" Classes="label" Margin="0,0,0,6" />
<TextBox Text="{Binding Mask, Mode=TwoWay}"
Watermark="e.g. 1234"
FontSize="15"
Height="48"
Padding="14,0"
MaxLength="4"
VerticalContentAlignment="Center"
Margin="0,0,0,20">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
<!-- Opening Balance -->
<TextBlock Text="OPENING BALANCE" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,0"
Margin="0,0,0,20"
Height="52">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="20"
FontWeight="Bold"
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="20"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Height="48"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
<!-- Currency -->
<TextBlock Text="CURRENCY" Classes="label" Margin="0,0,0,6" />
<TextBox Text="{Binding CurrencySearch, Mode=TwoWay}"
Watermark="Search currency..."
FontSize="14"
Height="42"
Padding="14,0"
VerticalContentAlignment="Center"
Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="8"
Margin="0,0,0,20">
<ScrollViewer MaxHeight="100"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding FilteredCurrencies}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Classes="nav"
Content="{Binding}"
Margin="2"
Padding="8,6"
CornerRadius="6"
FontSize="13"
Command="{Binding DataContext.SetCurrencyCommand, ElementName=AccountFormRoot}"
CommandParameter="{Binding}">
<Classes.accented>
<MultiBinding Converter="{StaticResource EqualValueConverter}">
<Binding Path="." />
<Binding Path="DataContext.Currency" ElementName="AccountFormRoot" />
</MultiBinding>
</Classes.accented>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Credit Limit -->
<StackPanel Spacing="6" Margin="0,0,0,20" IsVisible="{Binding IsCredit}">
<TextBlock Text="CREDIT LIMIT (OPTIONAL)" Classes="label" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,0"
Height="52">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="20"
FontWeight="Bold"
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="20"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Height="48"
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,20">
<cc:DateRangePicker Classes="ghost"
SelectionMode="SingleDate"
SelectedDates="{Binding OpenedAtDates}"
HorizontalAlignment="Stretch"
Padding="14,12" />
</Border>
<!-- Icon -->
<TextBlock Text="ICON" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
CornerRadius="7"
Width="32" Height="32"
Margin="10,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="10,12"
FontSize="15"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
<!-- Color -->
<TextBlock Text="COLOR" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Height="48"
Margin="0,0,0,20">
<ColorPicker
Color="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
HorizontalAlignment="Stretch"
Height="48"
CornerRadius="{DynamicResource RadiusControl}"
IsAlphaEnabled="False"
IsAlphaVisible="False"
IsColorPaletteVisible="False"
IsAccentColorsVisible="False" />
</Border>
<!-- Primary account toggle -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,12"
Margin="0,0,0,20">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="Primary Account"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="Sets this account's currency as the reference currency"
FontSize="11"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding IsPrimary, Mode=TwoWay}"
OffContent=""
OnContent=""
VerticalAlignment="Center" />
</Grid>
</Border>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,8"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="13" Height="13"
Css="{DynamicResource SvgRed}" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Bottom action bar -->
<Border Grid.Row="2"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0"
Padding="16,12,16,20">
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0"
Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Grid.Column="1"
Classes="accented"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding IsValid}"
Command="{Binding SaveCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="{Binding SaveButtonLabel}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
</Grid>
</UserControl>

View File

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

View File

@@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Clario.ViewModels"
xmlns:model="clr-namespace:Clario.Models"
xmlns:mobileViews="clr-namespace:Clario.MobileViews"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.MobileViews.AccountsViewMobile"
x:DataType="vm:AccountsViewModel"
@@ -16,7 +17,7 @@
<!-- Root grid — content + overlay stacked -->
<Grid>
<!-- ── Main content ───────────────────────── -->
<!-- Main content -->
<Grid RowDefinitions="Auto,*"
Background="{DynamicResource BgBase}">
@@ -30,14 +31,24 @@
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
</StackPanel>
<Button Grid.Column="1"
Classes="accented"
Padding="12,8"
VerticalAlignment="Center">
<Svg Path="../Assets/Icons/plus.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="base"
Padding="12,8"
IsVisible="{Binding HasArchivedAccounts}"
Command="{Binding ShowArchivedListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/archive.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Archived" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Classes="accented"
Padding="12,8"
Command="{Binding CreateAccountCommand}">
<Svg Path="../Assets/Icons/plus.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
</Button>
</StackPanel>
</Grid>
<!-- Account list -->
@@ -70,11 +81,17 @@
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2"
Text="{Binding TotalBalance, StringFormat='$0.00'}"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource AccentBlue}"
VerticalAlignment="Center" />
VerticalAlignment="Center">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N2}">
<Binding Path="PrimarySymbol" />
<Binding Path="TotalBalance" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
</Border>
@@ -119,10 +136,23 @@
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="3">
<TextBlock Text="{Binding Name}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Name}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Border IsVisible="{Binding IsPrimary}"
CornerRadius="4"
Padding="5,2"
Background="{DynamicResource IconBgGreen}"
VerticalAlignment="Center">
<TextBlock Text="PRIMARY"
FontSize="9"
FontWeight="Bold"
Foreground="{DynamicResource AccentGreen}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Institution}"
FontSize="12"
@@ -137,7 +167,7 @@
HorizontalAlignment="Right"
VerticalAlignment="Center"
Spacing="3">
<TextBlock Text="{Binding CurrentBalance, StringFormat='$0.00'}"
<TextBlock Text="{Binding CurrentBalanceFormatted}"
FontSize="15"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
@@ -159,7 +189,7 @@
</MultiBinding>
</Svg.Css>
</Svg>
<TextBlock Text="{Binding MonthlyIncrease, Converter={StaticResource DecimalSignConverter}}"
<TextBlock Text="{Binding MonthlyIncreaseFormatted}"
FontSize="11">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource DecimalColorConverter}">
@@ -182,7 +212,7 @@
</ScrollViewer>
</Grid>
<!-- ── Bottom sheet overlay ───────────────── -->
<!-- Bottom sheet overlay -->
<Grid IsVisible="False"
x:Name="OverlayGrid">
@@ -239,20 +269,32 @@
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<!-- Close button -->
<Button Grid.Column="2"
Background="{DynamicResource BgBase}"
BorderThickness="0"
CornerRadius="20"
Width="34" Height="34"
Padding="0"
HorizontalContentAlignment="Center"
VerticalAlignment="Top"
x:Name="CloseButton">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
<!-- Edit + Close buttons -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
<Button Background="{DynamicResource BgBase}"
BorderThickness="0"
CornerRadius="20"
Width="34" Height="34"
Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding EditAccountCommand}"
CommandParameter="{Binding SelectedAccount}">
<Svg Path="../Assets/Icons/pencil.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
<Button Background="{DynamicResource BgBase}"
BorderThickness="0"
CornerRadius="20"
Width="34" Height="34"
Padding="0"
HorizontalContentAlignment="Center"
x:Name="CloseButton">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
</StackPanel>
</Grid>
</Grid>
@@ -269,7 +311,7 @@
Padding="16,14">
<StackPanel Spacing="4">
<TextBlock Text="CURRENT BALANCE" Classes="label" />
<TextBlock Text="{Binding SelectedAccount.CurrentBalance, StringFormat='$0.00'}"
<TextBlock Text="{Binding SelectedAccount.CurrentBalanceFormatted}"
FontSize="28"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
@@ -329,7 +371,7 @@
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="1">
<TextBlock Text="Money In" FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding SelectedAccount.TotalIncomeThisMonth, StringFormat='$0.00'}" FontSize="14"
<TextBlock Text="{Binding SelectedAccount.TotalIncomeFormatted}" FontSize="14"
FontWeight="SemiBold" Foreground="{DynamicResource AccentGreen}" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding SelectedAccount.IncomeTransactionsThisMonth}" FontSize="11"
@@ -342,7 +384,7 @@
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="1">
<TextBlock Text="Money Out" FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding SelectedAccount.TotalExpenseThisMonth, StringFormat='$0.00'}" FontSize="14"
<TextBlock Text="{Binding SelectedAccount.TotalExpenseFormatted}" FontSize="14"
FontWeight="SemiBold" Foreground="{DynamicResource AccentRed}" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding SelectedAccount.ExpenseTransactionsThisMonth}" FontSize="11"
@@ -446,7 +488,9 @@
<StackPanel Spacing="10">
<TextBlock Text="Manage" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimary}" Margin="0,0,0,2" />
<Button Background="Transparent" BorderBrush="{DynamicResource BorderSubtle}" BorderThickness="1" CornerRadius="10"
Padding="14,10" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left">
Padding="14,10" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding RequestArchiveAccountCommand}"
CommandParameter="{Binding SelectedAccount}">
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/archive.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" />
<StackPanel Spacing="1">
@@ -456,13 +500,18 @@
</StackPanel>
</StackPanel>
</Button>
<Button Background="#2A0D0D" BorderBrush="#3A1515" BorderThickness="1" CornerRadius="10" Padding="14,10"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left">
<Button Classes="danger"
CornerRadius="10"
Padding="14,10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
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; }" />
<Svg Path="../Assets/Icons/trash-2.svg" Width="14" Height="14" Css="{DynamicResource SvgRed}" />
<StackPanel Spacing="1">
<TextBlock Text="Delete Account" FontSize="12" FontWeight="SemiBold" Foreground="#FF5E5E" />
<TextBlock Text="Delete Account" FontSize="12" FontWeight="SemiBold" />
<TextBlock Text="Permanently removes all data" FontSize="11" Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</StackPanel>
@@ -476,5 +525,12 @@
</Border>
</Grid>
<!-- Dialog overlays -->
<mobileViews:DeleteAccountDialogViewMobile IsVisible="{Binding DataContext.IsDeleteDialogVisible, ElementName=AccountsPage}"
DataContext="{Binding DeleteDialog}" />
<mobileViews:ArchiveAccountDialogViewMobile IsVisible="{Binding IsArchiveDialogVisible}" />
<mobileViews:ArchivedAccountsDialogViewMobile IsVisible="{Binding IsArchivedListVisible}" />
</Grid>
</UserControl>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
@@ -7,12 +7,13 @@ using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Clario.Models;
using Clario.ViewModels;
namespace Clario.MobileViews;
public partial class AccountsViewMobile : UserControl
{
private bool _sheetVisible = false;
private bool _sheetVisible;
private TranslateTransform SheetTranslate =>
(TranslateTransform)BottomSheet.RenderTransform!;
@@ -24,10 +25,32 @@ public partial class AccountsViewMobile : UserControl
DimOverlay.PointerPressed += async (_, _) => await HideSheet();
CloseButton.Click += async (_, _) => await HideSheet();
AddHandler(Button.ClickEvent, async (sender, e) =>
AddHandler(Button.ClickEvent, async (_, e) =>
{
if (e.Source is Button { DataContext: Account }) await ShowSheet();
}, handledEventsToo: false);
DataContextChanged += (_, _) =>
{
if (DataContext is AccountsViewModel vm)
{
vm.TryCloseSheet = () =>
{
if (!_sheetVisible) return false;
_ = HideSheet();
return true;
};
vm.PropertyChanged += async (_, args) =>
{
if (args.PropertyName == nameof(AccountsViewModel.ShouldCloseSheet) && vm.ShouldCloseSheet)
{
await HideSheet();
vm.ShouldCloseSheet = false;
}
};
}
};
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@@ -84,6 +107,7 @@ public partial class AccountsViewMobile : UserControl
public async Task HideSheet()
{
if (!_sheetVisible) return;
_sheetVisible = false;
var sheetAnim = new Animation
{
@@ -110,9 +134,8 @@ public partial class AccountsViewMobile : UserControl
await Task.WhenAll(sheetAnim.RunAsync(BottomSheet), dimAnim.RunAsync(DimOverlay));
_sheetVisible = false;
OverlayGrid.IsVisible = false;
SheetTranslate.Y = 0;
DimOverlay.Opacity = 1;
}
}
}

View File

@@ -0,0 +1,161 @@
<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:model="clr-namespace:Clario.Models"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
mc:Ignorable="d"
Classes="mobile"
x:DataType="vm:AnalyticsViewModel"
x:Class="Clario.MobileViews.AnalyticsViewMobile">
<Design.DataContext>
<vm:AnalyticsViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<!-- Top bar -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,12,16,12">
<StackPanel Grid.Column="0">
<TextBlock Classes="muted" Text="Insights &amp; Trends" FontSize="12" />
<TextBlock Text="Analytics" FontSize="22" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
</StackPanel>
<ComboBox Grid.Column="1" ItemsSource="{Binding PeriodOptions}"
SelectedItem="{Binding SelectedPeriod}"
Background="{DynamicResource BgSurface}"
Foreground="{DynamicResource TextSecondary}"
BorderBrush="{DynamicResource BorderSubtle}"
CornerRadius="{DynamicResource RadiusControl}"
Padding="8,5" FontSize="12"
VerticalAlignment="Center" />
</Grid>
<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="14" Margin="16,0,16,24">
<!-- KPI Cards (2x2 grid) -->
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<!-- Total Income -->
<Border Grid.Row="0" Grid.Column="0" Classes="card" Margin="0,0,7,7">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="INCOME" />
<TextBlock Text="{Binding TotalIncomeFormatted}" FontSize="16" FontWeight="Bold"
Foreground="{DynamicResource AccentGreen}" />
</StackPanel>
</Border>
<!-- Total Expenses -->
<Border Grid.Row="0" Grid.Column="1" Classes="card" Margin="7,0,0,7">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="EXPENSES" />
<TextBlock Text="{Binding TotalExpensesFormatted}" FontSize="16" FontWeight="Bold"
Foreground="{DynamicResource AccentRed}" />
</StackPanel>
</Border>
<!-- Net Savings -->
<Border Grid.Row="1" Grid.Column="0" Classes="card" Margin="0,0,7,0">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NET SAVINGS" />
<TextBlock Text="{Binding NetSavingsFormatted}" FontSize="16" FontWeight="Bold"
Foreground="{Binding NetSavingsPositive, Converter={StaticResource BoolToColorConverter}, ConverterParameter='#2ECC8A|#FF5E5E'}" />
</StackPanel>
</Border>
<!-- Savings Rate -->
<Border Grid.Row="1" Grid.Column="1" Classes="card" Margin="7,0,0,0">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="SAVINGS RATE" />
<TextBlock Text="{Binding SavingsRateFormatted}" FontSize="16" FontWeight="Bold"
Foreground="{DynamicResource AccentPurple}" />
</StackPanel>
</Border>
</Grid>
<!-- Cash Flow Chart -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Cash Flow Trend" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<lvc:CartesianChart Series="{Binding CashFlowSeries}"
XAxes="{Binding CashFlowXAxes}"
YAxes="{Binding CashFlowYAxes}"
Height="200"
Background="{DynamicResource BgSurface}"
LegendPosition="Bottom"
TooltipPosition="Top"
ZoomMode="None"
AnimationsSpeed="00:00:00.2" />
</StackPanel>
</Border>
<!-- Net Worth -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Net Worth" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<lvc:CartesianChart Series="{Binding NetWorthSeries}"
XAxes="{Binding NetWorthXAxes}"
YAxes="{Binding NetWorthYAxes}"
Height="180"
Background="{DynamicResource BgSurface}"
LegendPosition="Hidden"
TooltipPosition="Top"
ZoomMode="None"
AnimationsSpeed="00:00:00.2" />
</StackPanel>
</Border>
<!-- Top Categories -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Top Spending Categories" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="No expense data for this period."
Classes="muted" IsVisible="{Binding !HasTopCategories}" FontSize="12" />
<ItemsControl ItemsSource="{Binding TopCategories}"
IsVisible="{Binding HasTopCategories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:CategorySpendRow">
<Border Background="{DynamicResource BgHover}" CornerRadius="10"
Padding="12,10" Margin="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" CornerRadius="7" Width="30" Height="30" Margin="0,0,10,0">
<Border.Background>
<SolidColorBrush Color="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Icon, Converter={StaticResource SvgPathFromName}}"
Width="14" Height="14"
Css="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<ProgressBar Value="{Binding Percentage}" Minimum="0" Maximum="100"
Height="3" Classes="blue" CornerRadius="2" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding AmountFormatted}"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" Margin="8,0,0,0" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,123 @@
<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.MobileViews.ArchiveAccountDialogViewMobile"
x:DataType="vm:AccountsViewModel"
x:CompileBindings="False"
Classes="mobile">
<Design.DataContext>
<vm:AccountsViewModel />
</Design.DataContext>
<Grid>
<Border Background="#70000000" />
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentOrange}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,40">
<StackPanel Spacing="0">
<!-- Handle bar -->
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,20" />
<!-- Icon -->
<Border Background="{DynamicResource IconBgOrange}"
CornerRadius="14"
Width="54" Height="54"
HorizontalAlignment="Center"
Margin="0,0,0,16">
<Svg Path="../Assets/Icons/archive.svg"
Width="22" Height="22"
Css="{DynamicResource SvgOrange}" />
</Border>
<!-- Title -->
<TextBlock Text="Archive Account"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,12" />
<!-- Account badge -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
HorizontalAlignment="Center"
Margin="0,0,0,14">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border CornerRadius="7" Width="26" Height="26" VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush
Color="{Binding AccountToArchive.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding AccountToArchive.Icon, Converter={StaticResource SvgPathFromName}}"
Width="13" Height="13"
Css="{Binding AccountToArchive.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<TextBlock Text="{Binding AccountToArchive.Name}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Description -->
<TextBlock Text="This account will be hidden from your active list and won't appear when adding transactions. You can restore it anytime."
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,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelArchiveCommand}" />
<Button Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="{DynamicResource AccentOrange}"
BorderThickness="0"
CornerRadius="{DynamicResource RadiusControl}"
Command="{Binding ConfirmArchiveCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/archive.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Archive"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,138 @@
<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:model="clr-namespace:Clario.Models"
mc:Ignorable="d"
x:Class="Clario.MobileViews.ArchivedAccountsDialogViewMobile"
x:Name="ArchivedListView"
x:DataType="vm:AccountsViewModel"
x:CompileBindings="False"
Classes="mobile">
<Design.DataContext>
<vm:AccountsViewModel />
</Design.DataContext>
<Grid>
<Border Background="#70000000" />
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,32">
<StackPanel Spacing="0">
<!-- Handle bar -->
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,18" />
<!-- Header -->
<Grid ColumnDefinitions="*,Auto" Margin="0,0,0,16">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Archived Accounts"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Text="Hidden from active lists and transaction forms"
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<Button Grid.Column="1"
Background="Transparent"
BorderThickness="0"
Padding="6"
VerticalAlignment="Top"
Command="{Binding CloseArchivedListCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
<!-- Accounts list -->
<ScrollViewer MaxHeight="400" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding ArchivedAccounts}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Account">
<Border Background="{DynamicResource BgBase}"
CornerRadius="10"
Padding="14,12">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="10"
Width="42" Height="42"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Icon, Converter={StaticResource SvgPathFromName}}"
Width="18" Height="18"
Css="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<!-- Name / meta -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="3">
<TextBlock Text="{Binding Name}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Type}"
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="·"
FontSize="12"
Foreground="{DynamicResource TextDisabled}" />
<TextBlock Text="{Binding Currency}"
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</StackPanel>
<!-- Restore button -->
<Button Grid.Column="2"
Background="Transparent"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="8"
Padding="12,8"
Command="{Binding DataContext.UnarchiveAccountCommand, ElementName=ArchivedListView}"
CommandParameter="{Binding .}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/rotate-ccw.svg"
Width="13" Height="13"
Css="{DynamicResource SvgBlue}" />
<TextBlock Text="Restore"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource AccentBlue}" />
</StackPanel>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,522 @@
<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="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.AuthViewMobile"
x:DataType="vm:AuthViewModel"
Classes="mobile"
Background="{DynamicResource BgBase}">
<Design.DataContext>
<vm:AuthViewModel />
</Design.DataContext>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="24,48,24,48" Spacing="0">
<!-- Logo -->
<StackPanel HorizontalAlignment="Center" Spacing="6" Margin="0,0,0,36">
<Border CornerRadius="16"
Height="80"
Width="80"
HorizontalAlignment="Center"
Margin="0,0,0,8">
<Image Source="{DynamicResource LogoCombinedPrimaryTransparent2x}" />
</Border>
<TextBlock Text="Your personal finance tracker"
FontSize="13"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center" />
</StackPanel>
<!-- Tab switcher -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,28"
IsVisible="{Binding ShowTabs}">
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Classes="nav"
Classes.accented="{Binding isSignin}"
CornerRadius="7"
Padding="0,10"
Command="{Binding SetOperationCommand}"
CommandParameter="login">
<TextBlock Text="Sign In"
FontSize="14"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</Button>
<Button Grid.Column="1"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Classes="nav"
Classes.accented="{Binding isCreateAccount}"
CornerRadius="7"
Padding="0,10"
Command="{Binding SetOperationCommand}"
CommandParameter="signup">
<TextBlock Text="Create Account"
FontSize="14"
HorizontalAlignment="Center" />
</Button>
</Grid>
</Border>
<!-- Sign In panel -->
<StackPanel Spacing="0" IsVisible="{Binding isSignin}">
<!-- Email -->
<TextBlock Text="EMAIL" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/mail.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Watermark="you@example.com"
Text="{Binding Email}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
</Grid>
</Border>
<!-- Password -->
<TextBlock Text="PASSWORD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,10">
<Grid ColumnDefinitions="Auto,*,Auto">
<Svg Grid.Column="0"
Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Watermark="••••••••"
Text="{Binding Password}"
PasswordChar="●"
RevealPassword="{Binding #showPasswordLogin.IsChecked}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
<ToggleButton Grid.Column="2"
Name="showPasswordLogin"
Background="Transparent"
BorderThickness="0"
Height="48"
Padding="12,0"
VerticalAlignment="Center">
<ToggleButton.Styles>
<Style Selector="ToggleButton:checked /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ToggleButton.Styles>
<Panel>
<Svg Path="../Assets/Icons/eye.svg"
Width="16" Height="16"
IsVisible="{Binding #showPasswordLogin.IsChecked}"
Css="{DynamicResource SvgMuted}" />
<Svg Path="../Assets/Icons/eye-closed.svg"
Width="16" Height="16"
IsVisible="{Binding !#showPasswordLogin.IsChecked}"
Css="{DynamicResource SvgMuted}" />
</Panel>
</ToggleButton>
</Grid>
</Border>
<!-- Forgot password -->
<Button Background="Transparent"
BorderThickness="0"
Padding="0"
Cursor="Hand"
HorizontalAlignment="Right"
Margin="0,8,0,24"
Command="{Binding SetOperationCommand}"
CommandParameter="forgotPassword">
<TextBlock Text="Forgot password?"
FontSize="13"
Foreground="{DynamicResource AccentBlue}" />
</Button>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,20"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="13"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Sign In button -->
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,14"
Margin="0,0,0,24"
Command="{Binding ConfirmLoginCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/log-in.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Sign In"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- Create Account panel -->
<StackPanel Spacing="0" IsVisible="{Binding isCreateAccount}">
<!-- First / Last name -->
<Grid ColumnDefinitions="*,12,*" Margin="0,0,0,16">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="FIRST NAME" Classes="label" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<TextBox Watermark="First"
Text="{Binding FirstName}"
Classes="ghost"
FontSize="14"
Height="48"
Padding="12,0"
VerticalContentAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="LAST NAME" Classes="label" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<TextBox Watermark="Last"
Classes="ghost"
Text="{Binding LastName}"
FontSize="14"
Height="48"
Padding="12,0"
VerticalContentAlignment="Center" />
</Border>
</StackPanel>
</Grid>
<!-- Email -->
<TextBlock Text="EMAIL" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/mail.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Watermark="you@example.com"
Classes="ghost"
Text="{Binding Email}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
</Grid>
</Border>
<!-- Password -->
<TextBlock Text="PASSWORD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*,Auto">
<Svg Grid.Column="0"
Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Watermark="At least 8 characters"
Classes="ghost"
Text="{Binding Password}"
RevealPassword="{Binding #showPasswordSignup.IsChecked}"
PasswordChar="●"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
<ToggleButton Grid.Column="2"
Name="showPasswordSignup"
Background="Transparent"
BorderThickness="0"
Height="48"
Padding="12,0"
VerticalAlignment="Center">
<ToggleButton.Styles>
<Style Selector="ToggleButton:checked /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ToggleButton.Styles>
<Panel>
<Svg Path="../Assets/Icons/eye.svg"
Width="16" Height="16"
IsVisible="{Binding #showPasswordSignup.IsChecked}"
Css="{DynamicResource SvgMuted}" />
<Svg Path="../Assets/Icons/eye-closed.svg"
Width="16" Height="16"
IsVisible="{Binding !#showPasswordSignup.IsChecked}"
Css="{DynamicResource SvgMuted}" />
</Panel>
</ToggleButton>
</Grid>
</Border>
<!-- Confirm Password -->
<TextBlock Text="CONFIRM PASSWORD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Watermark="Repeat your password"
PasswordChar="●"
Text="{Binding ConfirmPassword}"
Classes="ghost"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
</Grid>
</Border>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,20"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="13"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Create Account button -->
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,14"
Margin="0,0,0,24"
Command="{Binding ConfirmCreateAccountCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/user-plus.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Create Account"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- FORGOT PASSWORD PANEL -->
<StackPanel Spacing="0" IsVisible="{Binding isForgotPassword}">
<!-- Back button -->
<Button Background="Transparent"
BorderThickness="0"
Padding="0"
Cursor="Hand"
HorizontalAlignment="Left"
Margin="0,0,0,24"
Command="{Binding SetOperationCommand}"
CommandParameter="login">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/arrow-left.svg"
Width="15" Height="15"
Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Back to Sign In"
FontSize="13"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</Button>
<TextBlock Text="Reset your password"
FontSize="20" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
Margin="0,0,0,8" />
<TextBlock Text="Enter your email and we'll send you a reset link."
FontSize="13"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
Margin="0,0,0,24" />
<!-- Success state -->
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="12"
Padding="14,12"
Margin="0,0,0,16"
IsVisible="{Binding ResetEmailSent}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-check.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #2ECC8A; }" />
<TextBlock Text="Check your email for a reset link."
FontSize="13"
Foreground="{DynamicResource AccentGreen}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Email -->
<TextBlock Text="EMAIL" Classes="label" Margin="0,0,0,6"
IsVisible="{Binding !ResetEmailSent}" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16"
IsVisible="{Binding !ResetEmailSent}">
<Grid ColumnDefinitions="Auto,*">
<Svg Grid.Column="0"
Path="../Assets/Icons/mail.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Watermark="you@example.com"
Text="{Binding Email}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
</Grid>
</Border>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,20"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="13"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Send Reset Link button -->
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,14"
Margin="0,0,0,24"
IsVisible="{Binding !ResetEmailSent}"
Command="{Binding SendResetLinkCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/mail.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Send Reset Link"
FontSize="15" FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- Footer -->
<Separator Margin="0,0,0,16" />
<TextBlock Text="Your data is encrypted and synced securely."
FontSize="12"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

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

View File

@@ -0,0 +1,393 @@
<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:behaviors="clr-namespace:Clario.Behaviors"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.BudgetFormViewMobile"
x:DataType="vm:BudgetFormViewModel"
Classes="mobile">
<Design.DataContext>
<vm:BudgetFormViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto"
Background="{DynamicResource BgBase}">
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
Margin="16,16,16,0">
<!-- Close -->
<Button Grid.Column="0"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="20"
Width="36" Height="36"
Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
<!-- Title -->
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock Text="{Binding FormTitle}"
FontSize="15"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center" />
<TextBlock Text="{Binding FormSubtitle}"
FontSize="11"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center" />
</StackPanel>
<!-- Delete (edit mode only) -->
<Button Grid.Column="2"
Background="{DynamicResource DangerButtonBackground}"
BorderBrush="{DynamicResource DangerButtonBorder}"
BorderThickness="1"
CornerRadius="20"
Width="36" Height="36"
Padding="0"
HorizontalContentAlignment="Center"
IsVisible="{Binding IsEditMode}"
Command="{Binding RequestDeleteCommand}">
<Svg Path="../Assets/Icons/trash-2.svg"
Width="14" Height="14"
Css="{DynamicResource SvgRed}" />
</Button>
<!-- Placeholder to keep title centered -->
<Border Grid.Column="2" Width="36" IsVisible="{Binding !IsEditMode}" />
</Grid>
<!-- Scrollable form -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Margin="0,16,0,0">
<StackPanel Margin="16,0,16,16" Spacing="0">
<!-- Limit Amount (hero input) -->
<TextBlock Text="LIMIT AMOUNT" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,0"
Margin="0,0,0,20"
Height="64">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="32"
FontWeight="Bold"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center"
Margin="0,0,8,0"
Padding="0,0,0,2" />
<TextBox Grid.Column="1"
Classes="ghost"
Text="{Binding LimitAmount, Mode=TwoWay}"
Watermark="0.00"
FontSize="32"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Height="54"
Padding="0,0,0,2"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
<!-- Category -->
<TextBlock Text="CATEGORY" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*" Height="48">
<Border Grid.Column="0"
CornerRadius="7"
Width="32" Height="32"
Margin="10,0,0,0"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush
Color="{Binding SelectedCategory.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedCategory.Icon, Converter={StaticResource SvgPathFromName}}"
Width="14" Height="14"
Css="{Binding SelectedCategory.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<ComboBox Grid.Column="1"
VerticalAlignment="Stretch"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
Background="Transparent"
BorderThickness="0"
Padding="10,12"
FontSize="15"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
<!-- Period -->
<TextBlock Text="PERIOD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,20"
Height="50">
<Grid ColumnDefinitions="*,*,*">
<Button Grid.Column="0"
Classes="nav"
Classes.accented="{Binding IsMonthly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalAlignment="Stretch"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="monthly">
<TextBlock Text="Monthly" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</Button>
<Button Grid.Column="1"
Classes="nav"
Classes.accented="{Binding IsQuarterly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalAlignment="Stretch"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="quarterly">
<TextBlock Text="Quarterly" FontSize="14" VerticalAlignment="Center" />
</Button>
<Button Grid.Column="2"
Classes="nav"
Classes.accented="{Binding IsYearly}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalAlignment="Stretch"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetPeriodCommand}"
CommandParameter="yearly">
<TextBlock Text="Yearly" FontSize="14" 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="16,12"
Margin="0,0,0,20">
<StackPanel Spacing="6">
<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,12"
Margin="0,0,0,20">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
CornerRadius="8"
Width="34" Height="34"
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="15"
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>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,8"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="13" Height="13"
Css="{DynamicResource SvgRed}" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Bottom action bar -->
<Border Grid.Row="2"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0"
Padding="16,12,16,20">
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0"
Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Grid.Column="1"
Classes="accented"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding IsValid}"
Command="{Binding SaveCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/check.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="{Binding SaveButtonLabel}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
<!-- Delete confirm sub-modal -->
<Grid Grid.Row="0" Grid.RowSpan="3" IsVisible="{Binding ShowDeleteConfirm}">
<Border Background="#50000000" />
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="24,0"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="18"
Padding="24">
<StackPanel Spacing="0">
<Border Background="{DynamicResource IconBgRed}"
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="{DynamicResource SvgRed}" />
</Border>
<TextBlock Text="Delete Budget"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8" />
<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" />
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelDeleteCommand}" />
<Button Classes="danger"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ConfirmDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="Delete" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

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

View File

@@ -8,6 +8,7 @@
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.BudgetViewMobile"
x:DataType="vm:BudgetViewModel"
x:Name="budgetRoot"
Classes="mobile">
<Design.DataContext>
<vm:BudgetViewModel />
@@ -16,7 +17,7 @@
<Grid RowDefinitions="Auto,*"
Background="{DynamicResource BgBase}">
<!-- ── Top bar ────────────────────────────── -->
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
Margin="16,16,16,12">
@@ -63,7 +64,8 @@
</StackPanel>
</Border>
<Button Classes="accented"
Padding="10,8">
Padding="10,8"
Command="{Binding CreateBudgetCommand}">
<Svg Path="../Assets/Icons/plus.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
@@ -72,13 +74,13 @@
</Grid>
<!-- ── Scrollable content ────────────────── -->
<!-- Scrollable content -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,24" Spacing="14">
<!-- ── Period overview strip ─────────── -->
<!-- Period overview strip -->
<Grid ColumnDefinitions="*,*,*">
<!-- Budgeted -->
@@ -93,10 +95,16 @@
<TextBlock Text="Budgeted"
FontSize="10"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding TotalBudgeted, StringFormat='$0'}"
FontSize="13"
<TextBlock FontSize="13"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
Foreground="{DynamicResource TextPrimary}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N0}">
<Binding Path="PrimarySymbol" />
<Binding Path="TotalBudgeted" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
@@ -112,10 +120,16 @@
<TextBlock Text="Spent"
FontSize="10"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding TotalSpent, StringFormat='$0'}"
FontSize="13"
<TextBlock FontSize="13"
FontWeight="Bold"
Foreground="{DynamicResource AccentRed}" />
Foreground="{DynamicResource AccentRed}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N0}">
<Binding Path="PrimarySymbol" />
<Binding Path="TotalSpent" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
@@ -140,7 +154,7 @@
</Grid>
<!-- ── Overall progress bar ──────────── -->
<!-- Overall progress bar -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -181,7 +195,7 @@
</StackPanel>
</Border>
<!-- ── Budget cards list ─────────────── -->
<!-- Budget cards list -->
<ItemsControl ItemsSource="{Binding VisibleBudgets}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -248,14 +262,16 @@
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Right" />
</StackPanel>
<!-- Menu -->
<!-- Edit button -->
<Button Grid.Column="3"
Background="Transparent"
BorderThickness="0"
Padding="4"
VerticalAlignment="Center">
<Svg Path="../Assets/Icons/ellipsis.svg"
Width="15" Height="15"
VerticalAlignment="Center"
Command="{Binding DataContext.EditBudgetCommand, ElementName=budgetRoot}"
CommandParameter="{Binding .}">
<Svg Path="../Assets/Icons/pencil.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
@@ -306,7 +322,7 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ── Spending breakdown chart ──────── -->
<!-- Spending breakdown chart -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -348,7 +364,7 @@
</StackPanel>
</Border>
<!-- ── Period progress ───────────────── -->
<!-- Period progress -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -401,7 +417,7 @@
</StackPanel>
</Border>
<!-- ── Savings goal ──────────────────── -->
<!-- Savings goal -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -419,7 +435,8 @@
<Button Grid.Column="1"
Background="Transparent"
BorderThickness="0"
Padding="4">
Padding="4"
Command="{Binding EditSavingsGoalCommand}">
<Svg Path="../Assets/Icons/pencil.svg" Width="14" Height="14" Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
@@ -427,8 +444,15 @@
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Monthly goal" FontSize="12" Foreground="{DynamicResource TextMuted}" />
<TextBlock Grid.Column="1" Text="{Binding AppData.Profile.SavingsGoal, StringFormat='$0'}" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Grid.Column="1" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N0}">
<Binding Path="PrimarySymbol" />
<Binding Path="AppData.Profile.SavingsGoal" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Projected savings" FontSize="12" Foreground="{DynamicResource TextMuted}" />

View File

@@ -0,0 +1,168 @@
<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:model="clr-namespace:Clario.Models"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.CategoriesViewMobile"
x:DataType="vm:CategoriesViewModel"
x:Name="CategoriesPage"
Classes="mobile">
<Design.DataContext>
<vm:CategoriesViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*"
Background="{DynamicResource BgBase}">
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
Margin="16,16,16,12">
<StackPanel Grid.Column="0">
<TextBlock Text="Categories"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
</StackPanel>
<Button Grid.Column="1"
Classes="accented"
Padding="12,8"
Command="{Binding AddCategoryCommand}">
<Svg Path="../Assets/Icons/plus.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
</Button>
</Grid>
<!-- Content -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,24" Spacing="20">
<!-- Expense section -->
<StackPanel IsVisible="{Binding HasExpenseCategories}" Spacing="8">
<TextBlock Text="EXPENSES" Classes="label" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1,0.25"
CornerRadius="14"
ClipToBounds="True">
<ItemsControl ItemsSource="{Binding ExpenseCategories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="0" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Category">
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0.75">
<Button Classes="nav"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Background="{DynamicResource BgSurface}"
Padding="14,12"
Command="{Binding DataContext.EditCategoryCommand, ElementName=CategoriesPage}"
CommandParameter="{Binding .}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
CornerRadius="10"
Width="36" Height="36"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Svg Grid.Column="2"
Path="../Assets/Icons/chevron-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center" />
</Grid>
</Button>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<!-- Income section -->
<StackPanel IsVisible="{Binding HasIncomeCategories}" Spacing="8">
<TextBlock Text="INCOME" Classes="label" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1,0.25"
CornerRadius="14"
ClipToBounds="True">
<ItemsControl ItemsSource="{Binding IncomeCategories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="0" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Category">
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0.75">
<Button Classes="nav"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Background="{DynamicResource BgSurface}"
Padding="14,12"
Command="{Binding DataContext.EditCategoryCommand, ElementName=CategoriesPage}"
CommandParameter="{Binding .}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
CornerRadius="10"
Width="36" Height="36"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<TextBlock Grid.Column="1"
Text="{Binding Name}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Svg Grid.Column="2"
Path="../Assets/Icons/chevron-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center" />
</Grid>
</Button>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,320 @@
<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.MobileViews.CategoryFormViewMobile"
x:DataType="vm:CategoryFormViewModel"
x:Name="CategoryFormRoot"
Classes="mobile">
<Design.DataContext>
<vm:CategoryFormViewModel />
</Design.DataContext>
<Grid>
<Border Background="#70000000" />
<!-- Bottom sheet -->
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,32">
<StackPanel Spacing="0">
<!-- Handle bar -->
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,18" />
<!-- Header -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<Border Grid.Column="0"
CornerRadius="10"
Width="40" Height="40"
Margin="0,0,12,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="17" Height="17"
Css="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding FormTitle}"
FontSize="15"
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"
Command="{Binding CancelCommand}">
<Svg Path="../Assets/Icons/x.svg"
Width="15" Height="15"
Css="{DynamicResource SvgMuted}" />
</Button>
</Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="480">
<StackPanel Spacing="0">
<!-- Name -->
<TextBlock Text="NAME" Classes="label" Margin="0,0,0,6" />
<TextBox Text="{Binding Name, Mode=TwoWay}"
Watermark="e.g. Groceries"
FontSize="14"
Height="44"
Padding="14,0"
VerticalContentAlignment="Center"
Margin="0,0,0,16" />
<!-- Type toggle -->
<TextBlock Text="TYPE" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,16">
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0"
Classes="nav"
Classes.accented="{Binding IsExpense}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
CornerRadius="7"
Padding="0,10"
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="expense">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/arrow-up-right.svg" Width="14" Height="14" />
<TextBlock Text="Expense" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Grid.Column="1"
Classes="nav"
Classes.accented="{Binding IsIncome}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
CornerRadius="7"
Padding="0,10"
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="income">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/arrow-down-left.svg" Width="14" Height="14" />
<TextBlock Text="Income" FontSize="14" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
<!-- Icon picker -->
<TextBlock Text="ICON" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="8"
Margin="0,0,0,16">
<ScrollViewer MaxHeight="160"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding CategoryIcons}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="7" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Classes="nav"
Width="44"
Height="44"
Padding="0"
Margin="2"
CornerRadius="8"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Command="{Binding DataContext.SetIconCommand, ElementName=CategoryFormRoot}"
CommandParameter="{Binding}">
<Classes.accented>
<MultiBinding Converter="{StaticResource EqualValueConverter}">
<Binding Path="." />
<Binding Path="DataContext.SelectedIcon" ElementName="CategoryFormRoot" />
</MultiBinding>
</Classes.accented>
<Svg Path="{Binding Converter={StaticResource SvgPathFromName}}"
Width="16" Height="16" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Color -->
<TextBlock Text="COLOR" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="8"
Margin="0,0,0,20">
<ColorPicker
Color="{Binding SelectedColor, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
HorizontalAlignment="Stretch"
Height="44"
CornerRadius="{DynamicResource RadiusControl}"
IsAlphaEnabled="False"
IsAlphaVisible="False"
IsColorPaletteVisible="False"
IsAccentColorsVisible="False" />
</Border>
<!-- Error banner -->
<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="{DynamicResource SvgRed}" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Delete (edit mode only) -->
<Button Classes="danger"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,12"
Margin="0,0,0,10"
IsVisible="{Binding IsEditMode}"
IsEnabled="{Binding CanDelete}"
Command="{Binding RequestDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="{Binding CanDelete, Converter={StaticResource BoolToStringConverter}, ConverterParameter='Delete Category|Min. 4 categories required'}"
FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- Actions -->
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,13"
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="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</ScrollViewer>
</StackPanel>
</Border>
<!-- Delete confirm sub-modal -->
<Grid IsVisible="{Binding ShowDeleteConfirm}">
<Border Background="#50000000" />
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="24,0"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="18"
Padding="24">
<StackPanel Spacing="0">
<Border Background="{DynamicResource IconBgRed}"
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="{DynamicResource SvgRed}" />
</Border>
<TextBlock Text="Delete Category"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8" />
<TextBlock Text="This action cannot be undone. The category will be permanently removed."
FontSize="13"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Margin="0,0,0,24" />
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelDeleteCommand}" />
<Button Classes="danger"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ConfirmDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="Delete" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,304 @@
<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"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:CompileBindings="False"
x:Class="Clario.MobileViews.DashboardSkeletonViewMobile"
Classes="mobile">
<UserControl.Styles>
<Style Selector="Border.skeleton">
<Setter Property="Background" Value="{DynamicResource BgHover}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Style.Animations>
<Animation Duration="0:0:0.85" IterationCount="INFINITE" PlaybackDirection="Alternate" FillMode="Both">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0.35" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*" Background="{DynamicResource BgBase}">
<!-- Top Bar -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto" Margin="16,16,16,12">
<StackPanel Grid.Column="0" Spacing="7">
<Border Classes="skeleton" Height="22" Width="185" CornerRadius="7" />
<Border Classes="skeleton" Height="12" Width="115" />
</StackPanel>
<Border Grid.Column="1" Classes="skeleton" Width="36" Height="36"
CornerRadius="10" Margin="0,0,8,0" VerticalAlignment="Center" />
<Border Grid.Column="2" Classes="skeleton" Width="38" Height="36"
CornerRadius="10" VerticalAlignment="Center" />
</Grid>
<!-- Scrollable Content -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,24" Spacing="14">
<!-- KPI Cards 2-col grid -->
<Grid ColumnDefinitions="*,*">
<!-- Income card -->
<Border Grid.Column="0"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,12"
Margin="0,0,6,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="skeleton" Width="22" Height="22" CornerRadius="{StaticResource RadiusIcon}" />
<Border Classes="skeleton" Height="10" Width="52" VerticalAlignment="Center" />
</StackPanel>
<Border Classes="skeleton" Height="22" Width="90" CornerRadius="7" />
<Border Classes="skeleton" Height="20" Width="65" CornerRadius="20" />
</StackPanel>
</Border>
<!-- Expenses card -->
<Border Grid.Column="1"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,12"
Margin="6,0,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="skeleton" Width="22" Height="22" CornerRadius="{StaticResource RadiusIcon}" />
<Border Classes="skeleton" Height="10" Width="62" VerticalAlignment="Center" />
</StackPanel>
<Border Classes="skeleton" Height="22" Width="82" CornerRadius="7" />
<Border Classes="skeleton" Height="20" Width="60" CornerRadius="20" />
</StackPanel>
</Border>
</Grid>
<!-- Savings Rate full-width -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,12">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="skeleton" Width="22" Height="22" CornerRadius="{StaticResource RadiusIcon}" />
<Border Classes="skeleton" Height="10" Width="88" VerticalAlignment="Center" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<Border Grid.Column="0" Classes="skeleton" Height="7" VerticalAlignment="Center"
HorizontalAlignment="Stretch" CornerRadius="4" />
<Border Grid.Column="1" Classes="skeleton" Width="36" Height="18"
Margin="12,0,0,0" CornerRadius="6" />
</Grid>
</StackPanel>
</Border>
<!-- Spending by Category chart card -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6">
<Border Classes="skeleton" Height="14" Width="160" />
<Border Classes="skeleton" Height="11" Width="105" />
</StackPanel>
<Border Grid.Column="1" Classes="skeleton" Width="85" Height="28"
CornerRadius="{StaticResource RadiusIcon}" VerticalAlignment="Center" />
</Grid>
<!-- Chart placeholder -->
<Border Classes="skeleton" Height="180" HorizontalAlignment="Stretch" CornerRadius="8" />
<!-- Category labels -->
<Grid ColumnDefinitions="*,*,*,*">
<Border Grid.Column="0" Classes="skeleton" Height="10" Margin="4,0" HorizontalAlignment="Stretch" />
<Border Grid.Column="1" Classes="skeleton" Height="10" Margin="4,0" HorizontalAlignment="Stretch" />
<Border Grid.Column="2" Classes="skeleton" Height="10" Margin="4,0" HorizontalAlignment="Stretch" />
<Border Grid.Column="3" Classes="skeleton" Height="10" Margin="4,0" HorizontalAlignment="Stretch" />
</Grid>
<!-- Amount labels -->
<Grid ColumnDefinitions="*,*,*,*">
<Border Grid.Column="0" Classes="skeleton" Height="11" Width="30" HorizontalAlignment="Center" />
<Border Grid.Column="1" Classes="skeleton" Height="11" Width="34" HorizontalAlignment="Center" />
<Border Grid.Column="2" Classes="skeleton" Height="11" Width="28" HorizontalAlignment="Center" />
<Border Grid.Column="3" Classes="skeleton" Height="11" Width="32" HorizontalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<!-- Recent Transactions card -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="5">
<Border Classes="skeleton" Height="14" Width="150" />
<Border Classes="skeleton" Height="11" Width="100" />
</StackPanel>
<Border Grid.Column="1" Classes="skeleton" Width="50" Height="12" VerticalAlignment="Center" />
</Grid>
<!-- Transaction rows inside rounded container -->
<Border Background="{DynamicResource BorderSubtle}" CornerRadius="10">
<StackPanel Spacing="1">
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgSurface}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="36" Height="36"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="6">
<Border Classes="skeleton" Height="12" Width="130" />
<Border Classes="skeleton" Height="10" Width="88" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="52" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgSurface}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="36" Height="36"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="6">
<Border Classes="skeleton" Height="12" Width="110" />
<Border Classes="skeleton" Height="10" Width="75" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="48" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgSurface}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="36" Height="36"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="6">
<Border Classes="skeleton" Height="12" Width="145" />
<Border Classes="skeleton" Height="10" Width="95" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="55" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgSurface}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="36" Height="36"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="6">
<Border Classes="skeleton" Height="12" Width="120" />
<Border Classes="skeleton" Height="10" Width="82" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="50" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgSurface}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="36" Height="36"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="6">
<Border Classes="skeleton" Height="12" Width="135" />
<Border Classes="skeleton" Height="10" Width="90" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="46" Height="12" VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Budget Tracker card -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="5">
<Border Classes="skeleton" Height="14" Width="110" />
<Border Classes="skeleton" Height="11" Width="80" />
</StackPanel>
<Border Grid.Column="1" Classes="skeleton" Width="52" Height="24"
CornerRadius="{StaticResource RadiusControl}" VerticalAlignment="Center" />
</Grid>
<!-- Budget items -->
<StackPanel Spacing="14">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<Border Grid.Column="0" Classes="skeleton" Height="13" Width="88" />
<Border Grid.Column="1" Classes="skeleton" Height="11" Width="65" />
</Grid>
<Border Classes="skeleton" Height="5" HorizontalAlignment="Stretch" CornerRadius="3" />
</StackPanel>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<Border Grid.Column="0" Classes="skeleton" Height="13" Width="68" />
<Border Grid.Column="1" Classes="skeleton" Height="11" Width="58" />
</Grid>
<Border Classes="skeleton" Height="5" HorizontalAlignment="Stretch" CornerRadius="3" />
</StackPanel>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<Border Grid.Column="0" Classes="skeleton" Height="13" Width="98" />
<Border Grid.Column="1" Classes="skeleton" Height="11" Width="62" />
</Grid>
<Border Classes="skeleton" Height="5" HorizontalAlignment="Stretch" CornerRadius="3" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<!-- Accounts Summary card -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,14">
<StackPanel Spacing="14">
<StackPanel Spacing="5">
<Border Classes="skeleton" Height="14" Width="75" />
<Border Classes="skeleton" Height="11" Width="130" />
</StackPanel>
<!-- Account rows inside rounded container -->
<Border Background="{DynamicResource BorderSubtle}" CornerRadius="10">
<StackPanel Spacing="1">
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgBase}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="34" Height="34"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="5">
<Border Classes="skeleton" Height="12" Width="90" />
<Border Classes="skeleton" Height="10" Width="55" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="58" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgBase}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="34" Height="34"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="5">
<Border Classes="skeleton" Height="12" Width="70" />
<Border Classes="skeleton" Height="10" Width="48" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="52" Height="12" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto" Background="{DynamicResource BgBase}" Margin="12,10">
<Border Grid.Column="0" Classes="skeleton" Width="34" Height="34"
CornerRadius="9" Margin="0,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="5">
<Border Classes="skeleton" Height="12" Width="82" />
<Border Classes="skeleton" Height="10" Width="52" />
</StackPanel>
<Border Grid.Column="2" Classes="skeleton" Width="62" Height="12" VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<!-- Total Balance row -->
<Grid ColumnDefinitions="*,Auto">
<Border Grid.Column="0" Classes="skeleton" Height="13" Width="85" VerticalAlignment="Center" />
<Border Grid.Column="1" Classes="skeleton" Height="18" Width="95" CornerRadius="7" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

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

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<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"
@@ -7,45 +7,53 @@
xmlns:model="clr-namespace:Clario.Models"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="800"
x:DataType="vm:DashboardViewModel"
x:Class="Clario.MobileViews.DashboardViewMobile">
x:Class="Clario.MobileViews.DashboardViewMobile"
x:Name="DashboardRoot"
Classes="mobile">
<Design.DataContext>
<vm:DashboardViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*"
Background="{DynamicResource BgBase}">
<Grid RowDefinitions="Auto,*" Background="{DynamicResource BgBase}">
<!-- ── Top bar ────────────────────────────── -->
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
Margin="16,16,16,12">
<!-- Top bar -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto" Margin="16,16,16,12">
<StackPanel Grid.Column="0">
<TextBlock Text="Financial Overview"
FontSize="22"
FontWeight="Bold"
FontSize="22" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted"
Text="Friday, March 6, 2026"
FontSize="12"
Margin="0,2,0,0" />
<TextBlock Classes="muted" Text="{Binding DateToday}" FontSize="12" Margin="0,2,0,0" />
</StackPanel>
<!-- Settings button -->
<Button Grid.Column="1"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="9,8"
Margin="0,0,8,0"
VerticalAlignment="Center"
Command="{Binding NavigateToSettingsCommand}">
<Svg Path="../Assets/Icons/settings.svg" Width="16" Height="16" Css="{DynamicResource SvgMuted}" />
</Button>
<!-- Add transaction button -->
<Button Grid.Column="2"
Classes="accented"
Padding="12,8"
VerticalAlignment="Center">
<Svg Path="../Assets/Icons/plus.svg"
Width="16" Height="16"
VerticalAlignment="Center"
Command="{Binding CreateTransactionCommand}">
<Svg Path="../Assets/Icons/plus.svg" Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
</Button>
</Grid>
<!-- ── Scrollable content ────────────────── -->
<!-- Scrollable content -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,24" Spacing="14">
<!-- ── KPI cards ──────────────────────── -->
<!-- KPI cards -->
<Grid ColumnDefinitions="*,*">
<!-- Income -->
@@ -61,22 +69,22 @@
<Border Background="{DynamicResource IconBgGreen}"
CornerRadius="{StaticResource RadiusIcon}"
Padding="5">
<Svg Path="../Assets/Icons/trending-up.svg"
Height="12" Width="12"
<Svg Path="../Assets/Icons/trending-up.svg" Height="12" Width="12"
Css="{DynamicResource SvgGreen}" />
</Border>
<TextBlock Text="INCOME"
Classes="label"
VerticalAlignment="Center" />
<TextBlock Text="INCOME" Classes="label" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{Binding MonthlyIncome, StringFormat='$0.00'}"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock FontSize="18" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N2}">
<Binding Path="PrimarySymbol" />
<Binding Path="MonthlyIncome" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Border Classes="badge-green" HorizontalAlignment="Left">
<TextBlock Text="{Binding MonthlyIncomeChangeFormatted}"
FontSize="10"
FontWeight="SemiBold"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource AccentGreen}" />
</Border>
</StackPanel>
@@ -95,22 +103,22 @@
<Border Background="{DynamicResource IconBgOrange}"
CornerRadius="{StaticResource RadiusIcon}"
Padding="5">
<Svg Path="../Assets/Icons/trending-down.svg"
Height="12" Width="12"
<Svg Path="../Assets/Icons/trending-down.svg" Height="12" Width="12"
Css="{DynamicResource SvgRed}" />
</Border>
<TextBlock Text="EXPENSES"
Classes="label"
VerticalAlignment="Center" />
<TextBlock Text="EXPENSES" Classes="label" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="{Binding MonthlyExpenses, StringFormat='$0.00'}"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock FontSize="18" FontWeight="Bold" Foreground="{DynamicResource TextPrimary}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N2}">
<Binding Path="PrimarySymbol" />
<Binding Path="MonthlyExpenses" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Border Classes="badge-red" HorizontalAlignment="Left">
<TextBlock Text="{Binding MonthlyExpenseChangeFormatted}"
FontSize="10"
FontWeight="SemiBold"
FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource AccentRed}" />
</Border>
</StackPanel>
@@ -129,13 +137,10 @@
<Border Background="{DynamicResource IconBgPurple}"
CornerRadius="{StaticResource RadiusIcon}"
Padding="5">
<Svg Path="../Assets/Icons/landmark.svg"
Height="12" Width="12"
<Svg Path="../Assets/Icons/landmark.svg" Height="12" Width="12"
Css="{DynamicResource SvgPurple}" />
</Border>
<TextBlock Text="SAVINGS RATE"
Classes="label"
VerticalAlignment="Center" />
<TextBlock Text="SAVINGS RATE" Classes="label" VerticalAlignment="Center" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<ProgressBar Grid.Column="0"
@@ -145,8 +150,7 @@
Value="{Binding MonthlyExpenses}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
FontSize="15"
FontWeight="Bold"
FontSize="15" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
Margin="12,0,0,0">
<TextBlock.Text>
@@ -160,7 +164,7 @@
</StackPanel>
</Border>
<!-- ── Spending by category chart ────── -->
<!-- Spending by category chart -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -170,79 +174,104 @@
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Spending by Category"
FontSize="14"
FontWeight="SemiBold"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="March 2026" FontSize="11" />
<TextBlock Classes="muted" Text="{Binding SelectedChartTimPeriodSubTitle}" FontSize="11" />
</StackPanel>
<ComboBox Grid.Column="1"
SelectedIndex="0"
ItemsSource="{Binding ChartTimePeriods}"
SelectedItem="{Binding SelectedChartTimePeriod}"
Background="{DynamicResource BgHover}"
Foreground="{DynamicResource TextSecondary}"
BorderBrush="{DynamicResource BorderAccent}"
CornerRadius="{StaticResource RadiusIcon}"
Padding="8,5"
FontSize="12" />
Padding="8,5" FontSize="12" />
</Grid>
<lvc:CartesianChart Series="{Binding SpendingByCategoryChartSeries}"
Height="180"
Background="{DynamicResource BgSurface}"
LegendPosition="Hidden"
TooltipPosition="Hidden">
<lvc:CartesianChart.XAxes>
<lvc:XamlAxis IsVisible="False" />
</lvc:CartesianChart.XAxes>
<lvc:CartesianChart.YAxes>
<lvc:XamlLogarithmicAxis LogBase="10" IsVisible="False" MinLimit="1" />
</lvc:CartesianChart.YAxes>
</lvc:CartesianChart>
<Panel>
<!-- Chart + labels (when there is data) -->
<StackPanel Spacing="8" IsVisible="{Binding HasSpendingData}">
<lvc:CartesianChart Series="{Binding SpendingByCategoryChartSeries}"
Height="180"
Background="{DynamicResource BgSurface}"
LegendPosition="Hidden"
TooltipPosition="Hidden"
ZoomMode="None"
AnimationsSpeed="00:00:00.1">
<lvc:CartesianChart.XAxes>
<lvc:XamlAxis IsVisible="False" />
</lvc:CartesianChart.XAxes>
<lvc:CartesianChart.YAxes>
<lvc:XamlLogarithmicAxis LogBase="10" IsVisible="False" MinLimit="1" />
</lvc:CartesianChart.YAxes>
</lvc:CartesianChart>
<!-- Category labels -->
<ItemsControl ItemsSource="{Binding SpendingByCategoryChartData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="model:ColumnChartData">
<TextBlock Text="{Binding Name}"
HorizontalAlignment="Center"
FontSize="10"
Foreground="{DynamicResource TextDisabled}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Category labels -->
<ItemsControl ItemsSource="{Binding SpendingByCategoryChartData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="model:ColumnChartData">
<TextBlock Text="{Binding Name}"
HorizontalAlignment="Center"
FontSize="10"
Foreground="{DynamicResource TextDisabled}"
TextTrimming="CharacterEllipsis" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border HorizontalAlignment="Stretch"
Height="1"
Background="{DynamicResource BorderSubtle}" />
<Border HorizontalAlignment="Stretch" Height="1"
Background="{DynamicResource BorderSubtle}" />
<!-- Category amounts -->
<ItemsControl ItemsSource="{Binding SpendingByCategoryChartData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="model:ColumnChartData">
<TextBlock Text="{Binding Values, Converter={StaticResource FirstValueConverter}, StringFormat='$0,00'}"
HorizontalAlignment="Center"
FontSize="10"
FontWeight="SemiBold"
Foreground="{Binding Fill, Converter={StaticResource SkPaintToBrushConverter}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Category amounts -->
<ItemsControl ItemsSource="{Binding SpendingByCategoryChartData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="model:ColumnChartData">
<TextBlock HorizontalAlignment="Center"
FontSize="10" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
Foreground="{Binding Fill, Converter={StaticResource SkPaintToBrushConverter}}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N0}">
<Binding Path="DataContext.PrimarySymbol" ElementName="DashboardRoot" />
<Binding Path="Values" Converter="{StaticResource FirstValueConverter}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Empty state -->
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Spacing="10" Margin="0,40"
IsVisible="{Binding !HasSpendingData}">
<Svg Path="../Assets/Icons/chart-column.svg" Css="{DynamicResource SvgDisabled}"
Height="36" Width="36" HorizontalAlignment="Center" />
<TextBlock Text="No spending data"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" />
<TextBlock Text="Add expense transactions to see your spending breakdown."
FontSize="12" Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" TextWrapping="Wrap"
TextAlignment="Center" MaxWidth="220" />
</StackPanel>
</Panel>
</StackPanel>
</Border>
<!-- ── Recent transactions ───────────── -->
<!-- Recent transactions -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -252,148 +281,193 @@
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Recent Transactions"
FontSize="14"
FontWeight="SemiBold"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="Last 5 transactions" FontSize="11" />
</StackPanel>
<Button Grid.Column="1"
Background="Transparent"
Foreground="{DynamicResource AccentBlue}"
BorderThickness="0"
FontSize="12"
Padding="0"
Content="View all →"
VerticalAlignment="Center"
BorderThickness="0" FontSize="12" Padding="0"
Content="View all →" VerticalAlignment="Center"
Command="{Binding ViewAllTransactionsCommand}" />
</Grid>
<!-- Transaction rows -->
<Border Background="{DynamicResource BorderSubtle}" CornerRadius="10">
<ItemsControl ItemsSource="{Binding RecentTransactions}"
Background="{DynamicResource BgSurface}"
CornerRadius="10">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Transaction">
<Grid ColumnDefinitions="Auto,*,Auto"
<Panel>
<!-- Transaction rows -->
<Border Background="{DynamicResource BorderSubtle}" CornerRadius="10"
IsVisible="{Binding HasTransactionData}">
<ItemsControl ItemsSource="{Binding RecentTransactions}"
Background="{DynamicResource BgSurface}"
Margin="12,10">
<Border Grid.Column="0"
CornerRadius="9"
Width="36" Height="36"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Height="16" Width="16"
Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Description}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondary}" />
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding Category.Name}" Classes="muted" FontSize="11" />
<TextBlock Text="·" Classes="muted" FontSize="11" />
<TextBlock
Text="{Binding Date, Converter={StaticResource DateFormatConverter}, ConverterParameter='MMM d'}"
Classes="muted" FontSize="11" />
CornerRadius="10">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Transaction">
<Grid ColumnDefinitions="Auto,*,Auto"
Background="{DynamicResource BgSurface}"
Margin="12,10">
<Border Grid.Column="0"
CornerRadius="9" Width="36" Height="36"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Height="16" Width="16"
Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Description}"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondary}" />
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding Category.Name}" Classes="muted" FontSize="11" />
<TextBlock Text="·" Classes="muted" FontSize="11" />
<TextBlock
Text="{Binding Date, Converter={StaticResource DateFormatConverter}, ConverterParameter='MMM d'}"
Classes="muted" FontSize="11" />
</StackPanel>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2"
FontSize="13"
FontWeight="SemiBold"
Foreground="{Binding Type, Converter={StaticResource AmountColorConverter}}"
VerticalAlignment="Center">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource AmountSignConverter}">
<Binding Path="Amount" />
<Binding Path="Type" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<TextBlock Grid.Column="2"
FontSize="13" FontWeight="SemiBold"
Foreground="{Binding Type, Converter={StaticResource AmountColorConverter}}"
VerticalAlignment="Center">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource AmountSignConverter}">
<Binding Path="Amount" />
<Binding Path="Type" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Empty state -->
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Spacing="10" Margin="0,32"
IsVisible="{Binding !HasTransactionData}">
<Svg Path="../Assets/Icons/receipt.svg" Css="{DynamicResource SvgDisabled}"
Height="36" Width="36" HorizontalAlignment="Center" />
<TextBlock Text="No transactions yet"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" />
<TextBlock Text="Add your first transaction to get started."
FontSize="12" Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" TextWrapping="Wrap"
TextAlignment="Center" MaxWidth="220" />
</StackPanel>
</Panel>
</StackPanel>
</Border>
<!-- ── Budget tracker ────────────────── -->
<!-- Budget tracker -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="14,14">
<StackPanel Spacing="14">
<StackPanel>
<TextBlock Text="Budget Tracker"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="Monthly limits" FontSize="11" />
</StackPanel>
<ItemsControl ItemsSource="{Binding BudgetsTrackerData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="14" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Budget">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6">
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Height="13" Width="13"
Css="{DynamicResource SvgSecondary}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Category.Name}"
FontSize="13"
VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondary}" />
</StackPanel>
<Panel Grid.Column="1">
<StackPanel Orientation="Horizontal" IsVisible="{Binding !IsOverBudget}">
<TextBlock Text="{Binding Spent, StringFormat='$0'}" FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text=" / " FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding LimitAmount, StringFormat='$0'}" FontSize="11"
Foreground="{DynamicResource TextMuted}" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Budget Tracker"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="Monthly limits" FontSize="11" />
</StackPanel>
<Button Grid.Column="1"
Classes="nav"
VerticalAlignment="Center"
Command="{Binding NavigateToBudgetCommand}"
IsVisible="{Binding HasBudgetData}"
Padding="8,4">
<TextBlock Text="View all" FontSize="12"
Foreground="{DynamicResource AccentBlue}" />
</Button>
</Grid>
<Panel>
<ItemsControl ItemsSource="{Binding BudgetsTrackerData}"
IsVisible="{Binding HasBudgetData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="14" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="model:Budget">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6">
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Height="13" Width="13"
Css="{DynamicResource SvgSecondary}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Category.Name}"
FontSize="13" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondary}" />
</StackPanel>
<StackPanel Orientation="Horizontal" IsVisible="{Binding IsOverBudget}">
<TextBlock Text="{Binding Spent, StringFormat='$0'}" FontSize="11" Foreground="{DynamicResource AccentRed}" />
<TextBlock Text=" / " FontSize="11" Foreground="{DynamicResource AccentRed}" />
<TextBlock Text="{Binding LimitAmount, StringFormat='$0'}" FontSize="11"
Foreground="{DynamicResource AccentRed}" />
</StackPanel>
</Panel>
</Grid>
<ProgressBar Classes.green="{Binding IsOnTrack}"
Classes.yellow="{Binding IsWarning}"
Classes.red="{Binding IsOverBudget}"
Minimum="0"
Value="{Binding Spent}"
Maximum="{Binding LimitAmount}"
Height="5" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Panel Grid.Column="1">
<StackPanel Orientation="Horizontal" IsVisible="{Binding !IsOverBudget}">
<TextBlock Text="{Binding SpentFormatted}"
FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text=" / " FontSize="11" Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="{Binding LimitFormatted}"
FontSize="11" Foreground="{DynamicResource TextMuted}" />
</StackPanel>
<StackPanel Orientation="Horizontal" IsVisible="{Binding IsOverBudget}">
<TextBlock Text="{Binding SpentFormatted}"
FontSize="11" Foreground="{DynamicResource AccentRed}" />
<TextBlock Text=" / " FontSize="11" Foreground="{DynamicResource AccentRed}" />
<TextBlock Text="{Binding LimitFormatted}"
FontSize="11" Foreground="{DynamicResource AccentRed}" />
</StackPanel>
</Panel>
</Grid>
<ProgressBar Classes.green="{Binding IsOnTrack}"
Classes.yellow="{Binding IsWarning}"
Classes.red="{Binding IsOverBudget}"
Minimum="0" Value="{Binding Spent}"
Maximum="{Binding LimitAmount}" Height="5" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Empty state -->
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Spacing="10" Margin="0,20"
IsVisible="{Binding !HasBudgetData}">
<Svg Path="../Assets/Icons/wallet.svg" Css="{DynamicResource SvgDisabled}"
Height="32" Width="32" HorizontalAlignment="Center" />
<TextBlock Text="No budgets set"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" />
<Button Classes="accented"
HorizontalAlignment="Center"
Padding="20,8"
CornerRadius="10"
Command="{Binding OpenAddBudgetCommand}">
<TextBlock Text="Create Budget"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" />
</Button>
</StackPanel>
</Panel>
</StackPanel>
</Border>
<!-- ── Accounts summary ──────────────── -->
<!-- Accounts summary -->
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
@@ -402,8 +476,7 @@
<StackPanel Spacing="14">
<StackPanel>
<TextBlock Text="Accounts"
FontSize="14"
FontWeight="SemiBold"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<TextBlock Classes="muted" Text="{Binding AccountsSubtitle}" FontSize="11" />
</StackPanel>
@@ -423,8 +496,7 @@
Background="{DynamicResource BgBase}"
Margin="12,10">
<Border Grid.Column="0"
CornerRadius="9"
Width="34" Height="34"
CornerRadius="9" Width="34" Height="34"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
@@ -437,17 +509,14 @@
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}"
FontSize="13"
FontWeight="SemiBold"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondary}" />
<TextBlock Text="{Binding Mask, Converter={StaticResource MaskToStringConverter}}"
FontSize="11"
Classes="muted" />
FontSize="11" Classes="muted" />
</StackPanel>
<TextBlock Grid.Column="2"
Text="{Binding CurrentBalance, StringFormat='$0'}"
FontSize="13"
FontWeight="Bold"
Text="{Binding CurrentBalanceFormatted}"
FontSize="13" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
</Grid>
@@ -459,17 +528,20 @@
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Total Balance"
FontSize="13"
FontWeight="SemiBold"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{Binding TotalNetworth, StringFormat=$0}"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
FontSize="17" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1:N2}">
<Binding Path="PrimarySymbol" />
<Binding Path="TotalNetworth" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
</StackPanel>
</Border>
@@ -477,4 +549,4 @@
</ScrollViewer>
</Grid>
</UserControl>
</UserControl>

View File

@@ -0,0 +1,454 @@
<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.MobileViews.DeleteAccountDialogViewMobile"
x:DataType="vm:DeleteAccountDialogViewModel"
Classes="mobile">
<Design.DataContext>
<vm:DeleteAccountDialogViewModel />
</Design.DataContext>
<Grid>
<Border Background="#70000000" />
<!-- STEP 1 — Simple confirm (no transactions) -->
<Border IsVisible="{Binding IsSimpleConfirmStep}"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,40">
<StackPanel Spacing="0">
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,20" />
<Border Background="{DynamicResource IconBgRed}"
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="{DynamicResource SvgRed}" />
</Border>
<TextBlock Text="Delete Account"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,12" />
<!-- Account badge -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
HorizontalAlignment="Center"
Margin="0,0,0,14">
<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>
<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" />
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="danger"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ConfirmDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="Delete Account" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
<!-- STEP 1B — Has transactions warning -->
<Border IsVisible="{Binding IsHasTransactionsStep}"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentYellow}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,40">
<StackPanel Spacing="0">
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,20" />
<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="{DynamicResource SvgYellow}" />
</Border>
<TextBlock Text="Account Has Transactions"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,12" />
<!-- Account badge -->
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
HorizontalAlignment="Center"
Margin="0,0,0,14">
<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>
<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." />
</TextBlock>
<UniformGrid Rows="1">
<Button Classes="base"
Margin="0,0,6,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,13"
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="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
<!-- STEP 2 — Pick target account + confirm -->
<Border IsVisible="{Binding IsMigrateStep}"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,40">
<StackPanel Spacing="0">
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,20" />
<!-- Header -->
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<Button Grid.Column="0"
Background="Transparent"
BorderThickness="0"
Padding="4"
VerticalAlignment="Center"
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"
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,*">
<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>
<Svg Grid.Column="1"
Path="../Assets/Icons/arrow-right.svg"
Width="18" Height="18"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="8,0" />
<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,16">
<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,13"
FontSize="14"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
<!-- Warning info -->
<Border Background="{DynamicResource BadgeBgYellow}"
BorderBrush="{DynamicResource AccentYellow}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="8">
<Svg Grid.Column="0"
Path="../Assets/Icons/info.svg"
Width="13" Height="13"
Css="{DynamicResource SvgYellow}"
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="{DynamicResource SvgRed}" />
<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,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="danger"
Margin="6,0,0,0"
Padding="0,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding CanMigrateAndDelete}"
Command="{Binding MigrateAndDeleteCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/trash-2.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="Migrate &amp; Delete" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

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

View File

@@ -3,7 +3,6 @@
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:mobileViews="clr-namespace:Clario.MobileViews"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.MobileViews.MainViewMobile"
@@ -19,11 +18,23 @@
DataContext="{Binding TransactionFormViewModel}"
IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}">
</mobileViews:TransactionFormViewMobile>
<!-- ── Content area ──────────────────────── -->
<mobileViews:AccountFormViewMobile Grid.Row="0" Grid.RowSpan="2" ZIndex="3"
DataContext="{Binding AccountFormViewModel}"
IsVisible="{Binding DataContext.IsAccountFormVisible, ElementName=MainControl}" />
<mobileViews:BudgetFormViewMobile Grid.Row="0" Grid.RowSpan="2" ZIndex="3"
DataContext="{Binding BudgetFormViewModel}"
IsVisible="{Binding DataContext.IsBudgetFormVisible, ElementName=MainControl}" />
<mobileViews:CategoryFormViewMobile Grid.Row="0" Grid.RowSpan="2" ZIndex="3"
DataContext="{Binding CategoryFormViewModel}"
IsVisible="{Binding DataContext.IsCategoryFormVisible, ElementName=MainControl}" />
<mobileViews:SetSavingsGoalDialogViewMobile Grid.Row="0" Grid.RowSpan="2" ZIndex="3"
DataContext="{Binding SetSavingsGoalDialogViewModel}"
IsVisible="{Binding DataContext.IsSavingsGoalDialogVisible, ElementName=MainControl}" />
<!-- Content area -->
<ContentControl Grid.Row="0"
Content="{Binding CurrentView}" />
<!-- ── Bottom tab bar ────────────────────── -->
<!-- Bottom tab bar -->
<Border Grid.Row="1"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
@@ -98,19 +109,19 @@
</StackPanel>
</Button>
<!-- Budget -->
<!-- More -->
<Button Grid.Column="4"
Classes="nav"
Classes.active="{Binding isOnBudget}"
Command="{Binding GoToBudgetCommand}"
Classes.active="{Binding isOnMore}"
Command="{Binding GoToMoreCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,6">
<StackPanel Spacing="4" HorizontalAlignment="Center">
<Svg Path="../Assets/Icons/wallet.svg"
<Svg Path="../Assets/Icons/ellipsis.svg"
Width="22" Height="22"
HorizontalAlignment="Center" />
<TextBlock Text="Budget"
<TextBlock Text="More"
FontSize="10"
HorizontalAlignment="Center" />
</StackPanel>

View File

@@ -1,4 +1,8 @@
using Avalonia.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Clario.ViewModels;
namespace Clario.MobileViews;
@@ -8,4 +12,26 @@ public partial class MainViewMobile : UserControl
{
InitializeComponent();
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
topLevel.BackRequested += OnBackRequested;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
topLevel.BackRequested -= OnBackRequested;
}
private void OnBackRequested(object? sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
e.Handled = vm.HandleBackNavigation();
}
}

View File

@@ -0,0 +1,147 @@
<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="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.MoreViewMobile"
x:DataType="vm:MoreViewModel"
Classes="mobile">
<Design.DataContext>
<vm:MoreViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*"
Background="{DynamicResource BgBase}">
<!-- Top bar -->
<StackPanel Grid.Row="0" Margin="16,16,16,12">
<TextBlock Text="More"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
</StackPanel>
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,24" Spacing="0">
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1,0,1,0"
CornerRadius="14"
ClipToBounds="True">
<StackPanel Spacing="0">
<!-- Analytics -->
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,1">
<Button Classes="nav"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Padding="16,0"
MinHeight="58"
Command="{Binding GoToAnalyticsCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
Background="{DynamicResource IconBgPurple}"
CornerRadius="10"
Width="36" Height="36"
Margin="0,0,14,0">
<Svg Path="../Assets/Icons/chart-no-axes-combined.svg"
Width="16" Height="16"
Css="{DynamicResource SvgPurple}" />
</Border>
<TextBlock Grid.Column="1"
Text="Analytics"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Svg Grid.Column="2"
Path="../Assets/Icons/chevron-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center" />
</Grid>
</Button>
</Border>
<!-- Budget -->
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Button Classes="nav"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Padding="16,0"
MinHeight="58"
Command="{Binding GoToBudgetCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
Background="{DynamicResource IconBgGreen}"
CornerRadius="10"
Width="36" Height="36"
Margin="0,0,14,0">
<Svg Path="../Assets/Icons/wallet.svg"
Width="16" Height="16"
Css="{DynamicResource SvgGreen}" />
</Border>
<TextBlock Grid.Column="1"
Text="Budget"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Svg Grid.Column="2"
Path="../Assets/Icons/chevron-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center" />
</Grid>
</Button>
</Border>
<!-- Categories -->
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Button Classes="nav"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Padding="16,0"
MinHeight="58"
Command="{Binding GoToCategoriesCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0"
Background="{DynamicResource IconBgBlue}"
CornerRadius="10"
Width="36" Height="36"
Margin="0,0,14,0">
<Svg Path="../Assets/Icons/list.svg"
Width="16" Height="16"
Css="{DynamicResource SvgBlue}" />
</Border>
<TextBlock Grid.Column="1"
Text="Categories"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
<Svg Grid.Column="2"
Path="../Assets/Icons/chevron-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center" />
</Grid>
</Button>
</Border>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,238 @@
<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="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.ResetPasswordViewMobile"
x:DataType="vm:ResetPasswordViewModel"
Classes="mobile"
Background="{DynamicResource BgBase}">
<Design.DataContext>
<vm:ResetPasswordViewModel />
</Design.DataContext>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="24,48,24,48" Spacing="0">
<!-- Logo -->
<StackPanel HorizontalAlignment="Center" Spacing="6" Margin="0,0,0,36">
<Border CornerRadius="16"
Height="80"
Width="80"
HorizontalAlignment="Center"
Margin="0,0,0,8">
<Image Source="{DynamicResource LogoCombinedPrimaryTransparent2x}" />
</Border>
<TextBlock Text="Your personal finance tracker"
FontSize="13"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center" />
</StackPanel>
<!-- Title -->
<TextBlock Text="Set new password"
FontSize="22" FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,8" />
<TextBlock Text="Enter and confirm your new password below."
FontSize="13"
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Center"
TextWrapping="Wrap"
Margin="0,0,0,32" />
<!-- SUCCESS STATE -->
<StackPanel IsVisible="{Binding PasswordUpdated}" Spacing="16">
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="12"
Padding="14,12">
<StackPanel Orientation="Horizontal" Spacing="10">
<Svg Path="../Assets/Icons/circle-check.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #2ECC8A; }" />
<TextBlock Text="Password updated successfully."
FontSize="13"
Foreground="{DynamicResource AccentGreen}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,14"
Command="{Binding GoToSignInCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/log-in.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Sign In"
FontSize="15" FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- FORM STATE -->
<StackPanel IsVisible="{Binding !PasswordUpdated}" Spacing="0">
<!-- New Password -->
<TextBlock Text="NEW PASSWORD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*,Auto">
<Svg Grid.Column="0"
Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Watermark="At least 6 characters"
Text="{Binding NewPassword}"
PasswordChar="●"
RevealPassword="{Binding #showNew.IsChecked}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
<ToggleButton Grid.Column="2"
Name="showNew"
Background="Transparent"
BorderThickness="0"
Height="48"
Padding="12,0"
VerticalAlignment="Center">
<ToggleButton.Styles>
<Style Selector="ToggleButton:checked /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ToggleButton.Styles>
<Panel>
<Svg Path="../Assets/Icons/eye.svg"
Width="16" Height="16"
IsVisible="{Binding #showNew.IsChecked}"
Css="{DynamicResource SvgMuted}" />
<Svg Path="../Assets/Icons/eye-closed.svg"
Width="16" Height="16"
IsVisible="{Binding !#showNew.IsChecked}"
Css="{DynamicResource SvgMuted}" />
</Panel>
</ToggleButton>
</Grid>
</Border>
<!-- Confirm Password -->
<TextBlock Text="CONFIRM PASSWORD" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="0"
Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*,Auto">
<Svg Grid.Column="0"
Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Center"
Margin="14,0,10,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Watermark="Repeat your password"
Text="{Binding ConfirmPassword}"
PasswordChar="●"
RevealPassword="{Binding #showConfirm.IsChecked}"
FontSize="14"
Height="48"
Padding="0"
VerticalContentAlignment="Center" />
<ToggleButton Grid.Column="2"
Name="showConfirm"
Background="Transparent"
BorderThickness="0"
Height="48"
Padding="12,0"
VerticalAlignment="Center">
<ToggleButton.Styles>
<Style Selector="ToggleButton:checked /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
</ToggleButton.Styles>
<Panel>
<Svg Path="../Assets/Icons/eye.svg"
Width="16" Height="16"
IsVisible="{Binding #showConfirm.IsChecked}"
Css="{DynamicResource SvgMuted}" />
<Svg Path="../Assets/Icons/eye-closed.svg"
Width="16" Height="16"
IsVisible="{Binding !#showConfirm.IsChecked}"
Css="{DynamicResource SvgMuted}" />
</Panel>
</ToggleButton>
</Grid>
</Border>
<!-- Error banner -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,20"
IsVisible="{Binding HasError}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/circle-alert.svg"
Width="14" Height="14"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #FF5E5E; }" />
<TextBlock Text="{Binding ErrorMessage}"
FontSize="13"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Update password button -->
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,14"
Margin="0,0,0,24"
Command="{Binding SetNewPasswordCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/lock.svg"
Width="16" Height="16"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #0D0F14; }" />
<TextBlock Text="Update Password"
FontSize="15" FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- Footer -->
<Separator Margin="0,0,0,16" />
<TextBlock Text="Your data is encrypted and synced securely."
FontSize="12"
Foreground="{DynamicResource TextDisabled}"
HorizontalAlignment="Center" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

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

View File

@@ -0,0 +1,150 @@
<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:behaviors="clr-namespace:Clario.Behaviors"
mc:Ignorable="d"
x:Class="Clario.MobileViews.SetSavingsGoalDialogViewMobile"
x:DataType="vm:SetSavingsGoalDialogViewModel"
Classes="mobile">
<Design.DataContext>
<vm:SetSavingsGoalDialogViewModel />
</Design.DataContext>
<Grid>
<Border Background="#70000000" />
<!-- Bottom sheet -->
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource AccentBlue}"
BorderThickness="0,1,0,0"
CornerRadius="18,18,0,0"
Padding="20,20,20,40">
<StackPanel Spacing="0">
<!-- Handle bar -->
<Border Width="36" Height="4"
CornerRadius="2"
Background="{DynamicResource BorderSubtle}"
HorizontalAlignment="Center"
Margin="0,0,0,20" />
<!-- Icon -->
<Border Background="{DynamicResource IconBgBlue}"
CornerRadius="14"
Width="54" Height="54"
HorizontalAlignment="Center"
Margin="0,0,0,16">
<Svg Path="../Assets/Icons/target.svg"
Width="22" Height="22"
Css="{DynamicResource SvgBlue}" />
</Border>
<!-- Title -->
<TextBlock Text="Set Savings Goal"
FontSize="17"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center"
Margin="0,0,0,4" />
<!-- Subtitle -->
<TextBlock Text="Set a monthly savings target to track your progress on the budget page."
FontSize="12"
Foreground="{DynamicResource TextMuted}"
TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Margin="0,0,0,24" />
<!-- Amount input -->
<TextBlock Text="MONTHLY GOAL" Classes="label" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Margin="0,0,0,6">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0"
Text="$"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource TextMuted}"
VerticalAlignment="Center"
Margin="16,0,0,0" />
<TextBox Grid.Column="1"
Classes="ghost"
Text="{Binding GoalInput, Mode=TwoWay}"
Watermark="0.00"
FontSize="16"
FontWeight="SemiBold"
Padding="8,14">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Grid>
</Border>
<TextBlock Text="Set to 0 to remove the goal"
FontSize="11"
Foreground="{DynamicResource TextDisabled}"
Margin="0,0,0,20" />
<!-- 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="{DynamicResource SvgRed}" />
<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,13"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="14"
Content="Cancel"
Command="{Binding CancelCommand}" />
<Button Classes="accented"
Margin="6,0,0,0"
Padding="0,13"
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="{DynamicResource SvgBase}" />
<TextBlock Text="Save Goal"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</UniformGrid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

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

View File

@@ -0,0 +1,486 @@
<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="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.SettingsViewMobile"
x:DataType="vm:SettingsViewModel"
Classes="mobile"
Background="{DynamicResource BgBase}">
<Design.DataContext>
<vm:SettingsViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
Margin="16,16,16,12">
<StackPanel Grid.Column="0">
<TextBlock Text="Settings"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
</StackPanel>
</Grid>
<!-- Scrollable content -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,32" Spacing="0">
<!-- Global success / error banners -->
<Border Background="{DynamicResource IconBgGreen}"
BorderBrush="{DynamicResource AccentGreen}"
BorderThickness="1"
CornerRadius="12"
Padding="14,10"
Margin="0,0,0,12"
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,12"
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="14"
Padding="16"
Margin="0,0,0,20">
<StackPanel Spacing="16">
<!-- Avatar row -->
<Grid ColumnDefinitions="Auto,*">
<Panel Grid.Column="0"
Width="64" Height="64"
Margin="0,0,16,0">
<Border CornerRadius="32"
ClipToBounds="True"
Width="64" Height="64"
IsVisible="{Binding HasAvatar}">
<Image Source="{Binding AvatarImage}" Stretch="UniformToFill" />
</Border>
<Border CornerRadius="32"
Width="64" Height="64"
Background="{DynamicResource BorderAccent}"
IsVisible="{Binding !HasAvatar}">
<TextBlock Text="{Binding DisplayName[0]}"
FontSize="22"
FontWeight="Bold"
Foreground="{DynamicResource AccentBlue}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Border CornerRadius="32"
Width="64" Height="64"
Background="#80000000"
IsVisible="{Binding IsUploadingAvatar}">
<TextBlock Text="..."
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Panel>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="8">
<Button Classes="base"
Padding="14,8"
IsEnabled="{Binding !IsUploadingAvatar}"
Command="{Binding UploadAvatarCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Svg Path="../Assets/Icons/upload.svg"
Width="13" Height="13"
Css="{DynamicResource SvgMuted}" />
<TextBlock Text="Upload Photo" FontSize="13" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Background="Transparent"
BorderThickness="0"
Padding="0"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
IsVisible="{Binding HasAvatar}"
IsEnabled="{Binding !IsUploadingAvatar}"
Command="{Binding RemoveAvatarCommand}"
Content="Remove photo" />
</StackPanel>
</Grid>
<Separator />
<!-- Display name -->
<StackPanel Spacing="6">
<TextBlock Text="DISPLAY NAME" Classes="label" />
<TextBox Text="{Binding DisplayName, Mode=TwoWay}"
Watermark="Your name"
FontSize="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<!-- Theme -->
<StackPanel Spacing="6">
<TextBlock Text="THEME" Classes="label" />
<ComboBox ItemsSource="{Binding ThemeLabels}"
SelectedIndex="{Binding SelectedThemeIndex, Mode=TwoWay}"
HorizontalAlignment="Stretch"
Padding="12,10"
FontSize="14" />
</StackPanel>
<!-- Language -->
<StackPanel Spacing="6">
<TextBlock Text="LANGUAGE" Classes="label" />
<ComboBox ItemsSource="{Binding LanguageLabels}"
SelectedIndex="{Binding SelectedLanguageIndex, Mode=TwoWay}"
HorizontalAlignment="Stretch"
Padding="12,10"
FontSize="14" />
</StackPanel>
<Separator />
<!-- Save button -->
<Button Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,12"
IsEnabled="{Binding !IsSaving}"
Command="{Binding SaveProfileCommand}">
<TextBlock Text="{Binding IsSaving, Converter={StaticResource BoolToStringConverter}, ConverterParameter='Saving...|Save Changes'}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</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="14"
Padding="0"
Margin="0,0,0,20">
<StackPanel Spacing="0">
<!-- Email row -->
<Border BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,0,0,1"
Padding="16,0">
<Panel>
<!-- Display row -->
<Grid IsVisible="{Binding !IsChangingEmail}"
ColumnDefinitions="*,Auto"
MinHeight="56">
<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"
VerticalAlignment="Center"
Command="{Binding StartChangeEmailCommand}">
<StackPanel Orientation="Horizontal" Spacing="4">
<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" />
<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>
<StackPanel Spacing="6">
<TextBlock Text="NEW EMAIL" Classes="label" />
<TextBox Text="{Binding NewEmail, Mode=TwoWay}"
Watermark="new@email.com"
FontSize="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="CONFIRM WITH PASSWORD" Classes="label" />
<TextBox Text="{Binding EmailConfirmPassword, Mode=TwoWay}"
Watermark="Current password"
PasswordChar="•"
FontSize="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
<Button Grid.Column="0"
Classes="base"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,11"
FontSize="13"
Content="Cancel"
Command="{Binding CancelChangeEmailCommand}" />
<Button Grid.Column="1"
Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,11"
IsEnabled="{Binding !IsSaving}"
Command="{Binding ConfirmChangeEmailCommand}">
<TextBlock Text="Update Email"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" />
</Button>
</Grid>
</StackPanel>
</Panel>
</Border>
<!-- Password row -->
<Border Padding="16,0">
<Panel>
<!-- Display row -->
<Grid IsVisible="{Binding !IsChangingPassword}"
ColumnDefinitions="*,Auto"
MinHeight="56">
<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"
VerticalAlignment="Center"
Command="{Binding StartChangePasswordCommand}">
<StackPanel Orientation="Horizontal" Spacing="4">
<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" />
<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="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="NEW PASSWORD" Classes="label" />
<TextBox Text="{Binding NewPassword, Mode=TwoWay}"
Watermark="Min. 8 characters"
PasswordChar="•"
FontSize="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="CONFIRM NEW PASSWORD" Classes="label" />
<TextBox Text="{Binding ConfirmNewPassword, Mode=TwoWay}"
Watermark="Repeat new password"
PasswordChar="•"
FontSize="14"
Height="44"
Padding="12,0"
VerticalContentAlignment="Center" />
</StackPanel>
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
<Button Grid.Column="0"
Classes="base"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,11"
FontSize="13"
Content="Cancel"
Command="{Binding CancelChangePasswordCommand}" />
<Button Grid.Column="1"
Classes="accented"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="0,11"
IsEnabled="{Binding !IsSaving}"
Command="{Binding ConfirmChangePasswordCommand}">
<TextBlock Text="Update Password"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource BgBase}" />
</Button>
</Grid>
</StackPanel>
</Panel>
</Border>
</StackPanel>
</Border>
<!--
SECTION: Session
-->
<TextBlock Text="SESSION" Classes="label" Margin="0,0,0,10" />
<Border Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="14"
Padding="16">
<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"
Classes="danger"
Padding="14,10"
VerticalAlignment="Center"
Command="{Binding SignOutCommand}">
<StackPanel Orientation="Horizontal" Spacing="7">
<Svg Path="../Assets/Icons/log-out.svg" Width="13" Height="13" Css="{DynamicResource SvgRed}" />
<TextBlock Text="Sign Out" FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

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

View File

@@ -4,6 +4,7 @@
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" d:DesignWidth="400" d:DesignHeight="800"
x:Class="Clario.MobileViews.TransactionFormViewMobile"
x:DataType="vm:TransactionFormViewModel"
@@ -15,7 +16,7 @@
<Grid RowDefinitions="Auto,*,Auto"
Background="{DynamicResource BgBase}">
<!-- ── Top bar ────────────────────────────── -->
<!-- Top bar -->
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
Margin="16,16,16,0">
@@ -53,8 +54,8 @@
<!-- Delete (edit mode only) -->
<Button Grid.Column="2"
Background="#1A0808"
BorderBrush="#3A1515"
Background="{DynamicResource DangerButtonBackground}"
BorderBrush="{DynamicResource DangerButtonBorder}"
BorderThickness="1"
CornerRadius="20"
Width="36" Height="36"
@@ -74,7 +75,7 @@
</Grid>
<!-- ── Scrollable form ────────────────────── -->
<!-- Scrollable form -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
@@ -88,7 +89,7 @@
Padding="3"
Margin="0,0,0,20"
Height="50">
<Grid ColumnDefinitions="*,*">
<Grid ColumnDefinitions="*,*,*">
<!-- Expense -->
<Button Grid.Column="0"
Classes="nav"
@@ -101,11 +102,11 @@
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="expense">
<StackPanel Orientation="Horizontal" Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="5">
<Svg Path="../Assets/Icons/arrow-up-right.svg"
Width="16" Height="16" />
Width="14" Height="14" />
<TextBlock Text="Expense"
FontSize="16"
FontSize="14"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
@@ -122,18 +123,38 @@
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="income">
<StackPanel Orientation="Horizontal" Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="5">
<Svg Path="../Assets/Icons/arrow-down-left.svg"
Width="16" Height="16" />
Width="14" Height="14" />
<TextBlock Text="Income"
FontSize="16"
FontSize="14"
VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- Transfer -->
<Button Grid.Column="2"
Classes="nav"
Classes.accented="{Binding IsTransfer}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalAlignment="Stretch"
CornerRadius="7"
Padding="0,8"
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="transfer">
<StackPanel Orientation="Horizontal" Spacing="5">
<Svg Path="../Assets/Icons/arrow-right-left.svg"
Width="14" Height="14" />
<TextBlock Text="Transfer"
FontSize="14"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
<!-- ── Amount ──────────────────────── -->
<!-- Amount -->
<TextBlock Text="AMOUNT" Classes="label" FontSize="14" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
@@ -144,7 +165,7 @@
Height="64">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0"
Text="$"
Text="{Binding CurrencySymbol}"
FontSize="32"
FontWeight="Bold"
Foreground="{DynamicResource TextMuted}"
@@ -161,6 +182,9 @@
Height="54"
Padding="0 0 0 2"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
<TextBlock Grid.Column="2"
Text="{Binding Currency}"
@@ -171,18 +195,21 @@
</Grid>
</Border>
<!-- ── Description ─────────────────── -->
<TextBlock Text="DESCRIPTION" Classes="label" FontSize="14" Margin="0,0,0,6" />
<!-- Description (hidden for transfers) -->
<TextBlock Text="DESCRIPTION" Classes="label" FontSize="14" Margin="0,0,0,6"
IsVisible="{Binding !IsTransfer}" />
<TextBox Text="{Binding Description, Mode=TwoWay}"
Watermark="e.g. Grocery Shopping"
FontSize="16"
Height="48"
Padding="12,0"
VerticalContentAlignment="Center"
Margin="0,0,0,16" />
Margin="0,0,0,16"
IsVisible="{Binding !IsTransfer}" />
<!-- ── Category + Account ──────────── -->
<Grid ColumnDefinitions="*,14,*" Margin="0,0,0,16">
<!-- Category + Account (income/expense) -->
<Grid ColumnDefinitions="*,14,*" Margin="0,0,0,16"
IsVisible="{Binding !IsTransfer}">
<!-- Category -->
<StackPanel Grid.Column="0" Spacing="6">
@@ -260,7 +287,144 @@
</Grid>
<!-- ── Date ────────────────────────── -->
<!-- From + To accounts (transfer) -->
<Grid ColumnDefinitions="*,Auto,*" Margin="0,0,0,16"
IsVisible="{Binding IsTransfer}">
<!-- From Account -->
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Text="FROM ACCOUNT" Classes="label" FontSize="12" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<Grid ColumnDefinitions="Auto,*" Height="42">
<Border Grid.Column="0"
CornerRadius="7"
Width="30" Height="30"
Margin="6,0,0,0"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush
Color="{Binding SelectedAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedAccount.Icon, Converter={StaticResource SvgPathFromName}}"
Width="13" Height="13"
Css="{Binding SelectedAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<ComboBox Grid.Column="1"
ItemsSource="{Binding Accounts}"
SelectedItem="{Binding SelectedAccount, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
Background="Transparent"
BorderThickness="0"
Padding="6,8"
FontSize="12"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
</StackPanel>
<!-- Arrow -->
<Svg Grid.Column="1"
Path="../Assets/Icons/arrow-right.svg"
Width="14" Height="14"
Css="{DynamicResource SvgMuted}"
VerticalAlignment="Bottom"
Margin="6,0,6,12" />
<!-- To Account -->
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Text="TO ACCOUNT" Classes="label" FontSize="12" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}">
<Grid ColumnDefinitions="Auto,*" Height="42">
<Border Grid.Column="0"
CornerRadius="7"
Width="30" Height="30"
Margin="6,0,0,0"
VerticalAlignment="Center">
<Border.Background>
<SolidColorBrush
Color="{Binding SelectedToAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding SelectedToAccount.Icon, Converter={StaticResource SvgPathFromName}}"
Width="13" Height="13"
Css="{Binding SelectedToAccount.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<ComboBox Grid.Column="1"
ItemsSource="{Binding Accounts}"
SelectedItem="{Binding SelectedToAccount, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
Background="Transparent"
BorderThickness="0"
Padding="6,8"
FontSize="12"
HorizontalAlignment="Stretch" />
</Grid>
</Border>
</StackPanel>
</Grid>
<!-- Exchange Rate -->
<Border IsVisible="{Binding ShowExchangeRateField}"
Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="14,10"
Margin="0,0,0,16">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="EXCHANGE RATE"
Classes="label"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding IsFetchingRate}"
VerticalAlignment="Center">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource AccentBlue}"
Opacity="0.7" />
<TextBlock Text="Fetching rate..."
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding ExchangeRateLabel}"
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
<Border IsVisible="{Binding IsFetchingRate}"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Height="36"
Opacity="0.5" />
<Border IsVisible="{Binding !IsFetchingRate}"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="1"
CornerRadius="{DynamicResource RadiusControl}"
Padding="12,0">
<TextBox Classes="ghost"
Text="{Binding ExchangeRate, Mode=TwoWay}"
Watermark="0.00000"
FontSize="13"
Height="36"
Padding="0"
VerticalContentAlignment="Center">
<Interaction.Behaviors>
<behaviors:NumericInputBehavior />
</Interaction.Behaviors>
</TextBox>
</Border>
</StackPanel>
</Border>
<!-- Date -->
<TextBlock Text="DATE" Classes="label" FontSize="14" Margin="0,0,0,6" />
<Border Background="{DynamicResource BgBase}"
BorderBrush="{DynamicResource BorderSubtle}"
@@ -292,7 +456,7 @@
</Grid>
</Border>
<!-- ── Note ────────────────────────── -->
<!-- Note -->
<TextBlock Text="NOTE (OPTIONAL)" Classes="label" FontSize="14" Margin="0,0,0,6" />
<TextBox Text="{Binding Note, Mode=TwoWay}"
Watermark="Add a note..."
@@ -302,7 +466,47 @@
VerticalContentAlignment="Center"
Margin="0,0,0,8" />
<!-- ── Validation error ─────────────── -->
<!-- Budget approaching warning -->
<Border Background="{DynamicResource BadgeBgYellow}"
BorderBrush="{DynamicResource AccentYellow}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,8,0,8"
IsVisible="{Binding HasBudgetApproachingWarning}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Svg Path="../Assets/Icons/triangle-alert.svg"
Width="13" Height="13"
Css="path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #F5C842; }" />
<TextBlock Text="{Binding BudgetWarningMessage}"
FontSize="12"
Foreground="{DynamicResource AccentYellow}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Budget over-limit warning -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
CornerRadius="10"
Padding="12,8"
Margin="0,8,0,8"
IsVisible="{Binding BudgetWarningIsOverBudget}">
<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 BudgetWarningMessage}"
FontSize="12"
Foreground="{DynamicResource AccentRed}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Validation error -->
<Border Background="{DynamicResource BadgeBgRed}"
BorderBrush="{DynamicResource AccentRed}"
BorderThickness="1"
@@ -324,7 +528,7 @@
</StackPanel>
</ScrollViewer>
<!-- ── Bottom action bar ──────────────────── -->
<!-- Bottom action bar -->
<Border Grid.Row="2"
Background="{DynamicResource BgSurface}"
BorderBrush="{DynamicResource BorderSubtle}"
@@ -370,12 +574,11 @@
BorderThickness="1"
CornerRadius="18"
Padding="28"
Width="340"
BoxShadow="0 24 72 0 #60000000">
Width="340">
<StackPanel Spacing="0">
<!-- Icon -->
<Border Background="#2A0D0D"
<Border Background="{DynamicResource IconBgRed}"
CornerRadius="14"
Width="52" Height="52"
HorizontalAlignment="Center"
@@ -416,7 +619,7 @@
Padding="0,11"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="#FF5E5E"
Background="{DynamicResource AccentRed}"
BorderThickness="0"
CornerRadius="{DynamicResource RadiusControl}"
Command="{Binding ConfirmDeleteCommand}">
@@ -427,7 +630,7 @@
<TextBlock Text="Delete"
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFFF"
Foreground="{DynamicResource BgBase}"
VerticalAlignment="Center" />
</StackPanel>
</Button>

View File

@@ -7,11 +7,15 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.MobileViews.TransactionsViewMobile"
x:DataType="vm:TransactionsViewModel"
x:Name="transactionsRoot"
Classes="mobile">
<Design.DataContext>
<vm:TransactionsViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,Auto,*,Auto"
Background="{DynamicResource BgBase}">
<!-- ── Top Bar ───────────────────────────── -->
<!-- Top Bar -->
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
Margin="16,16,16,0">
@@ -37,8 +41,7 @@
BorderThickness="1"
CornerRadius="16"
Padding="20"
Width="300"
BoxShadow="0 8 32 0 #3C000000">
Width="300">
<StackPanel Spacing="0">
<TextBlock Text="FILTERS"
@@ -71,7 +74,7 @@
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,14">
<Grid ColumnDefinitions="*,*,*">
<Grid ColumnDefinitions="*,*,*,*">
<Button Grid.Column="0"
Classes="nav"
Classes.accented="{Binding FilterTypeAll}"
@@ -108,6 +111,18 @@
CommandParameter="expense">
<TextBlock Text="Expense" FontSize="12" HorizontalAlignment="Center" />
</Button>
<Button Grid.Column="3"
Classes="nav"
Classes.accented="{Binding FilterTypeTransfer}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Focusable="False"
CornerRadius="7"
Padding="0,6"
Command="{Binding SetTransactionTypeCommand}"
CommandParameter="transfer">
<TextBlock Text="Transfer" FontSize="12" HorizontalAlignment="Center" />
</Button>
</Grid>
</Border>
@@ -157,7 +172,8 @@
Padding="0,10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ApplyFiltersCommand}">
Command="{Binding LoadPageStrCommand}"
CommandParameter="1">
<TextBlock Text="Apply"
FontSize="13"
FontWeight="SemiBold"
@@ -176,7 +192,7 @@
</Button>
</Grid>
<!-- ── Summary strip ─────────────────────── -->
<!-- Summary strip -->
<Grid Grid.Row="1"
ColumnDefinitions="*,*,*"
Margin="16,12,16,0">
@@ -259,13 +275,13 @@
</Grid>
<!-- ── Transaction list ──────────────────── -->
<!-- Transaction list -->
<ScrollViewer Grid.Row="2"
Margin="0,12,0,0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<ItemsControl ItemsSource="{Binding FilteredTransactions}">
<ItemsControl ItemsSource="{Binding PagedTransactions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
@@ -282,74 +298,119 @@
Classes="label" />
</Border>
<!-- Transaction row -->
<Border Padding="16,12"
IsVisible="{Binding !GroupHeader}"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Transaction row (tappable to edit) -->
<Button IsVisible="{Binding !GroupHeader}"
Padding="0"
Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Focusable="False"
Command="{Binding DataContext.EditTransactionCommand, ElementName=transactionsRoot}"
CommandParameter="{Binding .}">
<Border Padding="16,12"
BorderBrush="{DynamicResource BorderSubtle}"
BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="{DynamicResource RadiusIcon}"
Width="38" Height="38"
VerticalAlignment="Center"
Margin="0,0,12,0">
<Border.Background>
<SolidColorBrush
Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<!-- Icon -->
<Panel Grid.Column="0"
Width="38" Height="38"
VerticalAlignment="Center"
Margin="0,0,12,0">
<!-- Category icon -->
<Border CornerRadius="{DynamicResource RadiusIcon}"
Width="38" Height="38"
IsVisible="{Binding !IsTransfer}">
<Border.Background>
<SolidColorBrush
Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}"
Opacity="0.15" />
</Border.Background>
<Svg Path="{Binding Category.Icon, Converter={StaticResource SvgPathFromName}}"
Width="17" Height="17"
Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
</Border>
<!-- Transfer icon -->
<Border CornerRadius="{DynamicResource RadiusIcon}"
Width="38" Height="38"
Background="{DynamicResource IconBgBlue}"
IsVisible="{Binding IsTransfer}">
<Svg Path="../Assets/Icons/arrow-right-left.svg"
Width="17" Height="17"
Css="{DynamicResource SvgBlue}" />
</Border>
</Panel>
<!-- Description + meta -->
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
<TextBlock Text="{Binding Description}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Category.Name}"
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
<TextBlock Text="·"
FontSize="11"
Foreground="{DynamicResource TextDisabled}" />
<TextBlock Text="{Binding Date, Converter={StaticResource DateFormatConverter}}"
<!-- Description + meta -->
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
<TextBlock Text="{Binding Description}"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<!-- Normal category name -->
<TextBlock Text="{Binding Category.Name}"
FontSize="11"
IsVisible="{Binding !IsTransfer}"
Foreground="{DynamicResource TextMuted}" />
<!-- Transfer label -->
<TextBlock Text="Transfer"
FontSize="11"
IsVisible="{Binding IsTransfer}"
Foreground="{DynamicResource AccentBlue}" />
<TextBlock Text="·"
FontSize="11"
Foreground="{DynamicResource TextDisabled}" />
<TextBlock Text="{Binding Date, Converter={StaticResource DateFormatConverter}}"
FontSize="11"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</StackPanel>
<!-- Dual-currency amount display -->
<StackPanel Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Spacing="1">
<TextBlock Text="{Binding PrimaryAmountFormatted}"
FontSize="14"
FontWeight="SemiBold"
HorizontalAlignment="Right"
Foreground="{Binding Type, Converter={StaticResource AmountColorConverter}}" />
<TextBlock Text="{Binding OriginalAmountFormatted}"
FontSize="11"
HorizontalAlignment="Right"
IsVisible="{Binding IsMultiCurrency}"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</StackPanel>
<!-- Dual-currency amount display -->
<StackPanel Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Spacing="1">
<TextBlock Text="{Binding PrimaryAmountFormatted}"
FontSize="14"
FontWeight="SemiBold"
HorizontalAlignment="Right"
Foreground="{Binding Type, Converter={StaticResource AmountColorConverter}}" />
<TextBlock Text="{Binding OriginalAmountFormatted}"
FontSize="11"
HorizontalAlignment="Right"
IsVisible="{Binding IsMultiCurrency}"
Foreground="{DynamicResource TextMuted}" />
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Button>
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Load More button -->
<Button Classes="base"
Margin="16,16,16,16"
Padding="0,12"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
FontSize="13"
IsVisible="{Binding HasNextPage}"
Command="{Binding LoadMoreCommand}">
<TextBlock Text="Load More"
FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimary}"
HorizontalAlignment="Center" />
</Button>
<!-- Empty state -->
<StackPanel HorizontalAlignment="Center"
Spacing="12"

View File

@@ -25,7 +25,7 @@ public class Budget : BaseModel
[Column("created_at")] public DateTime CreatedAt { get; set; }
// ── not in DB ──────────────────────────────────────
// not in DB
[JsonIgnore] public Category? Category { get; set; }
[JsonIgnore] public int TransactionsCount { get; set; }

View File

@@ -0,0 +1,12 @@
namespace Clario.Models;
public class CategorySpendRow
{
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Color { get; set; } = "#7B9CFF";
public decimal Amount { get; set; }
public double Percentage { get; set; }
public string AmountFormatted { get; set; } = string.Empty;
public string PercentageFormatted => $"{Percentage:F1}%";
}

View File

@@ -35,10 +35,13 @@ public class Transaction : BaseModel
[Column("exchange_rate")] public decimal? ExchangeRate { get; set; }
[Column("transfer_pair_id")] public Guid? TransferPairId { get; set; }
// Set during enrichment by GeneralDataRepo.LinkTransactionAccounts
[JsonIgnore] public string AccountCurrency { get; set; } = "";
[JsonIgnore] public string PrimaryAmountFormatted { get; set; } = "";
[JsonIgnore] public string OriginalAmountFormatted { get; set; } = "";
[JsonIgnore] public string AccountDisplayText { get; set; } = "";
[JsonIgnore] public decimal ConvertedAmount =>
!string.IsNullOrEmpty(AccountCurrency) && CurrencyService.LiveRates.TryGetValue(AccountCurrency, out var liveRate)
@@ -48,5 +51,7 @@ public class Transaction : BaseModel
[JsonIgnore] public string PrimaryAmountSignFormatted =>
Type == "expense" ? $"-{PrimaryAmountFormatted}" : $"+{PrimaryAmountFormatted}";
[JsonIgnore] public bool IsTransfer => Type is "transfer_in" or "transfer_out";
[JsonIgnore] public bool IsTransferOut => Type == "transfer_out";
[JsonIgnore] public bool GroupHeader { get; set; } = false;
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Clario.Services;
/// <summary>Resolves a named date range option into concrete start/end dates and a display label.</summary>
public static class DateRangeService
{
private static readonly CultureInfo Culture = new("en-US");
/// <param name="option">The named range key (e.g. "This Month", "Custom Range").</param>
/// <param name="customDates">Required when option is "Custom Range".</param>
/// <returns>Null Start/End means "All Time" (no filter). Label is already uppercased for display.</returns>
public static (DateTime? Start, DateTime? End, string Label) Resolve(string option, IList<DateTime>? customDates = null)
{
var now = DateTime.Now;
return option switch
{
"Today" => (now.Date, now.Date, now.ToString("MMM d, yyyy", Culture).ToUpper()),
"This Week" => ResolveThisWeek(now),
"This Month" => ResolveThisMonth(now),
"Last Month" => ResolveLastMonth(now),
"This Quarter" => ResolveThisQuarter(now),
"This Year" => (new DateTime(now.Year, 1, 1), new DateTime(now.Year, 12, 31), now.Year.ToString()),
"Custom Range" => ResolveCustomRange(customDates, now),
_ => (null, null, "ALL TIME")
};
}
private static (DateTime?, DateTime?, string) ResolveThisWeek(DateTime now)
{
var start = now.Date.AddDays(-(int)now.DayOfWeek);
return (start, start.AddDays(6), "THIS WEEK");
}
private static (DateTime?, DateTime?, string) ResolveThisMonth(DateTime now)
{
var start = new DateTime(now.Year, now.Month, 1);
return (start, start.AddMonths(1).AddDays(-1), now.ToString("MMMM yyyy", Culture).ToUpper());
}
private static (DateTime?, DateTime?, string) ResolveLastMonth(DateTime now)
{
var lm = now.AddMonths(-1);
var start = new DateTime(lm.Year, lm.Month, 1);
return (start, start.AddMonths(1).AddDays(-1), lm.ToString("MMMM yyyy", Culture).ToUpper());
}
private static (DateTime?, DateTime?, string) ResolveThisQuarter(DateTime now)
{
var quarterMonth = now.Month - ((now.Month - 1) % 3);
var start = new DateTime(now.Year, quarterMonth, 1);
var end = start.AddMonths(3).AddDays(-1);
return (start, end, $"Q{(now.Month - 1) / 3 + 1} {now.Year}");
}
private static (DateTime?, DateTime?, string) ResolveCustomRange(IList<DateTime>? dates, DateTime now)
{
if (dates is null || dates.Count == 0)
return (now.Date, now.Date, now.ToString("MMM d, yyyy", Culture).ToUpper());
var ordered = dates.Select(d => d.Date).Distinct().OrderBy(d => d).ToList();
var start = ordered.First();
var end = ordered.Last();
var label = ordered.Count == 1
? start.ToString("MMM dd, yyyy", Culture).ToUpper()
: $"{start.ToString("MMM dd", Culture)} - {end.ToString("MMM dd, yyyy", Culture)}".ToUpper();
return (start, end, label);
}
/// <summary>Formats a date as "Today - MMM dd", "Yesterday - MMM dd", or "MMM dd, yyyy".</summary>
public static string FormatGroupHeader(DateTime date)
{
var now = DateTime.Now.Date;
if (date.Date == now) return "Today — " + date.ToString("MMM dd", Culture);
if (date.Date == now.AddDays(-1)) return "Yesterday — " + date.ToString("MMM dd", Culture);
return date.ToString("MMM dd, yyyy", Culture);
}
}

View File

@@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Clario.Data;
using Clario.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Clario.Services;
public static class PdfExportService
{
// Print palette (readable on white paper)
private const string TextPrimary = "#111827";
private const string TextSecondary = "#374151";
private const string TextMuted = "#6B7280";
private const string Border = "#E5E7EB";
private const string HeaderBg = "#F3F4F6";
private const string IncomeColor = "#15803D";
private const string ExpenseColor = "#B91C1C";
private const string BlueColor = "#1D4ED8";
private const string AccentBar = "#1D4ED8"; // header rule
static PdfExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
public static async Task<string?> ExportAsync(
GeneralDataRepo data,
DateTime start,
DateTime end,
string periodLabel,
List<CategorySpendRow> topCategories)
{
var culture = new CultureInfo("en-US");
var sym = CurrencyService.GetSymbol(data.PrimaryAccount?.Currency ?? data.Profile?.Currency ?? "USD");
var userName = data.Profile?.DisplayName ?? "User";
var periodTxs = data.Transactions
.Where(t => !t.IsTransfer && t.Date.Date >= start.Date && t.Date.Date <= end.Date)
.OrderByDescending(t => t.Date)
.ToList();
var totalIncome = periodTxs.Where(t => t.Type == "income").Sum(t => t.ConvertedAmount);
var totalExpenses = periodTxs.Where(t => t.Type == "expense").Sum(t => t.ConvertedAmount);
var net = totalIncome - totalExpenses;
var savingsRate = totalIncome > 0 ? net / totalIncome * 100 : 0;
var subtitle = $"{start.ToString("MMM d, yyyy", culture)} {end.ToString("MMM d, yyyy", culture)}";
var generatedAt = DateTime.Now.ToString("MMM d, yyyy 'at' h:mm tt", culture);
// Load logo on the calling (UI) thread before entering Task.Run
byte[] logoBytes;
using (var assetStream = AssetLoader.Open(new Uri("avares://Clario/Assets/Logo/logo-combined-primary-transparent-384x128.png")))
using (var ms = new MemoryStream())
{
await assetStream.CopyToAsync(ms);
logoBytes = ms.ToArray();
}
byte[] pdfBytes = [];
await Task.Run(() =>
{
pdfBytes = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.MarginHorizontal(1.8f, Unit.Centimetre);
page.MarginVertical(1.5f, Unit.Centimetre);
page.DefaultTextStyle(s => s.FontSize(10).FontColor(TextPrimary).FontFamily("Arial"));
// Header
page.Header().Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(left =>
{
left.Item().Height(32).Image(logoBytes).FitHeight();
left.Item().PaddingTop(4).Text($"Financial Report — {periodLabel}")
.FontSize(12).SemiBold().FontColor(TextSecondary);
left.Item().Text(subtitle).FontSize(9).FontColor(TextMuted);
});
row.ConstantItem(140).AlignRight().Column(right =>
{
right.Item().AlignRight().Text(userName)
.FontSize(10).SemiBold().FontColor(TextPrimary);
right.Item().AlignRight().Text($"Generated {generatedAt}")
.FontSize(8).FontColor(TextMuted);
});
});
col.Item().PaddingTop(10).LineHorizontal(2).LineColor(AccentBar);
});
// Content
page.Content().PaddingTop(18).Column(col =>
{
// KPI cards
col.Item().Text("Summary").FontSize(11).SemiBold().FontColor(TextPrimary);
col.Item().PaddingTop(6).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn();
c.RelativeColumn();
c.RelativeColumn();
c.RelativeColumn();
});
void KpiCell(string label, string value, string valueColor)
{
table.Cell()
.Border(1).BorderColor(Border)
.Background(HeaderBg)
.Padding(12)
.Column(c =>
{
c.Item().Text(label).FontSize(8).FontColor(TextMuted);
c.Item().PaddingTop(4).Text(value)
.FontSize(14).Bold().FontColor(valueColor);
});
}
KpiCell("TOTAL INCOME", $"{sym}{totalIncome:N2}", IncomeColor);
KpiCell("TOTAL EXPENSES", $"{sym}{totalExpenses:N2}", ExpenseColor);
KpiCell("NET SAVINGS", $"{(net >= 0 ? "+" : "")}{sym}{net:N2}",
net >= 0 ? IncomeColor : ExpenseColor);
KpiCell("SAVINGS RATE", totalIncome > 0 ? $"{savingsRate:F1}%" : "—", BlueColor);
});
col.Item().Height(20);
// Top categories
if (topCategories.Count > 0)
{
col.Item().Text("Top Spending Categories")
.FontSize(11).SemiBold().FontColor(TextPrimary);
col.Item().PaddingTop(6).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(4);
c.RelativeColumn(2);
c.RelativeColumn(1);
});
// Header row — CATEGORY stays left, rest centered
void TH(string text, bool leftAlign = false)
{
var cell = table.Cell().Background(HeaderBg)
.PaddingHorizontal(8).PaddingVertical(7)
.BorderBottom(1).BorderColor(Border);
if (leftAlign)
cell.Text(text).FontSize(8).SemiBold().FontColor(TextMuted);
else
cell.AlignCenter().Text(text).FontSize(8).SemiBold().FontColor(TextMuted);
}
TH("CATEGORY", leftAlign: true); TH("AMOUNT"); TH("SHARE");
foreach (var cat in topCategories)
{
table.Cell().PaddingHorizontal(8).PaddingVertical(7)
.BorderBottom(1).BorderColor(Border)
.Text(cat.Name).FontSize(10).FontColor(TextPrimary);
table.Cell().PaddingHorizontal(8).PaddingVertical(7)
.BorderBottom(1).BorderColor(Border)
.Text(cat.AmountFormatted).FontSize(10).FontColor(TextSecondary);
table.Cell().PaddingHorizontal(8).PaddingVertical(7)
.BorderBottom(1).BorderColor(Border)
.AlignRight()
.Text(cat.PercentageFormatted).FontSize(10).FontColor(TextMuted);
}
});
col.Item().Height(20);
}
// Transactions
col.Item().Text($"Transactions ({periodTxs.Count})")
.FontSize(11).SemiBold().FontColor(TextPrimary);
col.Item().PaddingTop(6).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.ConstantColumn(60); // date
c.RelativeColumn(3); // description
c.RelativeColumn(2); // category
c.RelativeColumn(2); // account
c.RelativeColumn(2); // amount
});
// Header — DESCRIPTION stays left, all others centered
void TH(string text, bool leftAlign = false)
{
var cell = table.Cell().Background(HeaderBg)
.PaddingHorizontal(6).PaddingVertical(7)
.BorderBottom(2).BorderColor(Border);
if (leftAlign)
cell.Text(text).FontSize(8).SemiBold().FontColor(TextMuted);
else
cell.AlignCenter().Text(text).FontSize(8).SemiBold().FontColor(TextMuted);
}
TH("DATE"); TH("DESCRIPTION", leftAlign: true); TH("CATEGORY"); TH("ACCOUNT"); TH("AMOUNT");
foreach (var tx in periodTxs)
{
var amountStr = tx.Type == "income"
? $"+{sym}{tx.ConvertedAmount:N2}"
: $"-{sym}{tx.ConvertedAmount:N2}";
var amountColor = tx.Type == "income" ? IncomeColor : ExpenseColor;
void TD(string text, string color = TextPrimary, bool rightAlign = false)
{
var cell = table.Cell()
.PaddingHorizontal(6).PaddingVertical(6)
.BorderBottom(1).BorderColor(Border);
if (rightAlign)
cell.AlignRight().Text(text).FontSize(9).FontColor(color);
else
cell.Text(text).FontSize(9).FontColor(color);
}
TD(tx.Date.ToString("MMM d, yy", culture), TextMuted);
TD(tx.Description);
TD(tx.Category?.Name ?? "—", TextSecondary);
TD(tx.AccountDisplayText, TextSecondary);
TD(amountStr, amountColor, rightAlign: true);
}
});
});
// Footer
page.Footer().PaddingTop(6).BorderTop(1).BorderColor(Border).Row(row =>
{
row.RelativeItem().Text("Generated by Clario — Your personal finance tracker")
.FontSize(8).FontColor(TextMuted);
row.ConstantItem(60).AlignRight().Text(t =>
{
t.AlignRight();
t.Span("Page ").FontSize(8).FontColor(TextMuted);
t.CurrentPageNumber().FontSize(8).FontColor(TextMuted);
t.Span(" of ").FontSize(8).FontColor(TextMuted);
t.TotalPages().FontSize(8).FontColor(TextMuted);
});
});
});
}).GeneratePdf();
});
return await SavePdfAsync(pdfBytes, $"Clario_Report_{DateTime.Now:yyyy-MM-dd}.pdf");
}
private static async Task<string?> SavePdfAsync(byte[] pdfBytes, string suggestedName)
{
var topLevel = GetTopLevel();
if (topLevel is null)
{
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), suggestedName);
await File.WriteAllBytesAsync(path, pdfBytes);
return path;
}
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save PDF Report",
SuggestedFileName = suggestedName,
FileTypeChoices = [new FilePickerFileType("PDF Document") { Patterns = ["*.pdf"] }]
});
if (file is null) return null;
await using var stream = await file.OpenWriteAsync();
await stream.WriteAsync(pdfBytes);
return file.Path.LocalPath;
}
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;
}
}

View File

@@ -241,6 +241,20 @@
<x:String x:Key="SvgOrange">path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #E8622A; }</x:String>
<x:String x:Key="SvgPink">path, circle, rect, ellipse, line, polyline, polygon, text, use { stroke: #D4306A; }</x:String>
<!-- SVG FILL COLORS -->
<x:String x:Key="SvgFillBase">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #F4F5F8; }</x:String>
<x:String x:Key="SvgFillPrimary">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #0F1117; }</x:String>
<x:String x:Key="SvgFillSecondary">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #353A4A; }</x:String>
<x:String x:Key="SvgFillMuted">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #6B7080; }</x:String>
<x:String x:Key="SvgFillDisabled">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #A0A6B8; }</x:String>
<x:String x:Key="SvgFillBlue">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #3B6AFF; }</x:String>
<x:String x:Key="SvgFillGreen">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #18A86B; }</x:String>
<x:String x:Key="SvgFillYellow">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #D4A012; }</x:String>
<x:String x:Key="SvgFillRed">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #E53535; }</x:String>
<x:String x:Key="SvgFillPurple">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #6B44E0; }</x:String>
<x:String x:Key="SvgFillOrange">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #E8622A; }</x:String>
<x:String x:Key="SvgFillPink">path, circle, rect, ellipse, line, polyline, polygon, text, use { fill: #D4306A; }</x:String>
<!-- Toggle Switch specific -->
<SolidColorBrush x:Key="ToggleSwitchTrackOff" Color="#E2E4ED" />
<SolidColorBrush x:Key="ToggleSwitchTrackBorderOff" Color="#C8CCDE" />
@@ -249,10 +263,9 @@
<SolidColorBrush x:Key="ToggleSwitchTrackOnHover" Color="#5580FF" />
<SolidColorBrush x:Key="ToggleSwitchTrackOnPressed" Color="#2D5CE8" />
<SolidColorBrush x:Key="ToggleSwitchTrackOnDisabled" Color="#A0B4FF" />
<Bitmap x:Key="key">path</Bitmap>
<!-- logos -->
<!-- logos -->
<!-- Icon only, light bg -->
<x:String x:Key="LogoIconPrimaryBgSvg">avares://Clario/Assets/Logo/logo-icon-primary-bg-light.svg</x:String>
<Bitmap x:Key="LogoIconPrimaryBg1x">avares://Clario/Assets/Logo/logo-icon-primary-bg-light-128.png</Bitmap>
@@ -606,7 +619,7 @@
</Style>
<Style Selector="Border.inset">
<Setter Property="Background" Value="{DynamicResource BgElevated}" />
<Setter Property="Background" Value="{DynamicResource BgBase}" />
<Setter Property="CornerRadius" Value="{DynamicResource RadiusInset}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
@@ -1092,11 +1105,11 @@
<Style Selector="TextBox:disabled">
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}" />
<Setter Property="Background" Value="{DynamicResource BgElevated}" />
<Setter Property="Background" Value="{DynamicResource BgBase}" />
</Style>
<Style Selector="TextBox:readonly">
<Setter Property="Background" Value="{DynamicResource BgElevated}" />
<Setter Property="Background" Value="{DynamicResource BgBase}" />
<Setter Property="Foreground" Value="{DynamicResource TextMuted}" />
</Style>
<Style Selector="TextBox:readonly /template/ Border#PART_BorderElement">
@@ -1181,7 +1194,7 @@
</Style>
<Style Selector="ComboBox:disabled /template/ Border#Background">
<Setter Property="Background" Value="{DynamicResource BgElevated}" />
<Setter Property="Background" Value="{DynamicResource BgBase}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}" />
<Setter Property="Opacity" Value="0.5" />
</Style>
@@ -1422,12 +1435,12 @@
</Style>
<Style Selector="cc|DateRangePicker:disabled /template/ Button#PART_Button /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource BgElevated}" />
<Setter Property="Background" Value="{DynamicResource BgBase}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}" />
<Setter Property="Opacity" Value="0.5" />
</Style>
<!-- ── Budget Card — On Track ─────────────────────── -->
<!-- Budget Card — On Track -->
<Style Selector="Border.budget-card">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtle}" />
@@ -1437,7 +1450,7 @@
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Budget Card — Warning ──────────────────────── -->
<!-- Budget Card — Warning -->
<Style Selector="Border.budget-card-warning">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentYellow}" />
@@ -1447,7 +1460,7 @@
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Budget Card — Over Budget ─────────────────── -->
<!-- Budget Card — Over Budget -->
<Style Selector="Border.budget-card-over">
<Setter Property="Background" Value="{DynamicResource BgSurface}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentRed}" />
@@ -1457,13 +1470,13 @@
<Setter Property="Cursor" Value="Hand" />
</Style>
<!-- ── Progress Bar — Yellow ─────────────────────── -->
<!-- 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 ───────────────────────────── -->
<!-- Badge — Warning -->
<Style Selector="Border.badge-warning">
<Setter Property="Background" Value="{DynamicResource BadgeBgYellow}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource AccentYellow}" />
@@ -1471,7 +1484,7 @@
<Setter Property="Padding" Value="6,2" />
</Style>
<!-- ── Badge — Over ──────────────────────────────── -->
<!-- Badge — Over -->
<Style Selector="Border.badge-over">
<Setter Property="Background" Value="{DynamicResource BadgeBgRed}" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource AccentRed}" />

View File

@@ -7,7 +7,7 @@
</Border>
</Design.PreviewWith>
<!-- ── FluentCalendarButton (header + prev/next) ──────── -->
<!-- FluentCalendarButton (header + prev/next) -->
<Style Selector="Button.FluentCalendarButton, Button#PART_HeaderButton, Button#PART_PreviousButton, Button#PART_NextButton">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
@@ -24,7 +24,7 @@
<Setter Property="Background" Value="{DynamicResource BorderSubtle}"/>
</Style>
<!-- ── Calendar root ──────────────────────────────────── -->
<!-- Calendar root -->
<Style Selector="Calendar">
<Setter Property="CornerRadius" Value="{DynamicResource RadiusControl}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BgSurface}"/>
@@ -32,7 +32,7 @@
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
</Style>
<!-- ── CalendarItem (the main container) ─────────────── -->
<!-- CalendarItem (the main container) -->
<Style Selector="CalendarItem">
<Setter Property="Foreground" Value="{DynamicResource TextPrimary}"/>
<Setter Property="Background" Value="{DynamicResource BgSurface}"/>
@@ -154,7 +154,7 @@
</Setter>
</Style>
<!-- ── CalendarDayButton ──────────────────────────────── -->
<!-- CalendarDayButton -->
<Style Selector="CalendarDayButton">
<Setter Property="Width" Value="38"/>
<Setter Property="Height" Value="38"/>
@@ -243,7 +243,7 @@
<Setter Property="Opacity" Value="0.4"/>
</Style>
<!-- ── CalendarButton (month/year picker cells) ──────── -->
<!-- CalendarButton (month/year picker cells) -->
<Style Selector="CalendarButton">
<Setter Property="Width" Value="60"/>
<Setter Property="Height" Value="52"/>

View File

@@ -30,7 +30,7 @@
In WinUI Min-Width from TemplateSettings
basically...MinWidth of DayItem = 40, 40 * 7 = 280 + margins/padding = ~294
Viewport height is set from # of rows displayed (2-8) in Month mode, = ~290 for 6 weeks (+ day names)
-->
-->
<Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" RowDefinitions="40,*" MinWidth="294"
Background="{DynamicResource BgSidebar}">
<Grid ColumnDefinitions="5*,*,*">

View File

@@ -8,11 +8,11 @@
</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"/>
@@ -56,9 +56,9 @@
<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}"/>
@@ -86,9 +86,9 @@
<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}"/>
@@ -98,9 +98,9 @@
<!-- <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>
@@ -129,9 +129,9 @@
<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}"/>
@@ -147,9 +147,9 @@
<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}"/>
@@ -161,9 +161,9 @@
<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"/>
@@ -193,9 +193,9 @@
<Setter Property="Height" Value="NaN"/>
</Style>
<!-- ═══════════════════════════════════════════════════════════
<!--
ColorPreviewer (accent color swatches at the bottom)
═══════════════════════════════════════════════════════════════ -->
-->
<Style Selector="primitives|ColorPreviewer">
<Setter Property="Background" Value="{DynamicResource BgSurface}"/>
</Style>

View File

@@ -130,7 +130,7 @@
<Setter Property="Background" Value="Transparent" />
</Style>
<!-- DisabledState -->
<!-- DisabledState -->
<Style Selector="^:disabled">
<Setter Property="Foreground" Value="{DynamicResource TextDisabled}" />
</Style>
@@ -155,7 +155,7 @@
<Setter Property="Opacity" Value="0.4" />
</Style>
<!-- CheckedState -->
<!-- CheckedState -->
<Style Selector="^:checked /template/ Border#OuterBorder">
<Setter Property="Opacity" Value="0" />
</Style>
@@ -180,7 +180,7 @@
<Setter Property="Opacity" Value="1" />
</Style>
<!-- UncheckedState -->
<!-- UncheckedState -->
<Style Selector="^:unchecked /template/ Border#OuterBorder">
<Setter Property="Opacity" Value="1" />
</Style>

View File

@@ -14,7 +14,7 @@ public partial class AccountFormViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
// ── Mode ────────────────────────────────────────────────
// Mode
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
private bool _isEditMode = false;
@@ -22,7 +22,7 @@ public partial class AccountFormViewModel : ViewModelBase
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Account";
// ── Fields ──────────────────────────────────────────────
// Fields
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private string _name = "";
@@ -78,12 +78,12 @@ public partial class AccountFormViewModel : ViewModelBase
[ObservableProperty] private string _selectedColor = "#3B82F6";
// ── Options ─────────────────────────────────────────────
// Options
[ObservableProperty] private List<string> _accountTypes = new() { "Cash", "Checking", "Savings", "Credit", "Investment", "Other" };
[ObservableProperty] private List<string> _icons = new() { "wallet", "credit-card", "banknote", "landmark", "piggy-bank", "dollar-sign" };
// ── Validation ──────────────────────────────────────────
// Validation
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
@@ -95,17 +95,17 @@ public partial class AccountFormViewModel : ViewModelBase
public bool IsCredit => SelectedType == "Credit";
// ── Callbacks ───────────────────────────────────────────
// Callbacks
public Action? OnSaved;
public Action? OnCancelled;
// ── Edit mode: original account ─────────────────────────
// Edit mode: original account
private Guid? _editingId;
// ── Result account ──────────────────────────────────────
// Result account
public Account? ResultAccount { get; set; }
// ── Commands ────────────────────────────────────────────
// Commands
partial void OnSelectedTypeChanged(string value)
{
@@ -220,7 +220,7 @@ public partial class AccountFormViewModel : ViewModelBase
OnCancelled?.Invoke();
}
// ── Public setup methods ─────────────────────────────────
// Public setup methods
/// <summary>Call this to open the form for adding a new account.</summary>
public void SetupForAdd()

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using Clario.Services;
@@ -21,37 +22,61 @@ public partial class AccountsViewModel : ViewModelBase
public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
[ObservableProperty] private Account? _selectedAccount;
[ObservableProperty] private bool _isAccountDeletionConfirmationVisible;
public bool CanDeleteAccount => VisibleAccounts.Count > 1;
public bool CanDeleteAccount => VisibleAccounts.Count(x => !x.GroupHeader) > 1;
public int ActiveAccountCount => VisibleAccounts.Count(x => !x.GroupHeader);
[ObservableProperty] private bool _isDeleteDialogVisible;
[ObservableProperty] private DeleteAccountDialogViewModel _deleteDialog = new();
[ObservableProperty] private bool _isArchiveDialogVisible;
[ObservableProperty] private Account? _accountToArchive;
[ObservableProperty] private bool _isArchivedListVisible;
[ObservableProperty] private List<Account> _archivedAccounts = new();
public bool HasArchivedAccounts => ArchivedAccounts.Count > 0;
[ObservableProperty] private bool _shouldCloseSheet;
/// <summary>Set by AccountsViewMobile. Returns true and closes the sheet if it was open.</summary>
public Func<bool>? TryCloseSheet { get; set; }
public AccountsViewModel()
{
AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); };
Track(AppData.Accounts, (_, _) => Initialize());
Track(AppData.Transactions, (_, _) => Initialize());
Initialize();
}
public void Initialize()
{
var prevSelectedId = SelectedAccount?.Id;
FetchAndProcessAccountInfo();
GroupAccounts();
SelectedAccount = VisibleAccounts.FirstOrDefault(x => !x.GroupHeader);
ArchivedAccounts = AppData.Accounts.Where(a => a.IsArchived).ToList();
OnPropertyChanged(nameof(HasArchivedAccounts));
OnPropertyChanged(nameof(ActiveAccountCount));
OnPropertyChanged(nameof(CanDeleteAccount));
// Set to null first so PropertyChanged fires even when re-selecting the same account,
// ensuring the detail panel re-reads all computed properties (balance, income, etc.)
SelectedAccount = null;
SelectedAccount = (prevSelectedId.HasValue
? VisibleAccounts.FirstOrDefault(a => a.Id == prevSelectedId && !a.GroupHeader)
: null) ?? VisibleAccounts.FirstOrDefault(x => !x.GroupHeader);
}
private void FetchAndProcessAccountInfo()
{
TotalBalance = 0;
var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD";
foreach (var account in AppData.Accounts)
foreach (var account in AppData.Accounts.Where(a => !a.IsArchived))
{
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);
account.TotalExpenseThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type == "expense").Sum(t => t.Amount);
account.IncomeTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type == "income");
account.ExpenseTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type == "expense");
account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount);
account.TotalIncomeThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type is "income" or "transfer_in").Sum(t => t.Amount);
account.TotalExpenseThisMonth = accountTransactions.Where(t => t.Date.Month == DateTime.Now.Month && t.Type is "expense" or "transfer_out").Sum(t => t.Amount);
account.IncomeTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type is "income" or "transfer_in");
account.ExpenseTransactionsThisMonth = accountTransactions.Count(t => t.Date.Month == DateTime.Now.Month && t.Type is "expense" or "transfer_out");
account.RecentTransactions = accountTransactions.OrderByDescending(t => t.Date).Take(3).ToList();
var lastMonthBalance = accountTransactions.Where(t => t.Date.Month == DateTime.Now.AddMonths(-1).Month && t.Type == "income")
.Sum(t => t.Type == "income" ? t.Amount : -t.Amount);
@@ -59,7 +84,7 @@ public partial class AccountsViewModel : ViewModelBase
if (account.Currency.Equals(primaryCurrency, StringComparison.OrdinalIgnoreCase))
TotalBalance += account.CurrentBalance;
else
TotalBalance += accountTransactions.Sum(t => t.Type == "income" ? t.ConvertedAmount : -t.ConvertedAmount);
TotalBalance += accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount);
}
}
@@ -91,7 +116,7 @@ public partial class AccountsViewModel : ViewModelBase
foreach (var type in accountTypes)
{
var accountsOfType = AppData.Accounts
.Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase))
.Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase) && !a.IsArchived)
.OrderByDescending(a => a.IsPrimary)
.ThenBy(a => a.CreatedAt)
.ToList();
@@ -109,6 +134,53 @@ public partial class AccountsViewModel : ViewModelBase
OnPropertyChanged(nameof(CanDeleteAccount));
}
[RelayCommand]
private void RequestArchiveAccount(Account account)
{
AccountToArchive = account;
IsArchiveDialogVisible = true;
}
[RelayCommand]
private void CancelArchive()
{
IsArchiveDialogVisible = false;
AccountToArchive = null;
}
[RelayCommand]
private async Task ConfirmArchive()
{
if (AccountToArchive is null) return;
AccountToArchive.IsArchived = true;
await AppData.UpdateAccount(AccountToArchive);
IsArchiveDialogVisible = false;
AccountToArchive = null;
Initialize();
}
[RelayCommand]
private void ShowArchivedList()
{
IsArchivedListVisible = true;
}
[RelayCommand]
private void CloseArchivedList()
{
IsArchivedListVisible = false;
}
[RelayCommand]
private async Task UnarchiveAccount(Account account)
{
account.IsArchived = false;
await AppData.UpdateAccount(account);
Initialize();
if (!HasArchivedAccounts)
IsArchivedListVisible = false;
}
[RelayCommand]
private void RequestDeleteAccount(Account account)
{
@@ -117,6 +189,7 @@ public partial class AccountsViewModel : ViewModelBase
{
IsDeleteDialogVisible = false;
Initialize();
ShouldCloseSheet = true;
};
DeleteDialog.OnCancelled = () => IsDeleteDialogVisible = false;
IsDeleteDialogVisible = true;

View File

@@ -0,0 +1,439 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
using SKColor = SkiaSharp.SKColor;
namespace Clario.ViewModels;
public partial class AnalyticsViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
private static readonly SKTypeface _interTypeface = SKTypeface.FromFamilyName("Inter");
// Period
public List<string> PeriodOptions { get; } = new()
{
"Last 30 Days", "Last 3 Months", "Last 6 Months", "Last 12 Months", "This Year"
};
[ObservableProperty] private string _selectedPeriod = "Last 6 Months";
partial void OnSelectedPeriodChanged(string value) => Initialize();
// KPI cards
[ObservableProperty] private string _totalIncomeFormatted = "—";
[ObservableProperty] private string _totalExpensesFormatted = "—";
[ObservableProperty] private string _netSavingsFormatted = "—";
[ObservableProperty] private string _savingsRateFormatted = "—";
[ObservableProperty] private bool _netSavingsPositive = true;
public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
// Cash Flow chart
[ObservableProperty] private ISeries[] _cashFlowSeries = [];
[ObservableProperty] private Axis[] _cashFlowXAxes = [];
[ObservableProperty] private Axis[] _cashFlowYAxes = [];
// Net Worth chart
[ObservableProperty] private ISeries[] _netWorthSeries = [];
[ObservableProperty] private Axis[] _netWorthXAxes = [];
[ObservableProperty] private Axis[] _netWorthYAxes = [];
// Day-of-week chart
[ObservableProperty] private ISeries[] _dayOfWeekSeries = [];
[ObservableProperty] private Axis[] _dayOfWeekXAxes = [];
// Top categories
[ObservableProperty] private ObservableCollection<CategorySpendRow> _topCategories = new();
[ObservableProperty] private bool _hasTopCategories;
// Income sources donut
[ObservableProperty] private ISeries[] _incomeSourcesSeries = [];
[ObservableProperty] private bool _hasIncomeSources;
// State
[ObservableProperty] private bool _isExporting;
[ObservableProperty] private string? _exportStatusMessage;
//
public AnalyticsViewModel()
{
Track(AppData.Transactions, (_, _) => Initialize());
Track(AppData.Accounts, (_, _) => Initialize());
Initialize();
}
public void Initialize()
{
try
{
var (start, end) = GetDateRange();
var periodTxs = AppData.Transactions
.Where(t => !t.IsTransfer && t.Date.Date >= start.Date && t.Date.Date <= end.Date)
.ToList();
var expenses = periodTxs.Where(t => t.Type == "expense").ToList();
var income = periodTxs.Where(t => t.Type == "income").ToList();
ComputeKpis(income, expenses);
BuildCashFlowChart(start, end);
BuildNetWorthChart(start, end);
BuildDayOfWeekChart(expenses, start, end);
BuildTopCategories(expenses);
BuildIncomeSourcesChart(income);
OnPropertyChanged(nameof(PrimarySymbol));
}
catch (Exception e)
{
DebugLogger.Log(e);
}
}
// Date range
private (DateTime start, DateTime end) GetDateRange()
{
var now = DateTime.Now;
return SelectedPeriod switch
{
"Last 30 Days" => (now.AddDays(-30), now),
"Last 3 Months" => (now.AddMonths(-3), now),
"Last 6 Months" => (now.AddMonths(-6), now),
"Last 12 Months" => (now.AddMonths(-12), now),
"This Year" => (new DateTime(now.Year, 1, 1), now),
_ => (now.AddMonths(-6), now)
};
}
private static List<(DateTime monthStart, DateTime monthEnd, string label)> GetMonthBuckets(DateTime start, DateTime end)
{
var buckets = new List<(DateTime, DateTime, string)>();
var current = new DateTime(start.Year, start.Month, 1);
var endMonth = new DateTime(end.Year, end.Month, 1);
var culture = new CultureInfo("en-US");
while (current <= endMonth)
{
var next = current.AddMonths(1);
buckets.Add((current, next.AddSeconds(-1), current.ToString("MMM ''yy", culture)));
current = next;
}
return buckets;
}
// Section 1: KPIs
private void ComputeKpis(List<Transaction> income, List<Transaction> expenses)
{
var sym = PrimarySymbol;
var totalIncome = income.Sum(t => t.ConvertedAmount);
var totalExpenses = expenses.Sum(t => t.ConvertedAmount);
var net = totalIncome - totalExpenses;
TotalIncomeFormatted = $"{sym}{totalIncome:N2}";
TotalExpensesFormatted = $"{sym}{totalExpenses:N2}";
NetSavingsPositive = net >= 0;
NetSavingsFormatted = $"{(net >= 0 ? "+" : "")}{sym}{net:N2}";
SavingsRateFormatted = totalIncome > 0
? $"{Math.Max(0, (net / totalIncome) * 100):F1}%"
: "—";
}
// Section 2: Cash Flow
private void BuildCashFlowChart(DateTime start, DateTime end)
{
var buckets = GetMonthBuckets(start, end);
var incomeVals = new double[buckets.Count];
var expenseVals = new double[buckets.Count];
for (var i = 0; i < buckets.Count; i++)
{
var (mStart, mEnd, _) = buckets[i];
incomeVals[i] = (double)AppData.Transactions
.Where(t => t.Type == "income" && t.Date.Date >= mStart && t.Date.Date <= mEnd)
.Sum(t => t.ConvertedAmount);
expenseVals[i] = (double)AppData.Transactions
.Where(t => t.Type == "expense" && t.Date.Date >= mStart && t.Date.Date <= mEnd)
.Sum(t => t.ConvertedAmount);
}
var labels = buckets.Select(b => b.label).ToArray();
CashFlowSeries =
[
new LineSeries<double>
{
Name = "Income",
Values = incomeVals,
Stroke = new SolidColorPaint(SKColor.Parse("#2ECC8A"), 2),
Fill = null,
GeometryFill = new SolidColorPaint(SKColor.Parse("#2ECC8A")),
GeometryStroke = null,
GeometrySize = 5,
LineSmoothness = 0.5
},
new LineSeries<double>
{
Name = "Expenses",
Values = expenseVals,
Stroke = new SolidColorPaint(SKColor.Parse("#FF5E5E"), 2),
Fill = null,
GeometryFill = new SolidColorPaint(SKColor.Parse("#FF5E5E")),
GeometryStroke = null,
GeometrySize = 5,
LineSmoothness = 0.5
}
];
CashFlowXAxes =
[
new Axis
{
Labels = labels,
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface },
SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
TicksPaint = null,
TextSize = 11
}
];
var sym = PrimarySymbol;
CashFlowYAxes =
[
new Axis
{
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface },
SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
TicksPaint = null,
TextSize = 10,
Labeler = v => $"{sym}{v:N0}"
}
];
}
// Section 3: Net Worth
private void BuildNetWorthChart(DateTime start, DateTime end)
{
// Start from 12 months before start to show history, but respect the selected range
var buckets = GetMonthBuckets(start, end);
var netWorthVals = new double[buckets.Count];
for (var i = 0; i < buckets.Count; i++)
{
var (_, mEnd, _) = buckets[i];
double nw = 0;
foreach (var account in AppData.Accounts.Where(a => !a.IsArchived))
{
var txUpTo = AppData.Transactions.Where(t => t.AccountId == account.Id && t.Date.Date <= mEnd.Date);
nw += (double)(account.OpeningBalance +
txUpTo.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount));
}
netWorthVals[i] = nw;
}
var labels = buckets.Select(b => b.label).ToArray();
NetWorthSeries =
[
new LineSeries<double>
{
Name = "Net Worth",
Values = netWorthVals,
Stroke = new SolidColorPaint(SKColor.Parse("#7B9CFF"), 2),
Fill = new SolidColorPaint(SKColor.Parse("#7B9CFF").WithAlpha(25)),
GeometryFill = new SolidColorPaint(SKColor.Parse("#7B9CFF")),
GeometryStroke = null,
GeometrySize = 5,
LineSmoothness = 0.5
}
];
NetWorthXAxes =
[
new Axis
{
Labels = labels,
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface },
SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
TicksPaint = null,
TextSize = 11
}
];
var sym = PrimarySymbol;
NetWorthYAxes =
[
new Axis
{
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface },
SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
TicksPaint = null,
TextSize = 10,
Labeler = v => $"{sym}{v:N0}"
}
];
}
// Section 4: Day of Week
private void BuildDayOfWeekChart(List<Transaction> expenses, DateTime start, DateTime end)
{
// DayOfWeek: Sunday=0, Monday=1 ... Saturday=6
// We display MonSun (index 06 in our array)
var dayLabels = new[] { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" };
var totals = new double[7];
var counts = new int[7];
// Count occurrences of each weekday in the period
var d = start.Date;
while (d <= end.Date)
{
var idx = ((int)d.DayOfWeek + 6) % 7; // Mon=0..Sun=6
counts[idx]++;
d = d.AddDays(1);
}
foreach (var tx in expenses)
{
var idx = ((int)tx.Date.DayOfWeek + 6) % 7;
totals[idx] += (double)tx.ConvertedAmount;
}
var averages = totals.Select((total, i) => counts[i] > 0 ? total / counts[i] : 0).ToArray();
DayOfWeekSeries =
[
new ColumnSeries<double>
{
Name = "Avg Daily Spend",
Values = averages,
Fill = new SolidColorPaint(SKColor.Parse("#9B7BFF")),
MaxBarWidth = 40,
Padding = 3
}
];
DayOfWeekXAxes =
[
new Axis
{
Labels = dayLabels,
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")) { SKTypeface = _interTypeface },
SeparatorsPaint = null,
TicksPaint = null,
TextSize = 11
}
];
}
// Section 5: Top Categories
private void BuildTopCategories(List<Transaction> expenses)
{
var sym = PrimarySymbol;
var totalSpend = expenses.Sum(t => t.ConvertedAmount);
if (totalSpend == 0)
{
TopCategories = new ObservableCollection<CategorySpendRow>();
HasTopCategories = false;
return;
}
var grouped = expenses
.Where(t => t.Category is not null)
.GroupBy(t => t.Category!)
.Select(g => new CategorySpendRow
{
Name = g.Key.Name,
Icon = g.Key.Icon,
Color = g.Key.Color,
Amount = g.Sum(t => t.ConvertedAmount),
Percentage = (double)(g.Sum(t => t.ConvertedAmount) / totalSpend * 100),
AmountFormatted = $"{sym}{g.Sum(t => t.ConvertedAmount):N2}"
})
.OrderByDescending(r => r.Amount)
.Take(8)
.ToList();
TopCategories = new ObservableCollection<CategorySpendRow>(grouped);
HasTopCategories = grouped.Count > 0;
}
// Section 6: Income Sources
private void BuildIncomeSourcesChart(List<Transaction> income)
{
var grouped = income
.Where(t => t.Category is not null)
.GroupBy(t => t.Category!)
.Select(g => (category: g.Key, total: g.Sum(t => t.ConvertedAmount)))
.OrderByDescending(x => x.total)
.ToList();
if (grouped.Count < 2)
{
IncomeSourcesSeries = [];
HasIncomeSources = false;
return;
}
var sym = PrimarySymbol;
IncomeSourcesSeries = grouped.Select(x => (ISeries)new PieSeries<double>
{
Name = x.category.Name,
Values = new[] { (double)x.total },
Fill = new SolidColorPaint(SKColor.Parse(x.category.Color)),
InnerRadius = 20,
ToolTipLabelFormatter = p => $"{sym}{p.Coordinate.PrimaryValue:N2}"
}).ToArray();
HasIncomeSources = true;
}
// PDF Export
[RelayCommand]
private async Task ExportPdf()
{
if (IsExporting) return;
IsExporting = true;
ExportStatusMessage = null;
try
{
var (start, end) = GetDateRange();
var path = await PdfExportService.ExportAsync(
AppData,
start,
end,
SelectedPeriod,
TopCategories.ToList());
ExportStatusMessage = path is not null ? "PDF saved successfully." : null;
}
catch (Exception e)
{
DebugLogger.Log(e);
ExportStatusMessage = "Export failed. Please try again.";
}
finally
{
IsExporting = false;
}
}
}

View File

@@ -6,11 +6,13 @@ using System.Text.Json;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Clario.Enums;
using Clario.Models;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Supabase.Gotrue;
using Supabase.Gotrue.Exceptions;
namespace Clario.ViewModels;
@@ -22,7 +24,7 @@ public partial class AuthViewModel : ViewModelBase
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand))]
private string _lastName;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))]
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand), nameof(SendResetLinkCommand))]
private string _email;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))]
@@ -32,10 +34,17 @@ public partial class AuthViewModel : ViewModelBase
private string _confirmPassword;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(isSignin), nameof(isCreateAccount))]
[NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))]
[NotifyPropertyChangedFor(nameof(isSignin), nameof(isCreateAccount), nameof(isForgotPassword), nameof(ShowTabs))]
[NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand), nameof(SendResetLinkCommand))]
private string _operation = "login";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
[ObservableProperty] private bool _resetEmailSent;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public AuthViewModel()
{
DebugLogger.Log("auth vm loaded");
@@ -64,11 +73,37 @@ public partial class AuthViewModel : ViewModelBase
private void SetOperation(string operation)
{
Operation = operation;
ErrorMessage = null;
ResetEmailSent = false;
}
[RelayCommand(CanExecute = nameof(canSendResetLink))]
private async Task SendResetLink()
{
ErrorMessage = null;
try
{
await SupabaseService.Client.Auth.ResetPasswordForEmail(_email);
ResetEmailSent = true;
}
catch (GotrueException e)
{
DebugLogger.Log(e);
ErrorMessage = e.Reason == FailureHint.Reason.UserBadEmailAddress
? GetErrorMessage(AuthError.InvalidEmail)
: GetErrorMessage(AuthError.Unknown);
}
catch (Exception e)
{
DebugLogger.Log(e);
ErrorMessage = GetErrorMessage(AuthError.Unknown);
}
}
[RelayCommand(CanExecute = nameof(canSignin))]
private async Task ConfirmLogin()
{
ErrorMessage = null;
try
{
await SupabaseService.Client.Auth.SignIn(_email, _password);
@@ -84,15 +119,22 @@ public partial class AuthViewModel : ViewModelBase
singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
}
}
catch (GotrueException e)
{
DebugLogger.Log(e);
ErrorMessage = GetLoginErrorMessage(e.Reason);
}
catch (Exception e)
{
DebugLogger.Log(e);
ErrorMessage = GetErrorMessage(AuthError.Unknown);
}
}
[RelayCommand(CanExecute = nameof(canCreateAccount))]
private async Task ConfirmCreateAccount()
{
ErrorMessage = null;
try
{
var session = await SupabaseService.Client.Auth.SignUp(
@@ -120,21 +162,65 @@ public partial class AuthViewModel : ViewModelBase
singleViewPlatform.MainView!.DataContext = user is not null ? new MainViewModel() : new AuthViewModel();
}
}
catch (GotrueException e)
{
DebugLogger.Log(e);
ErrorMessage = GetSignupErrorMessage(e.Reason);
}
catch (Exception e)
{
DebugLogger.Log(e);
ErrorMessage = GetErrorMessage(AuthError.Unknown);
}
}
private static string GetLoginErrorMessage(FailureHint.Reason reason) => reason switch
{
FailureHint.Reason.UserBadLogin => GetErrorMessage(AuthError.InvalidCredentials),
FailureHint.Reason.UserBadPassword => GetErrorMessage(AuthError.InvalidCredentials),
FailureHint.Reason.UserEmailNotConfirmed => GetErrorMessage(AuthError.EmailNotConfirmed),
FailureHint.Reason.UserTooManyRequests => GetErrorMessage(AuthError.RateLimited),
FailureHint.Reason.UserBadEmailAddress => GetErrorMessage(AuthError.InvalidEmail),
FailureHint.Reason.Offline => GetErrorMessage(AuthError.Unknown),
_ => GetErrorMessage(AuthError.Unknown),
};
private static string GetSignupErrorMessage(FailureHint.Reason reason) => reason switch
{
FailureHint.Reason.UserAlreadyRegistered => GetErrorMessage(AuthError.EmailAlreadyExists),
FailureHint.Reason.UserBadPassword => GetErrorMessage(AuthError.WeakPassword),
FailureHint.Reason.UserBadEmailAddress => GetErrorMessage(AuthError.InvalidEmail),
FailureHint.Reason.UserTooManyRequests => GetErrorMessage(AuthError.RateLimited),
FailureHint.Reason.Offline => GetErrorMessage(AuthError.Unknown),
_ => GetErrorMessage(AuthError.Unknown),
};
private static string GetErrorMessage(AuthError error) => error switch
{
AuthError.InvalidCredentials => "Invalid email or password.",
AuthError.EmailAlreadyExists => "An account with this email already exists.",
AuthError.EmailNotConfirmed => "Please confirm your email before signing in.",
AuthError.WeakPassword => "Password must be at least 6 characters.",
AuthError.InvalidEmail => "Please enter a valid email address.",
AuthError.SignupDisabled => "Sign-ups are currently disabled.",
AuthError.RateLimited => "Too many attempts. Please wait and try again.",
AuthError.SessionExpired => "Your session has expired. Please sign in again.",
_ => "Something went wrong. Please try again.",
};
public bool isSignin => Operation == "login";
public bool isCreateAccount => Operation == "signup";
public bool isForgotPassword => Operation == "forgotPassword";
public bool ShowTabs => !isForgotPassword;
public bool canSignin => isSignin && !string.IsNullOrWhiteSpace(_email) && !string.IsNullOrWhiteSpace(_password);
public bool canCreateAccount => isCreateAccount && !string.IsNullOrWhiteSpace(_firstName) && !string.IsNullOrWhiteSpace(_lastName) &&
!string.IsNullOrWhiteSpace(_email) &&
!string.IsNullOrWhiteSpace(_password) && _password == _confirmPassword;
public bool canSendResetLink => isForgotPassword && !string.IsNullOrWhiteSpace(_email);
}
class Wrapper

View File

@@ -14,7 +14,7 @@ public partial class BudgetFormViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
// ── Mode ────────────────────────────────────────────────
// Mode
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
private bool _isEditMode = false;
@@ -22,7 +22,7 @@ public partial class BudgetFormViewModel : ViewModelBase
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Budget";
// ── Fields ──────────────────────────────────────────────
// Fields
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsMonthly), nameof(IsQuarterly), nameof(IsYearly), nameof(IsValid))]
private string _period = "monthly";
@@ -43,7 +43,7 @@ public partial class BudgetFormViewModel : ViewModelBase
[ObservableProperty] private bool _rollover = false;
// ── Validation ──────────────────────────────────────────
// Validation
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
@@ -57,20 +57,20 @@ public partial class BudgetFormViewModel : ViewModelBase
decimal.TryParse(LimitAmount, out var amt) && amt > 0 &&
SelectedCategory is not null;
// ── Callbacks ───────────────────────────────────────────
// Callbacks
public Action? OnSaved;
public Action? OnCancelled;
public Action? OnDeleted;
[ObservableProperty] private bool _showDeleteConfirm = false;
// ── Edit mode: original budget ───────────────────────────
// Edit mode: original budget
private Guid? _editingId;
// ── Result ──────────────────────────────────────────────
// Result
public Budget? ResultBudget { get; set; }
// ── Commands ────────────────────────────────────────────
// Commands
[RelayCommand]
private void SetPeriod(string period)
@@ -174,7 +174,7 @@ public partial class BudgetFormViewModel : ViewModelBase
OnCancelled?.Invoke();
}
// ── Public setup methods ─────────────────────────────────
// Public setup methods
/// <summary>Call this to open the form for adding a new budget.</summary>
public void SetupForAdd(ObservableCollection<Category> categories)

View File

@@ -28,8 +28,8 @@ public partial class BudgetViewModel : ViewModelBase
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(NextPeriodCommand), nameof(PreviousPeriodCommand))]
private DateTime _currentPeriod = DateTime.Now.Date;
public bool CanGoToNextPeriod => CurrentPeriod.Month < DateTime.Now.Month;
public bool CanGoToPreviousPeriod => AppData.Transactions.Any() && CurrentPeriod.Month > AppData.Transactions.Min(x => x.Date.Month);
public bool CanGoToNextPeriod => CurrentPeriod.Year < DateTime.Now.Year || (CurrentPeriod.Year == DateTime.Now.Year && CurrentPeriod.Month < DateTime.Now.Month);
public bool CanGoToPreviousPeriod => AppData.Transactions.Any() && new DateTime(CurrentPeriod.Year, CurrentPeriod.Month, 1) > new DateTime(AppData.Transactions.Min(x => x.Date).Year, AppData.Transactions.Min(x => x.Date).Month, 1);
public string CurrentPeriodFormatted => CurrentPeriod.ToString("MMMM yyyy");
[ObservableProperty] private ISeries[] _spendingBreakdownChartSeries = [];
@@ -40,7 +40,7 @@ public partial class BudgetViewModel : ViewModelBase
public string SpentPercentageFormatted => (TotalSpent / TotalBudgeted).ToString("P0") + " of total budget.";
public decimal TotalLeft => Math.Clamp(Math.Round(TotalBudgeted - TotalSpent), 0, decimal.MaxValue);
private string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
public string PrimarySymbol => CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
public string TotalLeftFormatted => $"{PrimarySymbol}{TotalLeft:N0} left";
public bool HasSavingsGoal => AppData.Profile?.SavingsGoal is > 0;
@@ -69,17 +69,20 @@ public partial class BudgetViewModel : ViewModelBase
public BudgetViewModel()
{
AppData.Budgets.CollectionChanged += async (_, _) => { await Initialize(); };
AppData.Transactions.CollectionChanged += async (_, _) => { await Initialize(); };
AppData.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(AppData.Profile))
NotifyComputedPropertiesOnChanged();
};
Track(AppData.Budgets, async (_, _) => await Initialize());
Track(AppData.Transactions, async (_, _) => await Initialize());
AppData.PropertyChanged += OnProfileChanged;
OnDispose(() => AppData.PropertyChanged -= OnProfileChanged);
WeakReferenceMessenger.Default.Register<RatesRefreshed>(this, async (_, _) => await Initialize());
_ = Initialize();
}
private void OnProfileChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AppData.Profile))
NotifyComputedPropertiesOnChanged();
}
private async Task Initialize()
{
try
@@ -98,19 +101,19 @@ public partial class BudgetViewModel : ViewModelBase
[RelayCommand]
private void CreateBudget()
{
((MainViewModel)parentViewModel).OpenAddBudgetCommand.Execute(null);
if (parentViewModel is MainViewModel main) main.OpenAddBudgetCommand.Execute(null);
}
[RelayCommand]
private void EditBudget(Budget budget)
{
((MainViewModel)parentViewModel).OpenEditBudgetCommand.Execute(budget);
if (parentViewModel is MainViewModel main) main.OpenEditBudgetCommand.Execute(budget);
}
[RelayCommand]
private void EditSavingsGoal()
{
((MainViewModel)parentViewModel).OpenEditSavingsGoalCommand.Execute(null);
if (parentViewModel is MainViewModel main) main.OpenEditSavingsGoalCommand.Execute(null);
}
private void ProcessChartData()

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Clario.Data;
using Clario.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Clario.ViewModels;
public partial class CategoriesViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
public GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasExpenseCategories))]
private ObservableCollection<Category> _expenseCategories = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasIncomeCategories))]
private ObservableCollection<Category> _incomeCategories = new();
public bool HasExpenseCategories => ExpenseCategories.Count > 0;
public bool HasIncomeCategories => IncomeCategories.Count > 0;
public CategoriesViewModel()
{
Track(AppData.Categories, (_, _) => Initialize());
Initialize();
}
public void Initialize()
{
ExpenseCategories = new ObservableCollection<Category>(
AppData.Categories.Where(c => c.Type == "expense").OrderBy(c => c.Name));
IncomeCategories = new ObservableCollection<Category>(
AppData.Categories.Where(c => c.Type == "income").OrderBy(c => c.Name));
}
[RelayCommand]
private void EditCategory(Category category)
{
if (parentViewModel is MainViewModel mainVm)
mainVm.OpenEditCategory(category);
}
[RelayCommand]
private void AddCategory()
{
if (parentViewModel is MainViewModel mainVm)
mainVm.OpenAddCategory();
}
}

View File

@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Clario.Data;
using Clario.Models;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Clario.ViewModels;
public partial class CategoryFormViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
// Mode
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel), nameof(CanDelete))]
private bool _isEditMode = false;
public string FormTitle => IsEditMode ? "Edit Category" : "New Category";
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Category";
// Fields
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private string _name = "";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome))]
private string _type = "expense";
[ObservableProperty] private string _selectedIcon = "utensils";
[ObservableProperty] private string _selectedColor = "#7B9CFF";
// Icon options
public List<string> CategoryIcons { get; } = new()
{
// Food & Dining
"utensils", "hamburger", "coffee", "pizza", "wine",
// Shopping
"shopping-cart", "shopping-bag", "package", "gift", "shirt",
// Transport
"car", "bus", "train-front", "bike", "plane",
// Home & Utilities
"house", "zap", "wifi", "plug-2", "wrench",
// Health & Fitness
"heart-pulse", "pill", "dumbbell", "scissors", "stethoscope",
// Entertainment
"gamepad-2", "film", "music", "tv", "headphones",
// Finance
"banknote", "credit-card", "piggy-bank", "wallet", "hand-coins",
"trending-up", "trending-down", "landmark", "circle-dollar-sign", "gem",
// Work & Education
"briefcase", "graduation-cap", "book-open", "target", "mail",
// Personal & Lifestyle
"heart", "moon", "sun", "leaf", "camera",
// Bills & Subscriptions
"receipt", "receipt-text", "smartphone", "volume-2", "refresh-cw",
};
// Validation
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool IsExpense => Type == "expense";
public bool IsIncome => Type == "income";
public bool IsValid => !string.IsNullOrWhiteSpace(Name);
public bool CanDelete => IsEditMode && DataRepo.General.Categories.Count > 4;
// Delete confirm sub-modal
[ObservableProperty] private bool _showDeleteConfirm = false;
// Callbacks
public Action? OnSaved;
public Action? OnCancelled;
public Action? OnDeleted;
// Edit mode: original category
private Guid? _editingId;
// Commands
[RelayCommand]
private void SetType(string type) => Type = type;
[RelayCommand]
private void SetIcon(string icon) => SelectedIcon = icon;
[RelayCommand]
private async Task Save()
{
ErrorMessage = null;
if (string.IsNullOrWhiteSpace(Name))
{
ErrorMessage = "Name is required.";
return;
}
try
{
if (IsEditMode && _editingId.HasValue)
{
var updated = new Category
{
Id = _editingId.Value,
UserId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id),
Name = Name.Trim(),
Type = Type,
Icon = SelectedIcon,
Color = SelectedColor,
};
await DataRepo.General.UpdateCategory(updated);
}
else
{
var category = new Category
{
Id = Guid.NewGuid(),
UserId = Guid.Parse(SupabaseService.Client.Auth.CurrentUser!.Id!),
Name = Name.Trim(),
Type = Type,
Icon = SelectedIcon,
Color = SelectedColor,
};
await DataRepo.General.InsertCategory(category);
}
OnSaved?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Something went wrong. Please try again.";
DebugLogger.Log(ex);
}
}
[RelayCommand]
private void Cancel() => OnCancelled?.Invoke();
[RelayCommand]
private void RequestDelete() => ShowDeleteConfirm = true;
[RelayCommand]
private void CancelDelete() => ShowDeleteConfirm = false;
[RelayCommand]
private async Task ConfirmDelete()
{
if (!IsEditMode || !_editingId.HasValue) return;
try
{
await DataRepo.General.DeleteCategory(_editingId.Value);
OnDeleted?.Invoke();
}
catch (Exception ex)
{
ErrorMessage = "Failed to delete category.";
DebugLogger.Log(ex);
}
}
// Public setup methods
public void SetupForAdd()
{
ShowDeleteConfirm = false;
IsEditMode = false;
_editingId = null;
Name = "";
Type = "expense";
SelectedIcon = "utensils";
SelectedColor = "#7B9CFF";
ErrorMessage = null;
}
public void SetupForEdit(Category category)
{
ShowDeleteConfirm = false;
IsEditMode = true;
_editingId = category.Id;
Name = category.Name;
Type = category.Type;
SelectedIcon = category.Icon;
SelectedColor = category.Color;
ErrorMessage = null;
OnPropertyChanged(nameof(CanDelete));
}
}

View File

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

View File

@@ -86,38 +86,25 @@ public partial class DashboardViewModel : ViewModelBase
partial void OnSelectedChartTimePeriodChanged(string value)
{
ChartTimePeriod period = value switch
{
"This Month" => ChartTimePeriod.ThisMonth,
"Last Month" => ChartTimePeriod.LastMonth,
"This Quarter" => ChartTimePeriod.ThisQuarter,
"This Year" => ChartTimePeriod.ThisYear,
_ => ChartTimePeriod.ThisMonth
};
var (_, _, subtitle) = DateRangeService.Resolve(value);
SelectedChartTimPeriodSubTitle = subtitle.Length > 0
? char.ToUpper(subtitle[0]) + subtitle.Substring(1).ToLower()
: subtitle;
SelectedChartTimPeriodSubTitle = value switch
{
"This Month" => DateTime.Now.ToString("MMMM yyyy"),
"Last Month" => DateTime.Now.AddMonths(-1).ToString("MMMM yyyy"),
"This Quarter" => $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}",
"This Year" => DateTime.Now.Year.ToString(),
_ => DateTime.Now.ToString("MMMM yyyy")
};
UpdateSpendingByCategoryChart(period);
UpdateSpendingByCategoryChart(value);
}
public DashboardViewModel()
{
AppData.Transactions.CollectionChanged += (s, e) => UpdateUserOverview();
AppData.Accounts.CollectionChanged += (s, e) => UpdateUserOverview();
AppData.Categories.CollectionChanged += (s, e) => UpdateUserOverview();
AppData.Budgets.CollectionChanged += (s, e) => UpdateUserOverview();
Track(AppData.Transactions, (_, _) => UpdateUserOverview());
Track(AppData.Accounts, (_, _) => UpdateUserOverview());
Track(AppData.Categories, (_, _) => UpdateUserOverview());
Track(AppData.Budgets, (_, _) => UpdateUserOverview());
WeakReferenceMessenger.Default.Register<RatesRefreshed>(this, (_, _) => UpdateUserOverview());
initialize();
Initialize();
}
public void initialize()
public void Initialize()
{
UpdateUserOverview();
}
@@ -126,7 +113,7 @@ public partial class DashboardViewModel : ViewModelBase
private void UpdateUserOverview()
{
CalculateMonthlyValues();
UpdateSpendingByCategoryChart();
UpdateSpendingByCategoryChart(SelectedChartTimePeriod);
_ = UpdateBudgetTracker();
UpdateRecentTransactions();
UpdateAccountsSummary();
@@ -175,47 +162,50 @@ public partial class DashboardViewModel : ViewModelBase
[RelayCommand]
private void CreateTransaction()
{
((MainViewModel)parentViewModel).OpenAddTransaction();
if (parentViewModel is MainViewModel main) main.OpenAddTransaction();
}
private void UpdateSpendingByCategoryChart(ChartTimePeriod period = ChartTimePeriod.ThisMonth)
[RelayCommand]
private void NavigateToSettings()
{
if (parentViewModel is MainViewModel main) main.GoToSettingsCommand.Execute(null);
}
[RelayCommand]
private void NavigateToBudget()
{
if (parentViewModel is MainViewModel main) main.GoToBudgetCommand.Execute(null);
}
[RelayCommand]
private void OpenAddBudget()
{
if (parentViewModel is MainViewModel main) main.OpenAddBudgetCommand.Execute(null);
}
private void UpdateSpendingByCategoryChart(string period = "This Month")
{
var (start, end, _) = DateRangeService.Resolve(period);
var tempList = new List<ColumnChartData>();
foreach (var category in AppData.Categories)
{
var categoryTransactions =
AppData.Transactions.Where(x => x.CategoryId == category.Id && x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase));
var txns = AppData.Transactions
.Where(x => x.CategoryId == category.Id
&& x.Type.Equals("expense", StringComparison.OrdinalIgnoreCase)
&& (start is null || x.Date.Date >= start.Value)
&& (end is null || x.Date.Date <= end.Value));
switch (period)
var total = txns.Sum(x => x.ConvertedAmount);
if (total == 0) continue;
tempList.Add(new ColumnChartData
{
case ChartTimePeriod.ThisMonth:
categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.Month);
break;
case ChartTimePeriod.LastMonth:
categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.AddMonths(-1).Month);
break;
case ChartTimePeriod.ThisQuarter:
categoryTransactions = categoryTransactions.Where(x =>
x.Date.Month >= DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3).Month &&
x.Date.Month <= DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3).AddMonths(3).Month);
break;
case ChartTimePeriod.ThisYear:
categoryTransactions = categoryTransactions.Where(x => x.Date.Year == DateTime.Now.Year);
break;
default:
categoryTransactions = categoryTransactions.Where(x => x.Date.Month == DateTime.Now.Month);
break;
}
var balance = categoryTransactions.Sum(x => x.ConvertedAmount);
if (balance == 0) continue;
tempList.Add(new ColumnChartData()
{ id = category.Id, Name = category.Name, Values = [(double)balance], Fill = new SolidColorPaint(SKColor.Parse(category.Color)) });
id = category.Id,
Name = category.Name,
Values = [(double)total],
Fill = new SolidColorPaint(SKColor.Parse(category.Color))
});
}
tempList = tempList.OrderByDescending(x => x.Values[0]).ToList();
@@ -241,7 +231,7 @@ public partial class DashboardViewModel : ViewModelBase
private void UpdateRecentTransactions()
{
RecentTransactions = new ObservableCollection<Transaction>(AppData.Transactions.OrderByDescending(x => x.Date).Take(5));
RecentTransactions = new ObservableCollection<Transaction>(AppData.Transactions.Where(x => !x.IsTransfer).OrderByDescending(x => x.Date).Take(5));
OnPropertyChanged(nameof(HasTransactionData));
}
@@ -249,25 +239,17 @@ public partial class DashboardViewModel : ViewModelBase
{
TotalNetworth = 0;
var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD";
foreach (var account in AppData.Accounts)
foreach (var account in AppData.Accounts.Where(a => !a.IsArchived))
{
var accountTransactions = AppData.Transactions.Where(t => t.AccountId == account.Id).ToList();
account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type == "income" ? t.Amount : -t.Amount);
account.CurrentBalance = account.OpeningBalance + accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.Amount : -t.Amount);
if (account.Currency.Equals(primaryCurrency, StringComparison.OrdinalIgnoreCase))
TotalNetworth += account.CurrentBalance;
else
TotalNetworth += accountTransactions.Sum(t => t.Type == "income" ? t.ConvertedAmount : -t.ConvertedAmount);
TotalNetworth += accountTransactions.Sum(t => t.Type is "income" or "transfer_in" ? t.ConvertedAmount : -t.ConvertedAmount);
}
AccountsSummaryData = new ObservableCollection<Account>(AppData.Accounts.OrderBy(x => x.CreatedAt));
AccountsSummaryData = new ObservableCollection<Account>(AppData.Accounts.Where(a => !a.IsArchived).OrderBy(x => x.CreatedAt));
OnPropertyChanged(nameof(AccountsSubtitle));
}
private enum ChartTimePeriod
{
ThisMonth,
LastMonth,
ThisQuarter,
ThisYear
}
}

View File

@@ -12,7 +12,7 @@ namespace Clario.ViewModels;
public partial class DeleteAccountDialogViewModel : ViewModelBase
{
// ── State machine ────────────────────────────────────────
// State machine
public enum DialogStep
{
SimpleConfirm,
@@ -31,7 +31,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase
public bool IsHasTransactionsStep => CurrentStep == DialogStep.HasTransactions;
public bool IsMigrateStep => CurrentStep == DialogStep.Migrate;
// ── Data ─────────────────────────────────────────────────
// Data
[ObservableProperty] private Account? _account;
public GeneralDataRepo AppData => DataRepo.General;
@@ -40,7 +40,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection<Account> _availableAccounts = new();
// ── Validation ───────────────────────────────────────────
// Validation
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
@@ -50,11 +50,11 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase
TargetAccount is not null &&
TargetAccount.Id != Account?.Id;
// ── Callbacks ────────────────────────────────────────────
// Callbacks
public Action? OnDeleted;
public Action? OnCancelled;
// ── Setup ────────────────────────────────────────────────
// Setup
/// <summary>
/// Call this to open the dialog for a specific account.
@@ -79,7 +79,7 @@ public partial class DeleteAccountDialogViewModel : ViewModelBase
: DialogStep.SimpleConfirm;
}
// ── Commands ─────────────────────────────────────────────
// Commands
[RelayCommand]
private void Cancel() => OnCancelled?.Invoke();

View File

@@ -1,8 +1,10 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System;
using Avalonia.Xaml.Interactivity;
using Clario.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Clario.ViewModels;
public partial class LoadingViewModel : ViewModelBase
{
}

View File

@@ -21,6 +21,9 @@ public partial class MainViewModel : ViewModelBase
public TransactionsViewModel _transactionsViewModel = null!;
private AccountsViewModel _accountsViewModel = null!;
private BudgetViewModel _budgetViewModel = null!;
private CategoriesViewModel _categoriesViewModel = null!;
private AnalyticsViewModel _analyticsViewModel = null!;
private MoreViewModel _moreViewModel = null!;
GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private Profile? _profile;
@@ -28,18 +31,24 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty] private TransactionFormViewModel _transactionFormViewModel = null!;
[ObservableProperty] private AccountFormViewModel _accountFormViewModel = null!;
[ObservableProperty] private BudgetFormViewModel _budgetFormViewModel = null!;
[ObservableProperty] private CategoryFormViewModel _categoryFormViewModel = null!;
[ObservableProperty] private SettingsViewModel _settingsViewModel = null!;
[ObservableProperty] private SetSavingsGoalDialogViewModel _setSavingsGoalDialogViewModel = null!;
[ObservableProperty] private bool _isDimmed;
[ObservableProperty] private bool _isMessageBoxVisible;
[ObservableProperty] private MessageBoxViewModel _messageBoxViewModel = new();
[ObservableProperty] private bool _isTransactionFormVisible;
[ObservableProperty] private bool _isAccountFormVisible;
[ObservableProperty] private bool _isBudgetFormVisible;
[ObservableProperty] private bool _isCategoryFormVisible;
[ObservableProperty] private bool _isSavingsGoalDialogVisible;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnSettings))]
[NotifyPropertyChangedFor(nameof(isOnDashboard), nameof(isOnTransactions), nameof(isOnAccounts), nameof(isOnBudget), nameof(isOnCategories), nameof(isOnAnalytics),
nameof(isOnSettings), nameof(isOnMore))]
private ViewModelBase? _currentView;
[ObservableProperty] private bool _isDarkTheme;
@@ -47,13 +56,13 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
DebugLogger.Log("main vm loaded");
WeakReferenceMessenger.Default.Register<ProfileUpdated>(this, (_, m) =>
WeakReferenceMessenger.Default.Register<ProfileUpdated>(this, (s, m) =>
{
Profile = AppData.Profile;
_ = DataRepo.General.RefreshLiveRatesAndEnrich();
});
IsDimmed = true;
CurrentView = new LoadingViewModel();
CurrentView = new DashboardSkeletonViewModel();
_ = InitializeApp();
}
@@ -70,12 +79,12 @@ public partial class MainViewModel : ViewModelBase
var accountsTask = DataRepo.General.FetchAccounts();
var budgetsTask = DataRepo.General.FetchBudgets();
await Task.WhenAll(profilesTask, categoriesTask, accountsTask, transactionsTask, budgetsTask);
Profile = profilesTask.Result;
DataRepo.General.LinkTransactionCategories();
await DataRepo.General.RefreshLiveRatesAndEnrich();
DebugLogger.Log("fetched all data");
});
@@ -86,12 +95,13 @@ public partial class MainViewModel : ViewModelBase
parentViewModel = this
};
DebugLogger.Log("initialized DashboardViewModel");
_transactionsViewModel = new TransactionsViewModel()
{
parentViewModel = this
};
DebugLogger.Log("initialized TransactionsViewModel");
_accountsViewModel = new AccountsViewModel()
{
parentViewModel = this
@@ -103,6 +113,21 @@ public partial class MainViewModel : ViewModelBase
parentViewModel = this
};
DebugLogger.Log("initialized BudgetViewModel");
_categoriesViewModel = new CategoriesViewModel()
{
parentViewModel = this
};
DebugLogger.Log("initialized CategoriesViewModel");
_moreViewModel = new MoreViewModel()
{
parentViewModel = this
};
DebugLogger.Log("initialized MoreViewModel");
_analyticsViewModel = new AnalyticsViewModel()
{
parentViewModel = this
};
DebugLogger.Log("initialized AnalyticsViewModel");
SettingsViewModel = new SettingsViewModel()
{
parentViewModel = this
@@ -112,6 +137,8 @@ public partial class MainViewModel : ViewModelBase
{
parentViewModel = this
};
TransactionFormViewModel.OnOpenCategoryForm = OpenAddCategoryFromTransactionForm;
TransactionFormViewModel.OnOpenEditCategoryForm = OpenEditCategoryFromTransactionForm;
DebugLogger.Log("initialized TransactionFormViewModel");
AccountFormViewModel = new AccountFormViewModel()
{
@@ -123,12 +150,18 @@ public partial class MainViewModel : ViewModelBase
parentViewModel = this
};
DebugLogger.Log("initialized BudgetFormViewModel");
CategoryFormViewModel = new CategoryFormViewModel()
{
parentViewModel = this
};
DebugLogger.Log("initialized CategoryFormViewModel");
SetSavingsGoalDialogViewModel = new SetSavingsGoalDialogViewModel();
DebugLogger.Log("initialized SetSavingsGoalDialogViewModel");
IsDarkTheme = ThemeService.IsDarkTheme;
ThemeService.SwitchToTheme(AppData.Profile?.Theme ?? "system");
AppData.StartRealtimeSync();
CurrentView = _dashboardViewModel;
IsDimmed = false;
}
@@ -138,6 +171,24 @@ public partial class MainViewModel : ViewModelBase
}
}
/// <summary>Shows a themed message box overlay. Safe to call from any child ViewModel.</summary>
public void ShowMessage(MessageType type, string title, string message)
{
MessageBoxViewModel.Type = type;
MessageBoxViewModel.Title = title;
MessageBoxViewModel.Message = message;
MessageBoxViewModel.OnClose = CloseMessageBox;
IsMessageBoxVisible = true;
IsDimmed = true;
}
[RelayCommand]
private void CloseMessageBox()
{
IsMessageBoxVisible = false;
IsDimmed = false;
}
[RelayCommand]
public void OpenAddTransaction()
{
@@ -268,6 +319,78 @@ public partial class MainViewModel : ViewModelBase
IsBudgetFormVisible = false;
}
private void OpenEditCategoryFromTransactionForm(Category category)
{
CategoryFormViewModel.SetupForEdit(category);
CategoryFormViewModel.OnSaved = () =>
{
TransactionFormViewModel.Categories = AppData.Categories;
// Keep the selected category in sync after edit
var updated = AppData.Categories.FirstOrDefault(c => c.Id == category.Id);
if (updated is not null) TransactionFormViewModel.SelectedCategory = updated;
CloseCategoryForm();
};
CategoryFormViewModel.OnCancelled = CloseCategoryForm;
CategoryFormViewModel.OnDeleted = () =>
{
TransactionFormViewModel.Categories = AppData.Categories;
TransactionFormViewModel.SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Type == TransactionFormViewModel.Type);
CloseCategoryForm();
};
IsCategoryFormVisible = true;
}
// Called by the plus button inside TransactionFormView
private void OpenAddCategoryFromTransactionForm()
{
CategoryFormViewModel.SetupForAdd();
CategoryFormViewModel.OnSaved = () =>
{
// Refresh the category list in the transaction form after adding
TransactionFormViewModel.Categories = AppData.Categories;
CloseCategoryForm();
};
CategoryFormViewModel.OnCancelled = CloseCategoryForm;
CategoryFormViewModel.OnDeleted = () =>
{
TransactionFormViewModel.Categories = AppData.Categories;
CloseCategoryForm();
};
IsCategoryFormVisible = true;
}
[RelayCommand]
public void OpenAddCategory()
{
if (IsDimmed) return;
CategoryFormViewModel.SetupForAdd();
CategoryFormViewModel.OnSaved = CloseCategoryForm;
CategoryFormViewModel.OnCancelled = CloseCategoryForm;
CategoryFormViewModel.OnDeleted = CloseCategoryForm;
IsCategoryFormVisible = true;
IsDimmed = true;
}
[RelayCommand]
public void OpenEditCategory(Category category)
{
if (IsDimmed) return;
CategoryFormViewModel.SetupForEdit(category);
CategoryFormViewModel.OnSaved = CloseCategoryForm;
CategoryFormViewModel.OnCancelled = CloseCategoryForm;
CategoryFormViewModel.OnDeleted = CloseCategoryForm;
IsCategoryFormVisible = true;
IsDimmed = true;
}
private void CloseCategoryForm()
{
IsCategoryFormVisible = false;
// Only clear the dim if no other modal is open
if (!IsTransactionFormVisible)
IsDimmed = false;
}
[RelayCommand]
public void OpenEditSavingsGoal()
{
@@ -316,6 +439,24 @@ public partial class MainViewModel : ViewModelBase
CurrentView = _budgetViewModel;
}
[RelayCommand]
private void GoToCategories()
{
CurrentView = _categoriesViewModel;
}
[RelayCommand]
private void GoToMore()
{
CurrentView = _moreViewModel;
}
[RelayCommand]
private void GoToAnalytics()
{
CurrentView = _analyticsViewModel;
}
[RelayCommand]
private void GoToSettings()
{
@@ -339,9 +480,74 @@ public partial class MainViewModel : ViewModelBase
}
}
/// <summary>Returns true if the back event was handled (suppress system back), false to let the system close the app.</summary>
public bool HandleBackNavigation()
{
// 1. Close deepest-nested modal first (category form sits on top of transaction form)
if (IsCategoryFormVisible)
{
CloseCategoryForm();
return true;
}
if (IsTransactionFormVisible)
{
CloseTransactionForm();
return true;
}
if (IsAccountFormVisible)
{
CloseAccountForm();
return true;
}
if (IsBudgetFormVisible)
{
CloseBudgetForm();
return true;
}
if (IsSavingsGoalDialogVisible)
{
CloseSavingsGoalDialog();
return true;
}
// 2. Close dialogs inside AccountsView
if (_accountsViewModel is { IsDeleteDialogVisible: true })
{
_accountsViewModel.IsDeleteDialogVisible = false;
return true;
}
if (_accountsViewModel is { IsArchiveDialogVisible: true })
{
_accountsViewModel.IsArchiveDialogVisible = false;
return true;
}
// 3. Close AccountsView bottom sheet
if (_accountsViewModel?.TryCloseSheet?.Invoke() == true)
return true;
// 4. Navigate back to dashboard from any non-dashboard main view
if (!isOnDashboard)
{
CurrentView = _dashboardViewModel;
return true;
}
// 5. Already on dashboard — let the system handle (closes the app)
return false;
}
public bool isOnDashboard => CurrentView is DashboardViewModel;
public bool isOnTransactions => CurrentView is TransactionsViewModel;
public bool isOnAccounts => CurrentView is AccountsViewModel;
public bool isOnBudget => CurrentView is BudgetViewModel;
public bool isOnCategories => CurrentView is CategoriesViewModel;
public bool isOnAnalytics => CurrentView is AnalyticsViewModel;
public bool isOnSettings => CurrentView is SettingsViewModel;
public bool isOnMore => CurrentView is MoreViewModel or AnalyticsViewModel or BudgetViewModel or CategoriesViewModel;
}

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