diff --git a/Clario.Android/Clario.Android.csproj b/Clario.Android/Clario.Android.csproj
index a97e0a7..76c6176 100644
--- a/Clario.Android/Clario.Android.csproj
+++ b/Clario.Android/Clario.Android.csproj
@@ -20,6 +20,7 @@
+
diff --git a/Clario.Browser/Clario.Browser.csproj b/Clario.Browser/Clario.Browser.csproj
index 49cb37a..7279f1f 100644
--- a/Clario.Browser/Clario.Browser.csproj
+++ b/Clario.Browser/Clario.Browser.csproj
@@ -13,6 +13,7 @@
+
diff --git a/Clario.Desktop/Clario.Desktop.csproj b/Clario.Desktop/Clario.Desktop.csproj
index 41f803f..7b010b8 100644
--- a/Clario.Desktop/Clario.Desktop.csproj
+++ b/Clario.Desktop/Clario.Desktop.csproj
@@ -22,6 +22,7 @@
+
diff --git a/Clario.iOS/Clario.iOS.csproj b/Clario.iOS/Clario.iOS.csproj
index bf8b569..92cc263 100644
--- a/Clario.iOS/Clario.iOS.csproj
+++ b/Clario.iOS/Clario.iOS.csproj
@@ -13,6 +13,7 @@
+
diff --git a/Clario/Assets/Icons/bike.svg b/Clario/Assets/Icons/bike.svg
new file mode 100644
index 0000000..403139a
--- /dev/null
+++ b/Clario/Assets/Icons/bike.svg
@@ -0,0 +1,16 @@
+
diff --git a/Clario/Assets/Icons/book-open.svg b/Clario/Assets/Icons/book-open.svg
new file mode 100644
index 0000000..6906352
--- /dev/null
+++ b/Clario/Assets/Icons/book-open.svg
@@ -0,0 +1,14 @@
+
diff --git a/Clario/Assets/Icons/bus.svg b/Clario/Assets/Icons/bus.svg
new file mode 100644
index 0000000..9fdef2d
--- /dev/null
+++ b/Clario/Assets/Icons/bus.svg
@@ -0,0 +1,19 @@
+
diff --git a/Clario/Assets/Icons/camera.svg b/Clario/Assets/Icons/camera.svg
new file mode 100644
index 0000000..1e82bf4
--- /dev/null
+++ b/Clario/Assets/Icons/camera.svg
@@ -0,0 +1,14 @@
+
diff --git a/Clario/Assets/Icons/coffee.svg b/Clario/Assets/Icons/coffee.svg
new file mode 100644
index 0000000..52d4da3
--- /dev/null
+++ b/Clario/Assets/Icons/coffee.svg
@@ -0,0 +1,16 @@
+
diff --git a/Clario/Assets/Icons/dumbbell.svg b/Clario/Assets/Icons/dumbbell.svg
new file mode 100644
index 0000000..4aa46a2
--- /dev/null
+++ b/Clario/Assets/Icons/dumbbell.svg
@@ -0,0 +1,17 @@
+
diff --git a/Clario/Assets/Icons/film.svg b/Clario/Assets/Icons/film.svg
new file mode 100644
index 0000000..fae7568
--- /dev/null
+++ b/Clario/Assets/Icons/film.svg
@@ -0,0 +1,20 @@
+
diff --git a/Clario/Assets/Icons/gift.svg b/Clario/Assets/Icons/gift.svg
new file mode 100644
index 0000000..c33b33f
--- /dev/null
+++ b/Clario/Assets/Icons/gift.svg
@@ -0,0 +1,16 @@
+
diff --git a/Clario/Assets/Icons/graduation-cap.svg b/Clario/Assets/Icons/graduation-cap.svg
new file mode 100644
index 0000000..1c0009b
--- /dev/null
+++ b/Clario/Assets/Icons/graduation-cap.svg
@@ -0,0 +1,15 @@
+
diff --git a/Clario/Assets/Icons/headphones.svg b/Clario/Assets/Icons/headphones.svg
new file mode 100644
index 0000000..4cee5b5
--- /dev/null
+++ b/Clario/Assets/Icons/headphones.svg
@@ -0,0 +1,13 @@
+
diff --git a/Clario/Assets/Icons/leaf.svg b/Clario/Assets/Icons/leaf.svg
new file mode 100644
index 0000000..f322ef0
--- /dev/null
+++ b/Clario/Assets/Icons/leaf.svg
@@ -0,0 +1,14 @@
+
diff --git a/Clario/Assets/Icons/music.svg b/Clario/Assets/Icons/music.svg
new file mode 100644
index 0000000..dd9faac
--- /dev/null
+++ b/Clario/Assets/Icons/music.svg
@@ -0,0 +1,15 @@
+
diff --git a/Clario/Assets/Icons/package.svg b/Clario/Assets/Icons/package.svg
new file mode 100644
index 0000000..308b1f4
--- /dev/null
+++ b/Clario/Assets/Icons/package.svg
@@ -0,0 +1,16 @@
+
diff --git a/Clario/Assets/Icons/pizza.svg b/Clario/Assets/Icons/pizza.svg
new file mode 100644
index 0000000..796e5cc
--- /dev/null
+++ b/Clario/Assets/Icons/pizza.svg
@@ -0,0 +1,17 @@
+
diff --git a/Clario/Assets/Icons/plane.svg b/Clario/Assets/Icons/plane.svg
new file mode 100644
index 0000000..6f843bf
--- /dev/null
+++ b/Clario/Assets/Icons/plane.svg
@@ -0,0 +1,13 @@
+
diff --git a/Clario/Assets/Icons/scissors.svg b/Clario/Assets/Icons/scissors.svg
new file mode 100644
index 0000000..f87bf20
--- /dev/null
+++ b/Clario/Assets/Icons/scissors.svg
@@ -0,0 +1,17 @@
+
diff --git a/Clario/Assets/Icons/shirt.svg b/Clario/Assets/Icons/shirt.svg
new file mode 100644
index 0000000..90bba75
--- /dev/null
+++ b/Clario/Assets/Icons/shirt.svg
@@ -0,0 +1,13 @@
+
diff --git a/Clario/Assets/Icons/smartphone.svg b/Clario/Assets/Icons/smartphone.svg
new file mode 100644
index 0000000..fc84017
--- /dev/null
+++ b/Clario/Assets/Icons/smartphone.svg
@@ -0,0 +1,14 @@
+
diff --git a/Clario/Assets/Icons/stethoscope.svg b/Clario/Assets/Icons/stethoscope.svg
new file mode 100644
index 0000000..1d26e38
--- /dev/null
+++ b/Clario/Assets/Icons/stethoscope.svg
@@ -0,0 +1,17 @@
+
diff --git a/Clario/Assets/Icons/train-front.svg b/Clario/Assets/Icons/train-front.svg
new file mode 100644
index 0000000..d340f46
--- /dev/null
+++ b/Clario/Assets/Icons/train-front.svg
@@ -0,0 +1,18 @@
+
diff --git a/Clario/Assets/Icons/tv.svg b/Clario/Assets/Icons/tv.svg
new file mode 100644
index 0000000..96140a5
--- /dev/null
+++ b/Clario/Assets/Icons/tv.svg
@@ -0,0 +1,14 @@
+
diff --git a/Clario/Assets/Icons/wine.svg b/Clario/Assets/Icons/wine.svg
new file mode 100644
index 0000000..fbddb1d
--- /dev/null
+++ b/Clario/Assets/Icons/wine.svg
@@ -0,0 +1,16 @@
+
diff --git a/Clario/Assets/Icons/wrench.svg b/Clario/Assets/Icons/wrench.svg
new file mode 100644
index 0000000..3624343
--- /dev/null
+++ b/Clario/Assets/Icons/wrench.svg
@@ -0,0 +1,13 @@
+
diff --git a/Clario/Clario.csproj b/Clario/Clario.csproj
index 8413ec0..2eb9283 100644
--- a/Clario/Clario.csproj
+++ b/Clario/Clario.csproj
@@ -1,6 +1,6 @@
- net8.0
+ net8.0;net8.0-android
enable
latest
true
@@ -13,8 +13,8 @@
-
-
+
+
@@ -23,31 +23,38 @@
All
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
- MobileMainView.axaml
- Code
-
-
- AccountFormView.axaml
-
+
+ MobileMainView.axaml
+ Code
+
+
+ AccountFormView.axaml
+
+
+ CategoryFormView.axaml
+
+
+ AnalyticsView.axaml
+
-
-
- Always
-
+
+
+ Always
+
diff --git a/Clario/Data/GeneralDataRepo.cs b/Clario/Data/GeneralDataRepo.cs
index a6882ab..80e482a 100644
--- a/Clario/Data/GeneralDataRepo.cs
+++ b/Clario/Data/GeneralDataRepo.cs
@@ -129,14 +129,178 @@ 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().Insert(outTx);
+ var inResult = await SupabaseService.Client.From().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().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()
+ .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> FetchCategories(bool forceRefresh = false)
{
if (Categories.Count != 0 && !forceRefresh) return Categories.ToList();
+
var categories = await SupabaseService.Client.From().Get();
Categories = new ObservableCollection(categories.Models);
return categories.Models;
}
+ public async Task InsertCategory(Category category)
+ {
+ try
+ {
+ var result = await SupabaseService.Client.From()
+ .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().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().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> FetchAccounts(bool forceRefresh = false)
{
if (Accounts.Count != 0 && !forceRefresh) return Accounts.ToList();
@@ -294,7 +458,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 +563,20 @@ public partial class GeneralDataRepo : ObservableObject
WeakReferenceMessenger.Default.Send(new RatesRefreshed());
}
+ /// Converts from to
+ /// using the current live rates.
+ /// Falls back to 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 +619,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)
diff --git a/Clario/Enums/AuthError.cs b/Clario/Enums/AuthError.cs
new file mode 100644
index 0000000..636f62b
--- /dev/null
+++ b/Clario/Enums/AuthError.cs
@@ -0,0 +1,14 @@
+namespace Clario.Enums;
+
+public enum AuthError
+{
+ InvalidCredentials,
+ EmailAlreadyExists,
+ EmailNotConfirmed,
+ WeakPassword,
+ InvalidEmail,
+ SignupDisabled,
+ RateLimited,
+ SessionExpired,
+ Unknown
+}
\ No newline at end of file
diff --git a/Clario/MobileViews/AccountFormViewMobile.axaml b/Clario/MobileViews/AccountFormViewMobile.axaml
new file mode 100644
index 0000000..780d2a1
--- /dev/null
+++ b/Clario/MobileViews/AccountFormViewMobile.axaml
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/AccountFormViewMobile.axaml.cs b/Clario/MobileViews/AccountFormViewMobile.axaml.cs
new file mode 100644
index 0000000..c477662
--- /dev/null
+++ b/Clario/MobileViews/AccountFormViewMobile.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.MobileViews;
+
+public partial class AccountFormViewMobile : UserControl
+{
+ public AccountFormViewMobile()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/MobileViews/AccountsViewMobile.axaml b/Clario/MobileViews/AccountsViewMobile.axaml
index cdd95e1..5193166 100644
--- a/Clario/MobileViews/AccountsViewMobile.axaml
+++ b/Clario/MobileViews/AccountsViewMobile.axaml
@@ -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"
@@ -30,14 +31,24 @@
FontWeight="Bold"
Foreground="{DynamicResource TextPrimary}" />
-
+
+
+
+
@@ -70,11 +81,17 @@
Foreground="{DynamicResource TextPrimary}"
VerticalAlignment="Center" />
+ VerticalAlignment="Center">
+
+
+
+
+
+
+
@@ -119,10 +136,23 @@
-
+
+
+
+
+
+
-
-
@@ -239,20 +269,32 @@
FontSize="12"
Foreground="{DynamicResource TextMuted}" />
-
-
+
+
+
+
+
@@ -269,7 +311,7 @@
Padding="16,14">
-
@@ -329,7 +371,7 @@
-
-
-
+ Padding="10,8"
+ Command="{Binding CreateBudgetCommand}">
@@ -93,10 +95,16 @@
-
+ Foreground="{DynamicResource TextPrimary}">
+
+
+
+
+
+
+
@@ -112,10 +120,16 @@
-
+ Foreground="{DynamicResource AccentRed}">
+
+
+
+
+
+
+
@@ -248,14 +262,16 @@
Foreground="{DynamicResource TextMuted}"
HorizontalAlignment="Right" />
-
+
-
@@ -419,7 +435,8 @@
+ Padding="4"
+ Command="{Binding EditSavingsGoalCommand}">
@@ -427,8 +444,15 @@
-
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/CategoryFormViewMobile.axaml b/Clario/MobileViews/CategoryFormViewMobile.axaml
new file mode 100644
index 0000000..c1497a4
--- /dev/null
+++ b/Clario/MobileViews/CategoryFormViewMobile.axaml
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/CategoryFormViewMobile.axaml.cs b/Clario/MobileViews/CategoryFormViewMobile.axaml.cs
new file mode 100644
index 0000000..09dae62
--- /dev/null
+++ b/Clario/MobileViews/CategoryFormViewMobile.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.MobileViews;
+
+public partial class CategoryFormViewMobile : UserControl
+{
+ public CategoryFormViewMobile()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/MobileViews/DashboardViewMobile.axaml b/Clario/MobileViews/DashboardViewMobile.axaml
index 6de6328..20528e4 100644
--- a/Clario/MobileViews/DashboardViewMobile.axaml
+++ b/Clario/MobileViews/DashboardViewMobile.axaml
@@ -1,4 +1,4 @@
-
+ x:Class="Clario.MobileViews.DashboardViewMobile"
+ x:Name="DashboardRoot"
+ Classes="mobile">
-
+
-
+
-
+
+
+
+
+
+
-
+
@@ -61,22 +69,22 @@
-
-
+
-
+
+
+
+
+
+
+
+
@@ -95,22 +103,22 @@
-
-
+
-
+
+
+
+
+
+
+
+
@@ -129,13 +137,10 @@
-
-
+
@@ -170,75 +174,100 @@
-
+
+ Padding="8,5" FontSize="12" />
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -252,81 +281,92 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+ CornerRadius="10">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -339,57 +379,75 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -402,8 +460,7 @@
@@ -423,8 +480,7 @@
Background="{DynamicResource BgBase}"
Margin="12,10">
+ FontSize="11" Classes="muted" />
@@ -459,17 +512,20 @@
+ FontSize="17" FontWeight="Bold"
+ Foreground="{DynamicResource TextPrimary}">
+
+
+
+
+
+
+
-
@@ -477,4 +533,4 @@
-
\ No newline at end of file
+
diff --git a/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml
new file mode 100644
index 0000000..5093ca0
--- /dev/null
+++ b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml
@@ -0,0 +1,454 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs
new file mode 100644
index 0000000..0c35ce5
--- /dev/null
+++ b/Clario/MobileViews/DeleteAccountDialogViewMobile.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.MobileViews;
+
+public partial class DeleteAccountDialogViewMobile : UserControl
+{
+ public DeleteAccountDialogViewMobile()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/MobileViews/MainViewMobile.axaml b/Clario/MobileViews/MainViewMobile.axaml
index 1221ea6..3d5911c 100644
--- a/Clario/MobileViews/MainViewMobile.axaml
+++ b/Clario/MobileViews/MainViewMobile.axaml
@@ -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,6 +18,18 @@
DataContext="{Binding TransactionFormViewModel}"
IsVisible="{Binding DataContext.IsTransactionFormVisible,ElementName=MainControl}">
+
+
+
+
diff --git a/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml
new file mode 100644
index 0000000..f4525d4
--- /dev/null
+++ b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs
new file mode 100644
index 0000000..c72167f
--- /dev/null
+++ b/Clario/MobileViews/SetSavingsGoalDialogViewMobile.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.MobileViews;
+
+public partial class SetSavingsGoalDialogViewMobile : UserControl
+{
+ public SetSavingsGoalDialogViewMobile()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/MobileViews/SettingsViewMobile.axaml b/Clario/MobileViews/SettingsViewMobile.axaml
new file mode 100644
index 0000000..f0c9220
--- /dev/null
+++ b/Clario/MobileViews/SettingsViewMobile.axaml
@@ -0,0 +1,486 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/MobileViews/SettingsViewMobile.axaml.cs b/Clario/MobileViews/SettingsViewMobile.axaml.cs
new file mode 100644
index 0000000..4a44746
--- /dev/null
+++ b/Clario/MobileViews/SettingsViewMobile.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.MobileViews;
+
+public partial class SettingsViewMobile : UserControl
+{
+ public SettingsViewMobile()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/MobileViews/TransactionFormViewMobile.axaml b/Clario/MobileViews/TransactionFormViewMobile.axaml
index d4e59f8..58f2b61 100644
--- a/Clario/MobileViews/TransactionFormViewMobile.axaml
+++ b/Clario/MobileViews/TransactionFormViewMobile.axaml
@@ -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"
@@ -53,8 +54,8 @@
-
+
-
+
+ Width="14" Height="14" />
@@ -122,11 +123,31 @@
Focusable="False"
Command="{Binding SetTypeCommand}"
CommandParameter="income">
-
+
+ Width="14" Height="14" />
+
+
+
+
+
+
+
@@ -144,7 +165,7 @@
Height="64">
+
+
+
-
-
+
+
+ Margin="0,0,0,16"
+ IsVisible="{Binding !IsTransfer}" />
-
-
+
+
@@ -260,6 +287,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Width="340">
diff --git a/Clario/MobileViews/TransactionsViewMobile.axaml b/Clario/MobileViews/TransactionsViewMobile.axaml
index 17f0b95..eee4e95 100644
--- a/Clario/MobileViews/TransactionsViewMobile.axaml
+++ b/Clario/MobileViews/TransactionsViewMobile.axaml
@@ -7,6 +7,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Clario.MobileViews.TransactionsViewMobile"
x:DataType="vm:TransactionsViewModel"
+ x:Name="transactionsRoot"
Classes="mobile">
@@ -37,8 +38,7 @@
BorderThickness="1"
CornerRadius="16"
Padding="20"
- Width="300"
- BoxShadow="0 8 32 0 #3C000000">
+ Width="300">
-
+
+
+
+
@@ -282,69 +294,98 @@
Classes="label" />
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/Clario/Models/CategorySpendRow.cs b/Clario/Models/CategorySpendRow.cs
new file mode 100644
index 0000000..01b4c7c
--- /dev/null
+++ b/Clario/Models/CategorySpendRow.cs
@@ -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}%";
+}
diff --git a/Clario/Models/Transaction.cs b/Clario/Models/Transaction.cs
index 998dc01..2d3587e 100644
--- a/Clario/Models/Transaction.cs
+++ b/Clario/Models/Transaction.cs
@@ -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;
}
\ No newline at end of file
diff --git a/Clario/Services/PdfExportService.cs b/Clario/Services/PdfExportService.cs
new file mode 100644
index 0000000..7e05b04
--- /dev/null
+++ b/Clario/Services/PdfExportService.cs
@@ -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 ExportAsync(
+ GeneralDataRepo data,
+ DateTime start,
+ DateTime end,
+ string periodLabel,
+ List 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 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;
+ }
+}
diff --git a/Clario/ViewModels/AccountsViewModel.cs b/Clario/ViewModels/AccountsViewModel.cs
index 79823fd..3fc9507 100644
--- a/Clario/ViewModels/AccountsViewModel.cs
+++ b/Clario/ViewModels/AccountsViewModel.cs
@@ -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,56 @@ 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 _archivedAccounts = new();
+ public bool HasArchivedAccounts => ArchivedAccounts.Count > 0;
+
public AccountsViewModel()
{
AppData.Accounts.CollectionChanged += (_, _) => { Initialize(); };
+ AppData.Transactions.CollectionChanged += (_, _) => { 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 +79,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 +111,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 +129,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)
{
diff --git a/Clario/ViewModels/AnalyticsViewModel.cs b/Clario/ViewModels/AnalyticsViewModel.cs
new file mode 100644
index 0000000..02824af
--- /dev/null
+++ b/Clario/ViewModels/AnalyticsViewModel.cs
@@ -0,0 +1,436 @@
+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 SKColor = SkiaSharp.SKColor;
+
+namespace Clario.ViewModels;
+
+public partial class AnalyticsViewModel : ViewModelBase
+{
+ public required ViewModelBase parentViewModel;
+ public GeneralDataRepo AppData => DataRepo.General;
+
+ // ── Period ───────────────────────────────────────────
+ public List 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 _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()
+ {
+ AppData.Transactions.CollectionChanged += (_, _) => Initialize();
+ AppData.Accounts.CollectionChanged += (_, _) => 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 income, List 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
+ {
+ 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
+ {
+ 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")),
+ SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
+ TicksPaint = null,
+ TextSize = 11
+ }
+ ];
+
+ var sym = PrimarySymbol;
+ CashFlowYAxes =
+ [
+ new Axis
+ {
+ LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")),
+ 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
+ {
+ 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")),
+ SeparatorsPaint = new SolidColorPaint(new SKColor(30, 35, 48)),
+ TicksPaint = null,
+ TextSize = 11
+ }
+ ];
+
+ var sym = PrimarySymbol;
+ NetWorthYAxes =
+ [
+ new Axis
+ {
+ LabelsPaint = new SolidColorPaint(SKColor.Parse("#7A8090")),
+ 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 expenses, DateTime start, DateTime end)
+ {
+ // DayOfWeek: Sunday=0, Monday=1 ... Saturday=6
+ // We display Mon–Sun (index 0–6 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
+ {
+ 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")),
+ SeparatorsPaint = null,
+ TicksPaint = null,
+ TextSize = 11
+ }
+ ];
+ }
+
+ // ── Section 5: Top Categories ─────────────────────────
+
+ private void BuildTopCategories(List expenses)
+ {
+ var sym = PrimarySymbol;
+ var totalSpend = expenses.Sum(t => t.ConvertedAmount);
+ if (totalSpend == 0)
+ {
+ TopCategories = new ObservableCollection();
+ 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(grouped);
+ HasTopCategories = grouped.Count > 0;
+ }
+
+ // ── Section 6: Income Sources ─────────────────────────
+
+ private void BuildIncomeSourcesChart(List 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
+ {
+ 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;
+ }
+ }
+}
diff --git a/Clario/ViewModels/AuthViewModel.cs b/Clario/ViewModels/AuthViewModel.cs
index be89a07..579c2f5 100644
--- a/Clario/ViewModels/AuthViewModel.cs
+++ b/Clario/ViewModels/AuthViewModel.cs
@@ -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;
@@ -36,6 +38,11 @@ public partial class AuthViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(ConfirmCreateAccountCommand), nameof(ConfirmLoginCommand))]
private string _operation = "login";
+ [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))]
+ private string? _errorMessage;
+
+ public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
+
public AuthViewModel()
{
DebugLogger.Log("auth vm loaded");
@@ -64,11 +71,13 @@ public partial class AuthViewModel : ViewModelBase
private void SetOperation(string operation)
{
Operation = operation;
+ ErrorMessage = null;
}
[RelayCommand(CanExecute = nameof(canSignin))]
private async Task ConfirmLogin()
{
+ ErrorMessage = null;
try
{
await SupabaseService.Client.Auth.SignIn(_email, _password);
@@ -84,15 +93,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,12 +136,52 @@ 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";
diff --git a/Clario/ViewModels/BudgetViewModel.cs b/Clario/ViewModels/BudgetViewModel.cs
index e17726d..34ce8fd 100644
--- a/Clario/ViewModels/BudgetViewModel.cs
+++ b/Clario/ViewModels/BudgetViewModel.cs
@@ -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;
diff --git a/Clario/ViewModels/CategoryFormViewModel.cs b/Clario/ViewModels/CategoryFormViewModel.cs
new file mode 100644
index 0000000..87e2d50
--- /dev/null
+++ b/Clario/ViewModels/CategoryFormViewModel.cs
@@ -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 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));
+ }
+}
diff --git a/Clario/ViewModels/DashboardViewModel.cs b/Clario/ViewModels/DashboardViewModel.cs
index 57c83e7..1cdc6c7 100644
--- a/Clario/ViewModels/DashboardViewModel.cs
+++ b/Clario/ViewModels/DashboardViewModel.cs
@@ -178,6 +178,12 @@ public partial class DashboardViewModel : ViewModelBase
((MainViewModel)parentViewModel).OpenAddTransaction();
}
+ [RelayCommand]
+ private void NavigateToSettings()
+ {
+ ((MainViewModel)parentViewModel).GoToSettingsCommand.Execute(null);
+ }
+
private void UpdateSpendingByCategoryChart(ChartTimePeriod period = ChartTimePeriod.ThisMonth)
{
var tempList = new List();
@@ -241,7 +247,7 @@ public partial class DashboardViewModel : ViewModelBase
private void UpdateRecentTransactions()
{
- RecentTransactions = new ObservableCollection(AppData.Transactions.OrderByDescending(x => x.Date).Take(5));
+ RecentTransactions = new ObservableCollection(AppData.Transactions.Where(x => !x.IsTransfer).OrderByDescending(x => x.Date).Take(5));
OnPropertyChanged(nameof(HasTransactionData));
}
@@ -249,17 +255,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(AppData.Accounts.OrderBy(x => x.CreatedAt));
+ AccountsSummaryData = new ObservableCollection(AppData.Accounts.Where(a => !a.IsArchived).OrderBy(x => x.CreatedAt));
OnPropertyChanged(nameof(AccountsSubtitle));
}
diff --git a/Clario/ViewModels/MainViewModel.cs b/Clario/ViewModels/MainViewModel.cs
index d7b20e0..2e9bdb8 100644
--- a/Clario/ViewModels/MainViewModel.cs
+++ b/Clario/ViewModels/MainViewModel.cs
@@ -21,6 +21,7 @@ public partial class MainViewModel : ViewModelBase
public TransactionsViewModel _transactionsViewModel = null!;
private AccountsViewModel _accountsViewModel = null!;
private BudgetViewModel _budgetViewModel = null!;
+ private AnalyticsViewModel _analyticsViewModel = null!;
GeneralDataRepo AppData => DataRepo.General;
[ObservableProperty] private Profile? _profile;
@@ -28,6 +29,7 @@ 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!;
@@ -35,11 +37,12 @@ public partial class MainViewModel : ViewModelBase
[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(isOnAnalytics), nameof(isOnSettings))]
private ViewModelBase? _currentView;
[ObservableProperty] private bool _isDarkTheme;
@@ -103,6 +106,11 @@ public partial class MainViewModel : ViewModelBase
parentViewModel = this
};
DebugLogger.Log("initialized BudgetViewModel");
+ _analyticsViewModel = new AnalyticsViewModel()
+ {
+ parentViewModel = this
+ };
+ DebugLogger.Log("initialized AnalyticsViewModel");
SettingsViewModel = new SettingsViewModel()
{
parentViewModel = this
@@ -112,6 +120,8 @@ public partial class MainViewModel : ViewModelBase
{
parentViewModel = this
};
+ TransactionFormViewModel.OnOpenCategoryForm = OpenAddCategoryFromTransactionForm;
+ TransactionFormViewModel.OnOpenEditCategoryForm = OpenEditCategoryFromTransactionForm;
DebugLogger.Log("initialized TransactionFormViewModel");
AccountFormViewModel = new AccountFormViewModel()
{
@@ -123,6 +133,11 @@ 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");
@@ -268,6 +283,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 +403,12 @@ public partial class MainViewModel : ViewModelBase
CurrentView = _budgetViewModel;
}
+ [RelayCommand]
+ private void GoToAnalytics()
+ {
+ CurrentView = _analyticsViewModel;
+ }
+
[RelayCommand]
private void GoToSettings()
{
@@ -343,5 +436,6 @@ public partial class MainViewModel : ViewModelBase
public bool isOnTransactions => CurrentView is TransactionsViewModel;
public bool isOnAccounts => CurrentView is AccountsViewModel;
public bool isOnBudget => CurrentView is BudgetViewModel;
+ public bool isOnAnalytics => CurrentView is AnalyticsViewModel;
public bool isOnSettings => CurrentView is SettingsViewModel;
}
\ No newline at end of file
diff --git a/Clario/ViewModels/TransactionFormViewModel.cs b/Clario/ViewModels/TransactionFormViewModel.cs
index 6b8f509..45678c4 100644
--- a/Clario/ViewModels/TransactionFormViewModel.cs
+++ b/Clario/ViewModels/TransactionFormViewModel.cs
@@ -21,12 +21,13 @@ public partial class TransactionFormViewModel : ViewModelBase
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FormTitle), nameof(FormSubtitle), nameof(SaveButtonLabel))]
private bool _isEditMode = false;
- public string FormTitle => IsEditMode ? "Edit Transaction" : "New Transaction";
+ public string FormTitle => IsEditMode ? (IsTransfer ? "Edit Transfer" : "Edit Transaction") : (IsTransfer ? "New Transfer" : "New Transaction");
public string FormSubtitle => IsEditMode ? "Update the details below" : "Fill in the details below";
- public string SaveButtonLabel => IsEditMode ? "Save Changes" : "Save Transaction";
+ public string SaveButtonLabel => IsEditMode ? "Save Changes" : (IsTransfer ? "Save Transfer" : "Save Transaction");
// ── Fields ──────────────────────────────────────────────
- [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome), nameof(IsValid))]
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsExpense), nameof(IsIncome), nameof(IsTransfer), nameof(IsValid), nameof(FormTitle), nameof(SaveButtonLabel))]
private string _type = "expense";
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
@@ -38,6 +39,7 @@ public partial class TransactionFormViewModel : ViewModelBase
[ObservableProperty] private string? _note;
[ObservableProperty] private List _dates = [DateTime.Now];
[ObservableProperty] private DateTime? _selectedDate;
+
[ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrencySymbol))]
private string _currency = "USD";
@@ -56,6 +58,9 @@ public partial class TransactionFormViewModel : ViewModelBase
[ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
private Account? _selectedAccount;
+ [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsValid))]
+ private Account? _selectedToAccount;
+
[ObservableProperty] private ObservableCollection _categories = new();
[ObservableProperty] private ObservableCollection _accounts = new();
@@ -66,24 +71,28 @@ public partial class TransactionFormViewModel : ViewModelBase
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool IsExpense => Type == "expense";
public bool IsIncome => Type == "income";
+ public bool IsTransfer => Type == "transfer";
public bool IsValid =>
decimal.TryParse(Amount, out var amt) && amt > 0 &&
- !string.IsNullOrWhiteSpace(Description) &&
- SelectedCategory is not null &&
- SelectedAccount is not null &&
- Dates is not null;
+ Dates is not null &&
+ (IsTransfer
+ ? SelectedAccount is not null && SelectedToAccount is not null && SelectedAccount.Id != SelectedToAccount.Id
+ : !string.IsNullOrWhiteSpace(Description) && SelectedCategory is not null && SelectedAccount is not null);
// ── Callbacks ───────────────────────────────────────────
public Action? OnSaved;
public Action? OnCancelled;
public Action? OnDeleted;
+ public Action? OnOpenCategoryForm;
+ public Action? OnOpenEditCategoryForm;
[ObservableProperty] private bool _showDeleteConfirm = false;
// ── Edit mode: original transaction ─────────────────────
private Transaction? _editingTransaction;
private Guid? _editingId;
+ private Guid? _transferPairId;
private decimal _editingOriginalAmount;
private Guid? _editingOriginalCategoryId;
@@ -101,9 +110,10 @@ public partial class TransactionFormViewModel : ViewModelBase
public bool HasBudgetApproachingWarning => HasBudgetWarning && !BudgetWarningIsOverBudget;
// ── Commands ────────────────────────────────────────────
-
+
partial void OnSelectedCategoryChanged(Category? value)
{
+ if (value is null) return;
if (value.Type != Type) Type = value.Type;
CheckBudgetImpact();
}
@@ -120,6 +130,12 @@ public partial class TransactionFormViewModel : ViewModelBase
partial void OnTypeChanged(string value)
{
+ if (value == "transfer")
+ {
+ CheckBudgetImpact();
+ return;
+ }
+
if (value == SelectedCategory?.Type) return;
SelectedCategory = _categories.FirstOrDefault(c => c.Type == value);
CheckBudgetImpact();
@@ -127,6 +143,12 @@ public partial class TransactionFormViewModel : ViewModelBase
partial void OnSelectedAccountChanged(Account? value)
{
+ if (IsTransfer)
+ {
+ Currency = value?.Currency ?? "USD";
+ return;
+ }
+
var primaryCurrency = AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD";
var accountCurrency = value?.Currency ?? primaryCurrency;
Currency = accountCurrency;
@@ -136,6 +158,7 @@ public partial class TransactionFormViewModel : ViewModelBase
IsFetchingRate = true;
ExchangeRate = "";
}
+
ShowExchangeRateField = needsRate;
OnPropertyChanged(nameof(ExchangeRateLabel));
if (needsRate)
@@ -164,6 +187,7 @@ public partial class TransactionFormViewModel : ViewModelBase
BudgetWarningMessage = null;
BudgetWarningIsOverBudget = false;
+ if (IsTransfer) return;
if (Type != "expense") return;
if (SelectedCategory is null) return;
Debug.WriteLine(SelectedCategory.Name);
@@ -214,6 +238,16 @@ public partial class TransactionFormViewModel : ViewModelBase
}
}
+ [RelayCommand]
+ private void OpenCategoryForm() => OnOpenCategoryForm?.Invoke();
+
+ [RelayCommand]
+ private void OpenEditCategoryForm()
+ {
+ if (SelectedCategory is not null)
+ OnOpenEditCategoryForm?.Invoke(SelectedCategory);
+ }
+
[RelayCommand]
private void SetType(string type)
{
@@ -237,6 +271,37 @@ public partial class TransactionFormViewModel : ViewModelBase
return;
}
+ if (IsTransfer)
+ {
+ if (SelectedAccount is null || SelectedToAccount is null)
+ {
+ ErrorMessage = "Please select both accounts.";
+ return;
+ }
+
+ if (SelectedAccount.Id == SelectedToAccount.Id)
+ {
+ ErrorMessage = "From and To accounts must be different.";
+ return;
+ }
+
+ try
+ {
+ if (IsEditMode && _transferPairId.HasValue)
+ await DataRepo.General.UpdateTransfer(_transferPairId.Value, SelectedAccount.Id, SelectedToAccount.Id, amt, Dates.FirstOrDefault(), Note);
+ else
+ await DataRepo.General.InsertTransfer(SelectedAccount.Id, SelectedToAccount.Id, amt, Dates.FirstOrDefault(), Note);
+ OnSaved?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = "Something went wrong. Please try again.";
+ DebugLogger.Log(ex);
+ }
+
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(Description))
{
ErrorMessage = "Description is required.";
@@ -314,11 +379,14 @@ public partial class TransactionFormViewModel : ViewModelBase
[RelayCommand]
private async Task ConfirmDelete()
{
- if (!IsEditMode || !_editingId.HasValue) return;
+ if (!IsEditMode) return;
try
{
- await DataRepo.General.DeleteTransaction(_editingId.Value);
+ if (IsTransfer && _transferPairId.HasValue)
+ await DataRepo.General.DeleteTransfer(_transferPairId.Value);
+ else if (_editingId.HasValue)
+ await DataRepo.General.DeleteTransaction(_editingId.Value);
OnDeleted?.Invoke();
}
catch (Exception ex)
@@ -354,11 +422,12 @@ public partial class TransactionFormViewModel : ViewModelBase
ShowDeleteConfirm = false;
IsEditMode = false;
_editingId = null;
+ _transferPairId = null;
_editingOriginalAmount = 0;
_editingOriginalCategoryId = null;
Categories = AppData.Categories;
var sortedAccounts = new ObservableCollection(
- AppData.Accounts.OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt));
+ AppData.Accounts.Where(a => !a.IsArchived).OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt));
Accounts = sortedAccounts;
Type = "expense";
Amount = "";
@@ -368,6 +437,7 @@ public partial class TransactionFormViewModel : ViewModelBase
ErrorMessage = null;
SelectedCategory = AppData.Categories.Count > 0 ? AppData.Categories[0] : null;
SelectedAccount = sortedAccounts.Count > 0 ? sortedAccounts[0] : null;
+ SelectedToAccount = sortedAccounts.Count > 1 ? sortedAccounts[1] : null;
ShowExchangeRateField = false;
ExchangeRate = "";
IsFetchingRate = false;
@@ -377,8 +447,7 @@ public partial class TransactionFormViewModel : ViewModelBase
}
/// Call this to open the form for editing an existing transaction.
- public void SetupForEdit(
- Transaction transaction)
+ public void SetupForEdit(Transaction transaction)
{
ShowDeleteConfirm = false;
IsEditMode = true;
@@ -386,30 +455,55 @@ public partial class TransactionFormViewModel : ViewModelBase
_editingOriginalAmount = transaction.Amount;
_editingOriginalCategoryId = transaction.CategoryId;
Categories = AppData.Categories;
- Accounts = new ObservableCollection(
- AppData.Accounts.OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt));
- Type = transaction.Type;
+ var sortedAccounts = new ObservableCollection(
+ AppData.Accounts.Where(a => !a.IsArchived).OrderByDescending(a => a.IsPrimary).ThenBy(a => a.CreatedAt));
+ Accounts = sortedAccounts;
Amount = transaction.Amount.ToString("0.00");
- Description = transaction.Description;
Note = transaction.Note;
Dates = [transaction.Date];
ErrorMessage = null;
- SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId)
- ?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null);
- SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId)
- ?? (AppData.Accounts.Count > 0 ? AppData.Accounts[0] : null);
- if (transaction.ExchangeRate.HasValue)
+ ResultTransaction = transaction;
+
+ if (transaction.IsTransfer && transaction.TransferPairId.HasValue)
{
- ShowExchangeRateField = true;
- ExchangeRate = transaction.ExchangeRate.Value.ToString("0.##########");
+ _transferPairId = transaction.TransferPairId;
+ Type = "transfer";
+ // Find the counterpart to determine from/to
+ var counterpart = AppData.Transactions.FirstOrDefault(t => t.TransferPairId == transaction.TransferPairId && t.Id != transaction.Id);
+ var outTx = transaction.IsTransferOut ? transaction : counterpart;
+ var inTx = transaction.IsTransferOut ? counterpart : transaction;
+ SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == outTx?.AccountId) ?? sortedAccounts.FirstOrDefault();
+ SelectedToAccount = AppData.Accounts.FirstOrDefault(a => a.Id == inTx?.AccountId) ?? sortedAccounts.Skip(1).FirstOrDefault();
+ Description = "Transfer";
+ SelectedCategory = null;
+ ShowExchangeRateField = false;
+ ExchangeRate = "";
+ IsFetchingRate = false;
}
else
{
- ShowExchangeRateField = false;
- ExchangeRate = "";
+ _transferPairId = null;
+ Type = transaction.Type;
+ Description = transaction.Description;
+ SelectedCategory = AppData.Categories.FirstOrDefault(c => c.Id == transaction.CategoryId)
+ ?? (AppData.Categories.Count > 0 ? AppData.Categories[0] : null);
+ SelectedAccount = AppData.Accounts.FirstOrDefault(a => a.Id == transaction.AccountId)
+ ?? (sortedAccounts.Count > 0 ? sortedAccounts[0] : null);
+ SelectedToAccount = sortedAccounts.Count > 1 ? sortedAccounts[1] : null;
+ if (transaction.ExchangeRate.HasValue)
+ {
+ ShowExchangeRateField = true;
+ ExchangeRate = transaction.ExchangeRate.Value.ToString("0.##########");
+ }
+ else
+ {
+ ShowExchangeRateField = false;
+ ExchangeRate = "";
+ }
+
+ IsFetchingRate = false;
}
- IsFetchingRate = false;
- ResultTransaction = transaction;
+
CheckBudgetImpact();
}
}
\ No newline at end of file
diff --git a/Clario/ViewModels/TransactionsViewModel.cs b/Clario/ViewModels/TransactionsViewModel.cs
index 1b4b80e..0dc8656 100644
--- a/Clario/ViewModels/TransactionsViewModel.cs
+++ b/Clario/ViewModels/TransactionsViewModel.cs
@@ -23,7 +23,10 @@ public partial class TransactionsViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection _categories = new();
[ObservableProperty] private ObservableCollection _accounts = new();
- [ObservableProperty] private List _filteredTransactions = new();
+ [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))]
+ private List _filteredTransactions = new();
+
+ public int FilteredTransactionCount => _filteredTransactions.Count;
private int _pageSize = 25;
[ObservableProperty] private int _pageSizeIndex;
@@ -84,7 +87,7 @@ public partial class TransactionsViewModel : ViewModelBase
new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month))
};
- [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense))]
+ [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense), nameof(FilterTypeTransfer))]
private string _transactionType = "all";
@@ -157,8 +160,9 @@ public partial class TransactionsViewModel : ViewModelBase
private void ApplyFilters()
{
var filtered = AppData.Transactions.Where(x =>
- x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
- || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
+ x.Type != "transfer_in" &&
+ (x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
+ || x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
var culture = new CultureInfo("en-US");
@@ -224,7 +228,7 @@ public partial class TransactionsViewModel : ViewModelBase
}
- // Calculate totals based on date-filtered transactions (converted to primary currency)
+ // Calculate totals based on date-filtered transactions (transfers excluded)
TotalExpenses = filtered.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.ConvertedAmount));
TotalIncome = filtered.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.ConvertedAmount));
@@ -234,8 +238,12 @@ public partial class TransactionsViewModel : ViewModelBase
if (SelectedAccount.Name != "All Accounts")
filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id);
- if (TransactionType != "all")
- filtered = filtered.Where(x => x.Type == TransactionType);
+ if (TransactionType == "income")
+ filtered = filtered.Where(x => x.Type == "income");
+ else if (TransactionType == "expense")
+ filtered = filtered.Where(x => x.Type == "expense");
+ else if (TransactionType == "transfer")
+ filtered = filtered.Where(x => x.IsTransfer);
switch (SelectedSortOption)
{
@@ -281,6 +289,7 @@ public partial class TransactionsViewModel : ViewModelBase
public bool FilterTypeAll => TransactionType == "all";
public bool FilterTypeIncome => TransactionType == "income";
public bool FilterTypeExpense => TransactionType == "expense";
+ public bool FilterTypeTransfer => TransactionType == "transfer";
[RelayCommand(CanExecute = nameof(HasNextPage))]
private void NextPage()
@@ -344,6 +353,7 @@ public partial class TransactionsViewModel : ViewModelBase
private void InitializeCategories()
{
+ Categories.Clear();
Categories.Insert(0, new Category() { Name = "All Categories" });
foreach (var appDataCategory in AppData.Categories)
{
@@ -355,6 +365,7 @@ public partial class TransactionsViewModel : ViewModelBase
private void InitializeAccounts()
{
+ Accounts.Clear();
Accounts.Insert(0, new Account() { Name = "All Accounts" });
foreach (var appDataAccount in AppData.Accounts)
{
diff --git a/Clario/Views/AccountsView.axaml b/Clario/Views/AccountsView.axaml
index 2ccec08..3bc84e9 100644
--- a/Clario/Views/AccountsView.axaml
+++ b/Clario/Views/AccountsView.axaml
@@ -14,12 +14,20 @@
-
+
-
+
-
+
+
+
+
+
+
+
@@ -324,16 +332,26 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -399,7 +417,9 @@
+ HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Cursor="Hand"
+ Command="{Binding RequestArchiveAccountCommand}"
+ CommandParameter="{Binding SelectedAccount}">
@@ -431,8 +451,10 @@
-
+
+
\ No newline at end of file
diff --git a/Clario/Views/AnalyticsView.axaml b/Clario/Views/AnalyticsView.axaml
new file mode 100644
index 0000000..7fb740a
--- /dev/null
+++ b/Clario/Views/AnalyticsView.axaml
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/Views/AnalyticsView.axaml.cs b/Clario/Views/AnalyticsView.axaml.cs
new file mode 100644
index 0000000..55cc911
--- /dev/null
+++ b/Clario/Views/AnalyticsView.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace Clario.Views;
+
+public partial class AnalyticsView : UserControl
+{
+ public AnalyticsView()
+ {
+ InitializeComponent();
+ this.AddHandler(PointerWheelChangedEvent, WindowScrollHandler, RoutingStrategies.Tunnel);
+ }
+
+ private void WindowScrollHandler(object? sender, PointerWheelEventArgs e)
+ {
+ var offset = mainScrollviewer.Offset;
+ mainScrollviewer.Offset = new Vector(
+ offset.X,
+ offset.Y - e.Delta.Y * mainScrollviewer.SmallChange.Height * 3
+ );
+
+ e.Handled = true;
+ }
+}
\ No newline at end of file
diff --git a/Clario/Views/ArchiveAccountDialogView.axaml b/Clario/Views/ArchiveAccountDialogView.axaml
new file mode 100644
index 0000000..2bbe3c3
--- /dev/null
+++ b/Clario/Views/ArchiveAccountDialogView.axaml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/Views/ArchiveAccountDialogView.axaml.cs b/Clario/Views/ArchiveAccountDialogView.axaml.cs
new file mode 100644
index 0000000..010a192
--- /dev/null
+++ b/Clario/Views/ArchiveAccountDialogView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.Views;
+
+public partial class ArchiveAccountDialogView : UserControl
+{
+ public ArchiveAccountDialogView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/Views/ArchivedAccountsDialogView.axaml b/Clario/Views/ArchivedAccountsDialogView.axaml
new file mode 100644
index 0000000..7f01e8a
--- /dev/null
+++ b/Clario/Views/ArchivedAccountsDialogView.axaml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/Views/ArchivedAccountsDialogView.axaml.cs b/Clario/Views/ArchivedAccountsDialogView.axaml.cs
new file mode 100644
index 0000000..59a1f57
--- /dev/null
+++ b/Clario/Views/ArchivedAccountsDialogView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.Views;
+
+public partial class ArchivedAccountsDialogView : UserControl
+{
+ public ArchivedAccountsDialogView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/Views/AuthView.axaml b/Clario/Views/AuthView.axaml
index 7d51100..f484a4e 100644
--- a/Clario/Views/AuthView.axaml
+++ b/Clario/Views/AuthView.axaml
@@ -191,19 +191,18 @@
-
+ IsVisible="{Binding HasError}">
-
@@ -392,19 +391,18 @@
-
+ IsVisible="{Binding HasError}">
-
diff --git a/Clario/Views/BudgetView.axaml b/Clario/Views/BudgetView.axaml
index cf7bd22..038eb56 100644
--- a/Clario/Views/BudgetView.axaml
+++ b/Clario/Views/BudgetView.axaml
@@ -267,7 +267,7 @@
-
+
@@ -279,7 +279,7 @@
-
+
diff --git a/Clario/Views/CategoryFormView.axaml b/Clario/Views/CategoryFormView.axaml
new file mode 100644
index 0000000..681849a
--- /dev/null
+++ b/Clario/Views/CategoryFormView.axaml
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Clario/Views/CategoryFormView.axaml.cs b/Clario/Views/CategoryFormView.axaml.cs
new file mode 100644
index 0000000..e996091
--- /dev/null
+++ b/Clario/Views/CategoryFormView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Clario.Views;
+
+public partial class CategoryFormView : UserControl
+{
+ public CategoryFormView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Clario/Views/DashboardView.axaml b/Clario/Views/DashboardView.axaml
index e6e7de9..e5363ff 100644
--- a/Clario/Views/DashboardView.axaml
+++ b/Clario/Views/DashboardView.axaml
@@ -25,14 +25,6 @@
Foreground="{DynamicResource TextPrimary}" Margin="0,2,0,0" />
-
-
-
-
-
-
@@ -384,7 +376,8 @@
-
+
diff --git a/Clario/Views/MainView.axaml b/Clario/Views/MainView.axaml
index 85d4f6b..f74c327 100644
--- a/Clario/Views/MainView.axaml
+++ b/Clario/Views/MainView.axaml
@@ -86,7 +86,9 @@
-
+
@@ -117,6 +119,9 @@
+
diff --git a/Clario/Views/TransactionFormView.axaml b/Clario/Views/TransactionFormView.axaml
index 52df304..6ab6936 100644
--- a/Clario/Views/TransactionFormView.axaml
+++ b/Clario/Views/TransactionFormView.axaml
@@ -74,7 +74,7 @@
CornerRadius="{DynamicResource RadiusControl}"
Padding="3"
Margin="0,0,0,20">
-
+
+
+
+
+
+
+
+
@@ -155,25 +174,28 @@
-
-
+
+
+ Margin="0,0,0,16"
+ IsVisible="{Binding !IsTransfer}" />
-
-
+
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Padding="4,0,4,0">
-
+ HorizontalScrollBarVisibility="Disabled"
+ Padding="16,0,16,0">
+
+
-
+
-
+
-
+
+
+
+
@@ -324,8 +329,7 @@
ColumnDefinitions="*,Auto"
Margin="28,28,28,0">
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
+ Color="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=color}" />
+ Css="{Binding Category.Color, Converter={StaticResource HexToColorConverter}, ConverterParameter=css}" />
-
+
+
+
+
+
+
+
+
-
-
+ HorizontalAlignment="Center" />
+