From 1332e96fddcf2f1fd7ac4d2fe2c80016b86270b5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 22:17:30 +0100 Subject: [PATCH 1/7] fix: move Recover button below stages, full width at all breakpoints The runtime grid-repositioning fix was fragile across cached-view re-attachment and regressed (button overlapped 'Payment Schedule' on mobile). The button is now structurally the last element of the schedule card so it cannot overlap the header regardless of layout-mode races. --- .../Portfolio/InvestmentDetailView.axaml | 64 +++++++++---------- .../Portfolio/InvestmentDetailView.axaml.cs | 24 ------- 2 files changed, 31 insertions(+), 57 deletions(-) diff --git a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml index 5af69dfb2..1272014ff 100644 --- a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml +++ b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml @@ -1244,12 +1244,9 @@ BoxShadow="{DynamicResource ItemShadow}" Padding="24"> - + - - @@ -1512,6 +1481,35 @@ + + + diff --git a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml.cs b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml.cs index 42d64eecf..8e90bbe4d 100644 --- a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml.cs +++ b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml.cs @@ -41,8 +41,6 @@ public partial class InvestmentDetailView : UserControl private StackPanel? _stagesTableDesktop; private ItemsControl? _stagesCardsMobile; private StackPanel? _contentStack; - private Grid? _scheduleHeaderGrid; - private Button? _recoverFundsButton; public InvestmentDetailView() { @@ -73,8 +71,6 @@ public InvestmentDetailView() _stagesTableDesktop = this.FindControl("StagesTableDesktop"); _stagesCardsMobile = this.FindControl("StagesCardsMobile"); _contentStack = this.FindControl("ContentStack"); - _scheduleHeaderGrid = this.FindControl("ScheduleHeaderGrid"); - _recoverFundsButton = this.FindControl diff --git a/src/design/App/UI/Themes/V2/Controls/ListBox.axaml b/src/design/App/UI/Themes/V2/Controls/ListBox.axaml index 3bff53649..b119cbc31 100644 --- a/src/design/App/UI/Themes/V2/Controls/ListBox.axaml +++ b/src/design/App/UI/Themes/V2/Controls/ListBox.axaml @@ -14,6 +14,19 @@ + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + Foreground="{DynamicResource TextMuted}" + HorizontalAlignment="Center" /> - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + TextTrimming="CharacterEllipsis" + VerticalAlignment="Center" /> - + CornerRadius="4" Padding="6,2" + IsVisible="{Binding IsCurrentUser}" + VerticalAlignment="Center"> + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - + + + - + + diff --git a/src/design/App/UI/Sections/Portfolio/InvestorBreakdownView.axaml.cs b/src/design/App/UI/Sections/Portfolio/InvestorBreakdownView.axaml.cs index 3208abe7c..2bc6e0852 100644 --- a/src/design/App/UI/Sections/Portfolio/InvestorBreakdownView.axaml.cs +++ b/src/design/App/UI/Sections/Portfolio/InvestorBreakdownView.axaml.cs @@ -1,21 +1,91 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.LogicalTree; using Avalonia.VisualTree; +using App.UI.Shared; using App.UI.Shell; namespace App.UI.Sections.Portfolio; public partial class InvestorBreakdownView : UserControl { + private StackPanel? _tableDesktop; + private ItemsControl? _cardsMobile; + private Grid? _summaryStatsGrid; + private Border? _statCardInvestors; + private IDisposable? _layoutSubscription; + public InvestorBreakdownView() { InitializeComponent(); AddHandler(Button.ClickEvent, OnButtonClick, RoutingStrategies.Bubble); + SubscribeToLayoutMode(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + _tableDesktop = this.FindControl("BreakdownTableDesktop"); + _cardsMobile = this.FindControl("BreakdownCardsMobile"); + _summaryStatsGrid = this.FindControl("SummaryStatsGrid"); + _statCardInvestors = this.FindControl("StatCardInvestors"); + ApplyResponsiveLayout(LayoutModeService.Instance.IsCompact); + } + + /// Idempotent responsive-layout subscription — re-created on every logical-tree attach because OnDetachedFromLogicalTree disposes it. + private void SubscribeToLayoutMode() + { + if (_layoutSubscription != null) return; + _layoutSubscription = LayoutModeService.Instance + .WhenAnyValue(x => x.IsCompact) + .Subscribe(ApplyResponsiveLayout); + } + + protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) + { + base.OnAttachedToLogicalTree(e); + SubscribeToLayoutMode(); + } + + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + _layoutSubscription?.Dispose(); + _layoutSubscription = null; + base.OnDetachedFromLogicalTree(e); + } + + /// + /// Compact: fixed-width table → stacked cards (house pattern, same as + /// InvestmentDetailView stages), and the two summary stat cards stack. + /// + private void ApplyResponsiveLayout(bool isCompact) + { + if (_tableDesktop != null) _tableDesktop.IsVisible = !isCompact; + if (_cardsMobile != null) _cardsMobile.IsVisible = isCompact; + + if (_summaryStatsGrid == null || _statCardInvestors == null) return; + if (isCompact) + { + _summaryStatsGrid.ColumnDefinitions[1].Width = new GridLength(0); + _summaryStatsGrid.ColumnDefinitions[2].Width = new GridLength(0); + Grid.SetColumn(_statCardInvestors, 0); + Grid.SetRow(_statCardInvestors, 1); + _statCardInvestors.Margin = new Thickness(0, 12, 0, 0); + } + else + { + _summaryStatsGrid.ColumnDefinitions[1].Width = new GridLength(16); + _summaryStatsGrid.ColumnDefinitions[2].Width = GridLength.Star; + Grid.SetColumn(_statCardInvestors, 2); + Grid.SetRow(_statCardInvestors, 0); + _statCardInvestors.Margin = new Thickness(0); + } } private void OnButtonClick(object? sender, RoutedEventArgs e) { - if (e.Source is Button { Name: "CloseButton" }) + if (e.Source is Button { Name: "CloseButton" or "CloseButtonX" }) { var shellVm = this.FindAncestorOfType()?.DataContext as ShellViewModel; shellVm?.HideModal(); diff --git a/src/design/App/UI/Sections/Portfolio/InvestorBreakdownViewModel.cs b/src/design/App/UI/Sections/Portfolio/InvestorBreakdownViewModel.cs index 02ea84367..64c92f1b3 100644 --- a/src/design/App/UI/Sections/Portfolio/InvestorBreakdownViewModel.cs +++ b/src/design/App/UI/Sections/Portfolio/InvestorBreakdownViewModel.cs @@ -24,16 +24,27 @@ public class InvestorShareRowViewModel /// /// ViewModel for the investor breakdown modal. /// Shows all investors in a project with their share percentages. +/// +/// The modal opens optimistically: it is shown immediately in a loading state +/// (IsLoading=true) while the share data is fetched, then populated via +/// — or flipped to an error state via . /// -public class InvestorBreakdownViewModel +public partial class InvestorBreakdownViewModel : ReactiveObject { public string ProjectName { get; } - public string TotalInvested { get; } - public int TotalInvestors { get; } public string CurrencySymbol { get; } public string ProjectType { get; } public bool IsFundType { get; } + private readonly string _currentInvestorPublicKey; + + [Reactive] private bool isLoading = true; + [Reactive] private bool hasError; + [Reactive] private string totalInvested = "0.00000000"; + [Reactive] private int totalInvestors; + + public bool HasData => !IsLoading && !HasError; + /// /// Context note for Fund projects: "Shares are calculated as of now. /// New funds can always be added, which will change the percentages." @@ -43,7 +54,6 @@ public class InvestorBreakdownViewModel public ObservableCollection Investors { get; } = new(); public InvestorBreakdownViewModel( - GetInvestorShares.GetInvestorSharesResponse data, string projectName, string projectType, string currencySymbol, @@ -53,14 +63,24 @@ public InvestorBreakdownViewModel( ProjectType = projectType; CurrencySymbol = currencySymbol; IsFundType = projectType == "fund"; - TotalInvested = ((double)new Amount(data.TotalInvested).Sats.ToUnitBtc()) - .ToString("F8", CultureInfo.InvariantCulture); - TotalInvestors = data.TotalInvestors; + _currentInvestorPublicKey = currentInvestorPublicKey; ShareContextNote = IsFundType ? "Shares are calculated as of now. New funds can always be added, which will change the percentages." : null; + this.WhenAnyValue(x => x.IsLoading, x => x.HasError) + .Subscribe(_ => this.RaisePropertyChanged(nameof(HasData))); + } + + /// Populate the modal with fetched share data and leave the loading state. + public void ApplyData(GetInvestorShares.GetInvestorSharesResponse data) + { + TotalInvested = ((double)new Amount(data.TotalInvested).Sats.ToUnitBtc()) + .ToString("F8", CultureInfo.InvariantCulture); + TotalInvestors = data.TotalInvestors; + + Investors.Clear(); int rank = 1; foreach (var investor in data.Investors) { @@ -80,10 +100,20 @@ public InvestorBreakdownViewModel( AmountClaimed = ((double)new Amount(investor.AmountClaimedByFounder).Sats.ToUnitBtc()) .ToString("F8", CultureInfo.InvariantCulture), ClaimedPercentage = $"{investor.ClaimedPercentage:F2}%", - CurrencySymbol = currencySymbol, - IsCurrentUser = !string.IsNullOrEmpty(currentInvestorPublicKey) - && string.Equals(key, currentInvestorPublicKey, StringComparison.OrdinalIgnoreCase) + CurrencySymbol = CurrencySymbol, + IsCurrentUser = !string.IsNullOrEmpty(_currentInvestorPublicKey) + && string.Equals(key, _currentInvestorPublicKey, StringComparison.OrdinalIgnoreCase) }); } + + HasError = false; + IsLoading = false; + } + + /// Flip the modal into its error state (fetch failed). + public void SetError() + { + HasError = true; + IsLoading = false; } } From 7cc58ddf20614242c26970b3abd1fdcd5bb3de47 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 22:47:02 +0100 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20Recover=20button=20=E2=80=94=20bitco?= =?UTF-8?q?in-orange=20accent,=20mobile=20height=20standard,=20centred=20c?= =?UTF-8?q?ontent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BitcoinAccent (orange) token instead of the ad-hoc green gradient, matching the original design accent for the recovery action - MobileAction class: standard 52px action height on compact (MinHeight instead of inline Height so the style setter wins) - Content centred on both axes --- .../Portfolio/InvestmentDetailView.axaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml index 1272014ff..192e41f1b 100644 --- a/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml +++ b/src/design/App/UI/Sections/Portfolio/InvestmentDetailView.axaml @@ -1484,30 +1484,31 @@ From 9befbf733b4ca40f87a78a6ce2990a974c8101d4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 23 Jul 2026 23:40:27 +0100 Subject: [PATCH 6/7] fix: preset pill layout/height standard, transparent dark pills, themed banners - Preset pills (BTC amounts, months, frequency): transparent background + Stroke outline instead of the washed-out SurfaceHover fill in dark mode; standard 60px MinHeight so mixed preset rows all align with the two-line target-amount pills - New BalancedWrapPanel: preset rows divide the full width into balanced rows (5 -> 3+2, 6 -> 3+3) instead of hugging content; used by the project-length presets at all breakpoints - Frequency presets: 2x2 fill grid on compact (new Grid2 ItemsPanel class), single full-width row on desktop - Step5 duration input + Months dropdown aligned at the same 52px height; Add Another Stage matches primary action height - Advanced editor total/date banners use PillGreen*/PillAmber* theme tokens instead of hardcoded light-mode hexes - Stage row delete: borderless icon button (house RemoveBtn pattern), red trash (PillRedText) matching Settings deletes - Layout regression tests now cover all three project types AND the real step-5 form (ShowStep5Welcome=false) - the welcome interstitial was masking the form from the audits --- .../LayoutRegression/LayoutRegressionTests.cs | 24 ++-- .../Steps/CreateProjectStep4View.axaml | 8 +- .../Steps/CreateProjectStep5View.axaml | 53 +++++--- .../Steps/CreateProjectStep5View.axaml.cs | 42 +++++++ .../UI/Shared/Controls/BalancedWrapPanel.cs | 115 ++++++++++++++++++ .../App/UI/Themes/V2/Controls/ListBox.axaml | 30 ++++- 6 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 src/design/App/UI/Shared/Controls/BalancedWrapPanel.cs diff --git a/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs b/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs index 2d7ca3b98..dfb5af8e0 100644 --- a/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs +++ b/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs @@ -329,16 +329,19 @@ public void SettingsView_has_no_overlaps_or_overflow(double width, double height // CreateProjectView — all 6 wizard steps at phone + desktop widths // ═══════════════════════════════════════════════════════════════════ - public static TheoryData CreateProjectSteps + public static TheoryData CreateProjectSteps { get { - var data = new TheoryData(); - for (int step = 1; step <= 6; step++) + var data = new TheoryData(); + foreach (var type in new[] { "fund", "investment", "subscription" }) { - data.Add(step, 360); - data.Add(step, 768); - data.Add(step, 1280); + for (int step = 1; step <= 6; step++) + { + data.Add(type, step, 360); + data.Add(type, step, 768); + data.Add(type, step, 1280); + } } return data; @@ -347,20 +350,23 @@ public static TheoryData CreateProjectSteps [AvaloniaTheory] [MemberData(nameof(CreateProjectSteps))] - public void CreateProjectView_step_has_no_overlaps_or_overflow(int step, double width) + public void CreateProjectView_step_has_no_overlaps_or_overflow(string projectType, int step, double width) { var vm = global::App.App.Services.GetRequiredService(); - vm.SelectProjectType("fund"); + vm.SelectProjectType(projectType); vm.ProjectName = "A Very Long Project Name That Stresses The Wizard Header Layout"; vm.ProjectAbout = new string('x', 240); vm.GoToStep(step); + // Step 5 shows an interstitial welcome by default — the real form (presets, + // duration inputs, advanced editor) is what must be layout-audited. + vm.ShowStep5Welcome = false; var view = new CreateProjectView { DataContext = vm }; var violations = RenderAndAudit(view, width, 900); violations.Should().BeEmpty( - $"CreateProjectView step {step} must not have overlapping/overflowing elements at width {width}:\n" + + $"CreateProjectView ({projectType}) step {step} must not have overlapping/overflowing elements at width {width}:\n" + string.Join("\n", violations)); } diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml index 98e84c750..85ca0ae2a 100644 --- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml +++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml @@ -62,7 +62,7 @@ - @@ -131,7 +131,7 @@ Margin="0,4,0,0" /> - @@ -195,7 +195,7 @@ - @@ -305,7 +305,7 @@ - diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml index 41a8c8d02..f58bcb266 100644 --- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml +++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mp="clr-namespace:App.UI.Sections.MyProjects" + xmlns:ctrl="clr-namespace:App.UI.Shared.Controls" xmlns:i="https://github.com/projektanker/icons.avalonia" mc:Ignorable="d" d:DesignWidth="680" d:DesignHeight="1200" x:Class="App.UI.Sections.MyProjects.Steps.CreateProjectStep5View" @@ -76,6 +77,14 @@ + + + + + + + + + +