- Primary account determines app-wide reference currency; all totals, charts, and summaries convert to it automatically using live rates - Transactions show both converted and original amounts for cross-currency accounts; IsMultiCurrency recalculates on primary currency change - Exchange rates fetched live on account save and broadcast via RatesRefreshed so all views update without a restart - Account create/edit/delete with currency, icon, color, and primary toggle - Budget create/edit/delete; savings goal dialog - Settings view: display name, avatar upload, theme, language - Removed currency selector from Settings (follows primary account) - Fixed account list sort: primary first, then oldest CreatedAt, per group - Fixed total balance overlap in dashboard accounts card
19 KiB
Clario — New Chat Context Summary
Paste this entire file at the start of a new chat to continue work without quality degradation.
What Clario Is
Clario is a cross-platform personal finance tracking app built by Nouredeen (based in Jordan, Windows dev environment with Arabic region — always use en-US CultureInfo for dates/numbers). The app is in active development, not yet released.
Tech stack:
- Avalonia UI XPlat template — C# .NET 9
- CommunityToolkit.MVVM (
[ObservableProperty],[RelayCommand]) - Supabase backend (PostgreSQL, Auth, RLS, Realtime)
- LiveCharts2 (SkiaSharp) for charts
- Avalonia.Controls.ColorPicker (built-in)
- JetBrains Rider on Windows
Project Structure
Clario/ ← shared (ViewModels, Models, Services, Data, CustomControls, Behaviors, Converters)
Clario.Desktop/ ← Windows/macOS/Linux entry point (Program.cs)
Clario.Android/ ← Android entry point (MainActivity.cs)
Clario.iOS/ ← iOS entry point (AppDelegate.cs)
Clario.Browser/ ← Web/WASM entry point
Views/ ← desktop AXAML views
MobileViews/ ← mobile AXAML views
Namespaces: Clario.ViewModels, Clario.Views, Clario.MobileViews, Clario.Models, Clario.Data, Clario.Services, Clario.CustomControls, Clario.Behaviors, Clario.Converters
Architecture
Platform detection
// App.axaml.cs
public static bool IsMobile { get; private set; }
IsMobile = ApplicationLifetime is ISingleViewApplicationLifetime;
ViewLocator (auto view resolution)
// mobile: tries Clario.MobileViews.{Name}ViewMobile, falls back to Clario.Views.{Name}View
// desktop: Clario.Views.{Name}View
// No DataTemplates in AXAML — all registered via ViewLocator in App.axaml
App.axaml startup flow
public override async void OnFrameworkInitializationCompleted()
{
base.OnFrameworkInitializationCompleted();
IsMobile = ApplicationLifetime is ISingleViewApplicationLifetime;
// ViewLocator handles platform routing automatically
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US");
await SupabaseService.InitializeAsync(
IsMobile && !(ApplicationLifetime is ISingleViewApplicationLifetime browser)
? new FileSessionStorage()
: new FileSessionStorage() // BrowserSessionStorage for web
);
await SupabaseService.Client.Auth.RetrieveSessionAsync(); // catch { }
var user = SupabaseService.Client.Auth.CurrentUser;
var profile = await DataRepo.General.FetchProfileInfo();
if (profile is not null) ThemeService.SwitchToTheme(profile.Theme);
// desktop
desktop.MainWindow = new MainWindow { DataContext = user != null ? new MainViewModel() : new AuthViewModel() };
// mobile
singleView.MainView = new MobileShellView { DataContext = user != null ? new MainViewModel() : new AuthViewModel() };
}
MainViewModel (shell)
// constructor: sets CurrentView = _dashboardViewModel immediately, then _ = InitializeApp()
// InitializeApp(): Task.WhenAll all fetches, then pushes data into child VMs, calls vm.Initialize()
// Navigation: isOnDashboard, isOnTransactions, isOnAccounts, isOnBudget (bool properties)
// Commands: GoToDashboardCommand, GoToTransactionsCommand, GoToAccountsCommand, GoToBudgetCommand
// Modal: IsTransactionFormVisible, TransactionFormViewModel instance
// DeleteAccount: IsDeleteAccountDialogVisible, DeleteAccountDialogViewModel instance
// Data lists: List<Transaction> _transactions, _categories, _accounts, _budgets, Profile
ViewModel pattern
public partial class MyViewModel : ViewModelBase
{
public required ViewModelBase parentViewModel;
// Data set as plain fields/properties, NOT fetched in constructor
public void Initialize() { /* called after all data is set */ }
}
Critical rule: Never fetch data in child VM constructors. Never trigger init from partial void On{Property}Changed when VM depends on multiple properties. Always call Initialize() explicitly after object initializer sets all required fields.
DataRepo
public static class DataRepo { public static GeneralDataRepo General { get; } = new(); }
// Methods: FetchTransactions, FetchCategories, FetchAccounts, FetchBudgets, FetchProfileInfo
// InsertTransaction, UpdateTransaction, DeleteTransaction
// MigrateTransactions(fromId, toId), RecalculateAccountBalance(id), DeleteAccount(id)
Supabase Schema
Tables
profiles—id(UUID = auth.uid()),display_name,avatar_url,currency,theme,language,savings_goal(DECIMAL),savings_goal_deadline(DATE),created_at,updated_ataccounts—id,user_id,name,type,institution,mask,currency,balance,credit_limit,is_archived,opened_at,created_atcategories—id,user_id,name,icon(lucide icon name),color(hex),type(income/expense),created_attransactions—id,user_id,account_id,category_id,amount(DECIMAL, always positive),type(income/expense),description,note,date(DATE),created_atbudgets—id,user_id,category_id,amount,period(monthly/quarterly/yearly),alert_threshold(int, default 80),rollover(bool),created_at
RLS
- All tables have RLS enabled
- SELECT/UPDATE/DELETE:
USING (auth.uid() = user_id) - INSERT:
WITH CHECK (auth.uid() = user_id) - Profile auto-created via DB trigger on
auth.usersINSERT (SECURITY DEFINER)
Session persistence
FileSessionStorage— desktop/mobile, stores in%AppData%\Clario\session.jsonBrowserSessionStorage— web, uses JS interop to localStorageSupabaseSessionHandlerimplementsIGotrueSessionPersistence<Session>- Session restored manually:
await client.Auth.SetSession(accessToken, refreshToken) AutoRefreshToken = true,AutoConnectRealtime = true
Models
[Table("transactions")]
public class Transaction : BaseModel
{
[PrimaryKey("id", false)] public Guid Id { get; set; }
[Column("user_id")] public Guid UserId { get; set; }
[Column("account_id")] public Guid AccountId { get; set; }
[Column("category_id")] public Guid? CategoryId { get; set; }
[Column("amount")] public decimal Amount { get; set; }
[Column("type")] public string Type { get; set; }
[Column("description")] public string Description { get; set; }
[Column("note")] public string? Note { get; set; }
[Column("date")] public DateTime Date { get; set; }
[Column("created_at")] public DateTime CreatedAt { get; set; }
// non-DB nav properties (no [Column]):
public Category? Category { get; set; }
public bool GroupHeader { get; set; }
public bool IsExpense => Type == "expense";
}
// Account has: Id, UserId, Name, Type, Institution, Mask, Currency, Balance,
// OpeningBalance, CreditLimit, IsArchived, OpenedAt, CreatedAt
// + non-DB: Color (string hex), Icon (string lucide name), CurrentBalance,
// TransactionsCount, MonthlyIncrease, TotalIncomeThisMonth, TotalExpenseThisMonth,
// IncomeTransactionsThisMonth, ExpenseTransactionsThisMonth, RecentTransactions
// Category has: Id, UserId, Name, Icon, Color, Type, CreatedAt
// Budget has: Id, UserId, CategoryId, LimitAmount (was "Amount"), Period, AlertThreshold,
// Rollover, CreatedAt
// + non-DB: Category, Spent, TransactionsCount, GroupHeader
// + computed: IsOnTrack, IsWarning, IsOverBudget, PercentageUsed, RemainingFormatted,
// SpentFormatted, AmountFormatted, PercentageFormatted
// Profile has: Id, DisplayName, AvatarUrl, Currency, Theme, Language,
// SavingsGoal (decimal?), SavingsGoalDeadline (DateTime?), CreatedAt, UpdatedAt
Design System
Dark theme colors
BgBase: #0D0F14 BgSurface: #13161E BgSidebar: #0B0D12
BgElevated: #0D0F14 BgHover: #1A1E2A
BorderSubtle: #1E2330 BorderAccent: #2A3050
TextPrimary: #F0F2F8 TextSecondary: #C8D0E8 TextMuted: #7A8090 TextDisabled: #5A6070
AccentBlue: #7B9CFF AccentGreen: #2ECC8A AccentYellow: #F5C842
AccentRed: #FF5E5E AccentPurple: #9B7BFF AccentOrange: #FF7E5E AccentPink: #FF5E9B
IconBgBlue: #1A2240 IconBgGreen: #0D2A1A IconBgOrange: #2A1A0D
IconBgRed: #2A0D0D IconBgPurple: #1A1A2A IconBgPink: #1A0D1A
BadgeBgRed, BadgeBgYellow (semi-transparent dark versions of accent colors)
Light theme
Softened versions (~10% darker accents), BgBase: #EDEEF4, BgSurface: #F5F6FA
Theme switching
ThemeService.SwitchToTheme(string theme) — "dark"/"light"/"system"
Profile stores theme preference. IsDarkTheme bool on MainViewModel.
Dynamic resources used everywhere
{DynamicResource BgBase}, {DynamicResource AccentBlue}, {DynamicResource TextPrimary}
{DynamicResource RadiusControl}, {DynamicResource RadiusIcon}, {DynamicResource RadiusPill}
{DynamicResource FontSizeBody}, {DynamicResource FontSizeLabel}
SVG icons (Lucide)
<Svg Path="../Assets/Icons/icon-name.svg" Width="16" Height="16" Css="{DynamicResource SvgBlue}"/>
SVG CSS resources: SvgPrimary, SvgSecondary, SvgMuted, SvgDisabled, SvgBlue, SvgGreen, SvgYellow, SvgRed, SvgPurple, SvgOrange, SvgPink
Icon bg opacity pattern: <SolidColorBrush Color="{Binding Color, Converter=..., ConverterParameter=color}" Opacity="0.15"/>
Button / Style Classes
accented— primary action (AccentBlue bg)base— secondary actionnav— transparent nav/toggle,Classes.active="{Binding bool}"for active stateaccount— account card buttonghost— transparent TextBox (all states transparent, no border)label— uppercase muted label TextBlockmuted— TextMuted foregroundbadge-green/badge-red/badge-warning— status badgesbudget-card/budget-card-warning/budget-card-over— budget card bordersmobile— root class on mobile views (enables mobile style overrides)
ProgressBar: Classes="green" / "yellow" / "red" / "blue"
Mobile nav button override:
<Style Selector=".mobile Button.nav:pressed /template/ ContentPresenter"> Transparent </Style>
<Style Selector=".mobile Button.nav:pointerover /template/ ContentPresenter"> Transparent </Style>
View Architecture
Desktop shell (MainView.axaml)
<Grid ColumnDefinitions="220,*">
<Border><!-- 220px sidebar with nav + user profile --></Border>
<Grid Column="1">
<ContentControl Content="{Binding CurrentView}"/>
<!-- Transaction form modal overlay — IsVisible="{Binding IsTransactionFormVisible}" -->
<!-- Delete account dialog overlay — IsVisible="{Binding IsDeleteAccountDialogVisible}" -->
</Grid>
</Grid>
Views do NOT include sidebar. Background="{DynamicResource BgBase}" on root.
Typical layout: Grid RowDefinitions="Auto,*" — top bar + content.
Top bar margin: Margin="32,28,32,0".
Mobile shell (MainViewMobile.axaml)
<Grid Classes="mobile" RowDefinitions="*,Auto">
<ContentControl Content="{Binding CurrentView}"/>
<!-- Bottom tab bar: Home | Transactions | + FAB | Accounts | Budget -->
</Grid>
Mobile views: single column, Margin="16,12,16,0" top bar, no BoxShadow, no MinWidth/MinHeight.
Views built so far
Desktop: DashboardView, TransactionsView, AccountsView, BudgetView, AuthView, MainView Mobile: DashboardViewMobile, TransactionsViewMobile, AccountsViewMobile, BudgetViewMobile, MainViewMobile, AuthViewMobile (not yet built) Forms/Dialogs: TransactionFormView (add/edit modal), DeleteAccountDialogView (two-step) Shared: BudgetFormView, BudgetCardMenuView
Key Converters
| Key | Purpose |
|---|---|
HexToColorConverter |
ConverterParameter: color→Color, css→SVG CSS, brush→IBrush |
AmountColorConverter |
type string → AccentRed or AccentGreen brush |
AmountSignConverter |
MultiBinding(amount, type) → +$x.xx / -$x.xx |
BoolToColorConverter |
ConverterParameter='#hex1|#hex2' → color |
BoolToCssConverter |
ConverterParameter='#hex1|#hex2' → SVG CSS string |
SvgPathFromName |
"icon-name" → "../Assets/Icons/icon-name.svg" |
DateFormatConverter |
DateTime → formatted string (en-US), ConverterParameter=format |
EqualValueConverter |
MultiBinding equality → bool |
DecimalColorConverter |
decimal sign → one of two values |
NetworthSumConverter |
MultiBinding(income, expenses) → net formatted |
PercentageConverter |
MultiBinding(value, total) → percentage string |
MaskToStringConverter |
account mask → "•••• 1234" |
FirstValueConverter |
double[] → first element |
SkPaintToBrushConverter |
SolidColorPaint → IBrush |
AccountFromIdConverter |
Guid → account name string |
Custom Controls & Behaviors
DateRangePicker (Clario.CustomControls)
<cc:DateRangePicker SelectionMode="SingleDate" SelectedDates="{Binding Dates}"/>
<cc:DateRangePicker SelectionMode="SingleRange" SelectedDates="{Binding Dates}"/>
<cc:DateRangePicker Classes="ghost" SelectionMode="SingleDate" SelectedDates="{Binding Dates}" Padding="12,10"/>
Has SelectedDate (DateTime?) property that syncs with SelectedDates.
Uses _isSyncing guard flag to prevent feedback loops.
Only processes SelectedDatesChanged when popup is open (fixes instant-close bug).
NumericTextBox (Clario.CustomControls)
Subclass of TextBox, only accepts digits and one decimal point.
<local:NumericTextBox Classes="ghost" Text="{Binding Amount, Mode=TwoWay}"/>
NumericInputBehavior (Clario.Behaviors)
Alternative to NumericTextBox, attach to regular TextBox.
<TextBox><Interaction.Behaviors><behaviors:NumericInputBehavior/></Interaction.Behaviors></TextBox>
Transaction Form (TransactionFormViewModel)
IsEditMode,FormTitle,FormSubtitle,SaveButtonLabel(switches add/edit)SetupForAdd(categories, accounts)/SetupForEdit(transaction, categories, accounts)OnSaved/OnCancelled/OnDeleted— Action callbacks- Commands:
SetTypeCommand(string),SetTodayCommand,SaveCommand,DeleteCommand,RequestDeleteCommand,CancelCommand ShowDeleteConfirmbool — confirm delete sub-modal inside form- Validation:
IsValidbool,HasError+ErrorMessage
TransactionsViewModel — Key Details
_allTransactions(all),_filteredTransactions(after filters),PagedTransactions(ObservableCollection, current page)- Page size: 25 desktop / 10 mobile
LoadPage(int)— desktop pagination (clears and replaces)LoadMoreCommand— mobile infinite scroll (appends, does NOT call ApplyFilters)GroupTransactions()— inserts date header rows, uses_isSyncingguard, processes dates in reverse order to avoid index shiftingTransaction.GroupHeader = truerows act as section headers withDescription= date label- Filters: SearchText, SelectedDateRangeOption, SelectedCategory, SelectedAccount, TransactionType, SelectedSortOption
ApplyFilters()does NOT re-sort at end — sort is inside the switchWeakReferenceMessenger.Default.Send(new TransactionsScrollToTop())on desktop page change
BudgetViewModel — Key Details
CurrentPeriodDateTime — navigable by monthProcessBudgets()— setsSpent,TransactionsCountper budget, adds group headers toVisibleBudgetsProcessChartData()— buildsSpendingBreakdownChartSeries(half-donut) +SpendingBreakdownLegendsTotalLeft=TotalBudgeted - TotalSpent(clamped to 0)SavingsHint— comparesTotalLefttoProfile.SavingsGoalDailyBudgetLeftFormatted= remaining budget ÷ days left in period
Charts (LiveCharts2)
Bar chart (SpendingByCategory on Dashboard)
// ISeries[] — one ColumnSeries<double> per category
// Replace whole array on update (don't use ObservableCollection + SeriesSource)
// ZoomMode="None" on CartesianChart to allow scroll passthrough
// Scroll forwarding in code-behind via PointerWheelChangedEvent tunnel handler
Half-donut (Budget spending breakdown)
<Border Height="150" ClipToBounds="True">
<lvc:PieChart Series="{Binding ...}" Height="300" Margin="0,0,0,-150"
InitialRotation="-180" MaxAngle="180" LegendPosition="Hidden"/>
</Border>
TooltipLabelFormatter
TooltipLabelFormatter = point => $"${point.Coordinate.PrimaryValue:N0}"
Flyout Pattern
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight"
FlyoutPresenterTheme="{StaticResource TransparentFlyoutPresenter}">
<views:SomeView/>
</Flyout>
</Button.Flyout>
TransparentFlyoutPresenter ControlTheme defined in App.axaml resources (not Styles).
Modal Pattern (desktop)
Full-screen Grid overlay on top of ContentControl in MainView.
IsVisible bound to bool on MainViewModel.
Dim: <Border Background="#70000000"/> + centered card.
Bottom Sheet Pattern (mobile — AccountsViewMobile)
// public async Task ShowSheet() / HideSheet()
// TranslateTransform animation, CubicEaseOut 320ms up, CubicEaseIn 260ms down
// OverlayGrid.IsVisible = false by default in XAML
// Dismiss: DimOverlay.PointerPressed + CloseButton.Click
// Sheet height: BottomSheet.MaxHeight = Bounds.Height * 0.82 in OnAttachedToVisualTree
XAML Rules
- Always
{DynamicResource}for theme colors - Never hardcode hex except in SVG CSS strings
x:CompileBindings="False"on shell views with dynamic DataContext- Separator between list items:
Spacing="1"on StackPanel +BorderSubtlebackground on container - Never
MinWidth/MinHeighton mobile UserControls - Icon bg always:
<SolidColorBrush Color="..." Opacity="0.15"/> - No
BoxShadowon mobile
Performance Notes
- Build in Release mode for realistic Android performance
- Use
VirtualizingStackPanelin ItemsControl for long lists - Mobile page size = 10
- Remove
BoxShadowfrom all mobile views base.OnFrameworkInitializationCompleted()must be called FIRST inOnFrameworkInitializationCompleted
Things Currently In Progress / Not Yet Done
- AuthViewMobile (not yet built)
- Settings view (desktop + mobile, not yet designed)
- Analytics view (not yet designed)
- Edit account form (not yet built)
- Edit budget form (BudgetFormView exists but not wired to data)
- Light theme AppTheme variant file (not created yet)
- Notification/alert system for budget threshold warnings
- Real-time Supabase subscriptions (AutoConnectRealtime=true but not wired to UI refresh)
App Identity
- Name: Clario
- Logo: C-shaped segmented donut chart, 4 segments, blue/green/yellow/red gradient, on dark background
- Tagline: "Your personal finance tracker"
- Package:
com.CompanyName.Clario(placeholder, not finalized)