diff --git a/src/design/App.Android/MainActivity.cs b/src/design/App.Android/MainActivity.cs index a8355c477..e6c8a8580 100644 --- a/src/design/App.Android/MainActivity.cs +++ b/src/design/App.Android/MainActivity.cs @@ -140,16 +140,45 @@ public override bool DispatchKeyEvent(KeyEvent? e) { if (e?.KeyCode == Keycode.Back) { - if (e.Action == KeyEventActions.Down && CanHandleShellBack()) + // REGRESSION HOTSPOT (see PlatformBackRegressionTests in App.Test.Integration). + // + // Previous implementation consumed Down only when CanHandleShellBack() was true + // and Up only when TryHandleShellBack() succeeded. If app state changed between + // Down and Up (modal closing itself, async navigation), Android received an + // orphan Up (or a swallowed Down) and the back button silently stopped working. + // + // Deterministic contract: ALWAYS consume both Back events here. On Up, route + // to the in-app back ladder; if the app is at a root screen, perform the + // platform default (finish/minimize) ourselves via OnBackPressed. + if (e.Action == KeyEventActions.Down) return true; - if (e.Action == KeyEventActions.Up && TryHandleShellBack()) + if (e.Action == KeyEventActions.Up) + { + if (!TryHandleShellBack()) + { + _handlingPlatformBack = true; + try + { + base.OnBackPressed(); + } + finally + { + _handlingPlatformBack = false; + } + } + return true; + } } return base.DispatchKeyEvent(e); } + /// + /// Gesture-navigation / predictive-back path (no key events are dispatched). + /// Mirrors DispatchKeyEvent: in-app back first, platform default otherwise. + /// public override void OnBackPressed() { if (_handlingPlatformBack) @@ -162,16 +191,14 @@ public override void OnBackPressed() return; _handlingPlatformBack = true; - base.OnBackPressed(); - _handlingPlatformBack = false; - } - - private static bool CanHandleShellBack() - { - if (Dispatcher.UIThread.CheckAccess()) - return ShellService.CanHandlePlatformBack(); - - return Dispatcher.UIThread.InvokeAsync(ShellService.CanHandlePlatformBack).GetAwaiter().GetResult(); + try + { + base.OnBackPressed(); + } + finally + { + _handlingPlatformBack = false; + } } private static bool TryHandleShellBack() diff --git a/src/design/App.Test.Integration/LayoutRegression/LayoutAsserts.cs b/src/design/App.Test.Integration/LayoutRegression/LayoutAsserts.cs index b2b946671..13ad224e2 100644 --- a/src/design/App.Test.Integration/LayoutRegression/LayoutAsserts.cs +++ b/src/design/App.Test.Integration/LayoutRegression/LayoutAsserts.cs @@ -136,9 +136,22 @@ private static bool CellsIntersect(Control a, Control b) return aCol <= bColEnd && bCol <= aColEnd && aRow <= bRowEnd && bRow <= aRowEnd; } - /// Translate a control's bounds into root coordinates (handles render transforms). + /// + /// Translate a control's layout bounds into root coordinates. + /// The element's own render transform is deliberately excluded: render transforms + /// (e.g. an infinite spinner's RotateTransform) animate the visual without moving + /// its layout slot, so including them makes results depend on which animation + /// frame the layout pass happens to catch — a source of flaky false positives. + /// Ancestor transforms still apply via the parent's translation. + /// private static Rect ToRootRect(Visual v, Visual root) { + if (!ReferenceEquals(v, root) && v.GetVisualParent() is Visual parent && + parent.TranslatePoint(v.Bounds.Position, root) is { } layoutOrigin) + { + return new Rect(layoutOrigin, v.Bounds.Size); + } + var origin = v.TranslatePoint(default, root) ?? default; return new Rect(origin, v.Bounds.Size); } diff --git a/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs b/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs index 77e487720..dfb5af8e0 100644 --- a/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs +++ b/src/design/App.Test.Integration/LayoutRegression/LayoutRegressionTests.cs @@ -12,6 +12,9 @@ using App.UI.Sections.Portfolio; using App.UI.Sections.Settings; using App.UI.Shared; +using App.UI.Shared.PaymentFlow; +using App.UI.Shared.Services; +using Angor.Sdk.Common; using App.UI.Shared.Controls; using Microsoft.Extensions.DependencyInjection; using System.Collections.ObjectModel; @@ -326,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; @@ -344,20 +350,125 @@ 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)); + } + + // ═══════════════════════════════════════════════════════════════════ + // PaymentFlowView — "Use an existing wallet" modal (issue #920: + // Fund Deployment label overlapped by the amount; wallet card cramping) + // ═══════════════════════════════════════════════════════════════════ + + [AvaloniaTheory] + [MemberData(nameof(Viewports))] + public void PaymentFlowView_wallet_selector_has_no_overlaps_or_overflow(double width, double height) + { + var services = global::App.App.Services; + var config = new PaymentFlowConfig + { + AmountSats = 100_000_000, // 1.00000000 — worst-case width amount + Title = "Fund Deployment", + SuccessTitle = "Deployed", + SuccessButtonText = "Go to My Projects", + OnSuccessButtonClicked = () => { }, + OnPaymentReceived = (_, _, _) => Task.FromResult(CSharpFunctionalExtensions.Result.Success()), + }; + var logger = services.GetRequiredService() + .CreateLogger("PaymentFlowLayoutTest"); + var vm = ActivatorUtilities.CreateInstance( + services, config, logger); + + var view = new PaymentFlowView { DataContext = vm }; + + // Wallet context is empty in tests: exercise the wallet-card template with + // worst-case fabricated wallets (long name, long balance, pending balance). + var w1 = new WalletInfo( + new WalletId("test-1"), + "A Very Long Wallet Name That Must Not Overlap The Balance", "TBTC") + { + TotalBalanceSats = 123_456_789, + UnconfirmedBalanceSats = 12_345_678, + IsSelected = true, + }; + var w2 = new WalletInfo( + new WalletId("test-2"), "Angor Wallet", "TBTC") + { + TotalBalanceSats = 0, + }; + view.AttachedToVisualTree += (_, _) => + { + if (view.FindControl("WalletsList") is { } list) + list.ItemsSource = new[] { w1, w2 }; + }; + + var violations = RenderAndAudit(view, width, height); + + violations.Should().BeEmpty( + $"PaymentFlowView must not have overlapping/overflowing elements at {width}x{height}:\n" + + string.Join("\n", violations)); + } + + // ═══════════════════════════════════════════════════════════════════ + // InvestorBreakdownView — optimistic-loading modal (table→cards on mobile) + // ═══════════════════════════════════════════════════════════════════ + + [AvaloniaTheory] + [MemberData(nameof(Viewports))] + public void InvestorBreakdownView_has_no_overlaps_or_overflow(double width, double height) + { + var vm = new InvestorBreakdownViewModel( + "A Very Long Project Name That Stresses The Modal Header Layout", + "fund", "TBTC", + "aaaa000000000000000000000000000000000000000000000000000000000000"); + + // Worst-case rows: current-user highlight + long amounts. + var rows = new System.Collections.Generic.List(); + for (int i = 0; i < 6; i++) + { + rows.Add(new Angor.Sdk.Funding.Projects.Dtos.InvestorShareDto( + i == 2 + ? "aaaa000000000000000000000000000000000000000000000000000000000000" + : $"bbbb{i:D60}", + "", 123_456_789, 33.33, 12_345_678, 10.01)); + } + vm.ApplyData(new Angor.Sdk.Funding.Projects.Operations.GetInvestorShares.GetInvestorSharesResponse( + 370_370_367, rows.Count, rows)); + + var view = new InvestorBreakdownView { DataContext = vm }; + var violations = RenderAndAudit(view, width, height); + + violations.Should().BeEmpty( + $"InvestorBreakdownView must not have overlapping/overflowing elements at {width}x{height}:\n" + + string.Join("\n", violations)); + } + + [AvaloniaTheory] + [MemberData(nameof(Viewports))] + public void InvestorBreakdownView_loading_state_has_no_overlaps_or_overflow(double width, double height) + { + var vm = new InvestorBreakdownViewModel("Project", "fund", "TBTC"); + + var view = new InvestorBreakdownView { DataContext = vm }; + var violations = RenderAndAudit(view, width, height); + + violations.Should().BeEmpty( + $"InvestorBreakdownView (loading) must not have overlapping/overflowing elements at {width}x{height}:\n" + string.Join("\n", violations)); } diff --git a/src/design/App.Test.Integration/PlatformBackRegressionTests.cs b/src/design/App.Test.Integration/PlatformBackRegressionTests.cs new file mode 100644 index 000000000..c3c868709 --- /dev/null +++ b/src/design/App.Test.Integration/PlatformBackRegressionTests.cs @@ -0,0 +1,271 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using FluentAssertions; +using App.UI.Sections.FindProjects; +using App.UI.Sections.MyProjects; +using App.UI.Sections.Portfolio; +using App.UI.Shell; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.ObjectModel; +using Xunit; + +namespace App.Test.Integration; + +/// +/// Regression tests for the Android physical/system back button routing. +/// +/// The physical back button has repeatedly broken (commits 39350f93, 0300e890, +/// 79bc736b, plus follow-up touches) because the shell derives "can go back" +/// from booleans reverse-engineered out of the string-keyed view cache +/// (SyncDetailStateFromCachedViews) and a hand-maintained priority ladder +/// (TryHandlePlatformBack). Any new overlay/detail screen — or a renamed nav +/// label — silently breaks back navigation. +/// +/// These tests exercise the exact ShellViewModel entry points MainActivity +/// calls (CanHandlePlatformBack / TryHandlePlatformBack) headlessly, for every +/// state in the ladder and for the priority ordering between them. If you add +/// a new overlay/detail surface, add a case here. +/// +public class PlatformBackRegressionTests +{ + private static ShellViewModel GetShell() + { + var shell = global::App.App.Services.GetRequiredService(); + // Make sure the section views the back ladder inspects exist in the cache, + // exactly as they would after real navigation. These keys are load-bearing: + // SyncDetailStateFromCachedViews pattern-matches on them. If a nav label is + // renamed without updating the back ladder, these tests fail loudly instead + // of the back button silently dying on device. + shell.EnsureViewCreated("Find Projects"); + shell.EnsureViewCreated("Funded"); + shell.EnsureViewCreated("My Projects"); + return shell; + } + + private static FindProjectsViewModel FindProjectsVm(ShellViewModel shell) => + (FindProjectsViewModel)((Control)shell.ViewCache["Find Projects"]).DataContext!; + + private static MyProjectsViewModel MyProjectsVm(ShellViewModel shell) => + (MyProjectsViewModel)((Control)shell.ViewCache["My Projects"]).DataContext!; + + private static PortfolioViewModel PortfolioVm() => + global::App.App.Services.GetRequiredService(); + + /// Returns the shell to a root state so tests can't leak into each other. + private static void ResetToRoot(ShellViewModel shell) + { + shell.HideModal(); + FindProjectsVm(shell).CloseInvestPage(); + FindProjectsVm(shell).CloseProjectDetail(); + PortfolioVm().CloseInvestmentDetail(); + var mp = MyProjectsVm(shell); + mp.CancelCreateWizard(); + mp.CloseEditProfile(); + mp.CloseManageProject(); + shell.SyncDetailStateFromCachedViews(); + } + + // ───────────────────────────────────────────────────────────────────── + // Root state + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void At_root_back_is_not_handled_so_android_may_exit() + { + var shell = GetShell(); + ResetToRoot(shell); + + shell.CanHandlePlatformBack().Should().BeFalse(); + shell.TryHandlePlatformBack().Should().BeFalse(); + } + + // ───────────────────────────────────────────────────────────────────── + // Modal + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void Back_closes_open_modal() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + shell.ShowModal(new TextBlock { Text = "modal" }); + + shell.CanHandlePlatformBack().Should().BeTrue(); + shell.TryHandlePlatformBack().Should().BeTrue(); + shell.IsModalOpen.Should().BeFalse("back must close the modal"); + shell.TryHandlePlatformBack().Should().BeFalse("second back is at root again"); + } + finally + { + ResetToRoot(shell); + } + } + + [AvaloniaFact] + public void Modal_wins_over_open_project_detail() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + FindProjectsVm(shell).OpenProjectDetail(new ProjectItemViewModel { ProjectName = "P" }); + shell.ShowModal(new TextBlock { Text = "modal" }); + + shell.TryHandlePlatformBack().Should().BeTrue(); + shell.IsModalOpen.Should().BeFalse("first back closes the modal, not the detail"); + FindProjectsVm(shell).SelectedProject.Should().NotBeNull("detail must survive the modal close"); + + shell.TryHandlePlatformBack().Should().BeTrue(); + FindProjectsVm(shell).SelectedProject.Should().BeNull("second back closes the detail"); + } + finally + { + ResetToRoot(shell); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Investor detail flow + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void Back_closes_project_detail() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + FindProjectsVm(shell).OpenProjectDetail(new ProjectItemViewModel { ProjectName = "P" }); + + shell.CanHandlePlatformBack().Should().BeTrue(); + shell.TryHandlePlatformBack().Should().BeTrue(); + FindProjectsVm(shell).SelectedProject.Should().BeNull(); + shell.TryHandlePlatformBack().Should().BeFalse(); + } + finally + { + ResetToRoot(shell); + } + } + + [AvaloniaFact] + public void Back_closes_investment_detail() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + PortfolioVm().OpenInvestmentDetail(new InvestmentViewModel + { + ProjectName = "P", + Stages = new ObservableCollection(), + }); + + shell.CanHandlePlatformBack().Should().BeTrue(); + shell.TryHandlePlatformBack().Should().BeTrue(); + PortfolioVm().SelectedInvestment.Should().BeNull(); + shell.TryHandlePlatformBack().Should().BeFalse(); + } + finally + { + ResetToRoot(shell); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Founder create-wizard flow + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void Back_steps_back_through_create_wizard_then_closes_it() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + var mp = MyProjectsVm(shell); + mp.LaunchCreateWizard(); + mp.CreateProjectVm.MaxStepReached = 3; // GoToStep is clamped to MaxStepReached + mp.CreateProjectVm.GoToStep(3); + + shell.CanHandlePlatformBack().Should().BeTrue(); + + shell.TryHandlePlatformBack().Should().BeTrue(); + mp.CreateProjectVm.CurrentStep.Should().Be(2, "back must step the wizard back, not close it"); + mp.ShowCreateWizard.Should().BeTrue(); + + shell.TryHandlePlatformBack().Should().BeTrue(); + mp.CreateProjectVm.CurrentStep.Should().Be(1); + mp.ShowCreateWizard.Should().BeTrue(); + + shell.TryHandlePlatformBack().Should().BeTrue(); + mp.ShowCreateWizard.Should().BeFalse("back at step 1 closes the wizard"); + + shell.TryHandlePlatformBack().Should().BeFalse(); + } + finally + { + ResetToRoot(shell); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Founder edit-profile flow + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void Back_closes_edit_profile() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + MyProjectsVm(shell).OpenEditProfile(new MyProjectItemViewModel + { + Name = "P", + ProjectType = "fund", + ProjectIdentifier = "angor1qtest000000000000000000000000000000000", + }); + + shell.CanHandlePlatformBack().Should().BeTrue(); + shell.TryHandlePlatformBack().Should().BeTrue(); + MyProjectsVm(shell).SelectedEditProject.Should().BeNull(); + shell.TryHandlePlatformBack().Should().BeFalse(); + } + finally + { + ResetToRoot(shell); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Founder manage-funds flow + // ───────────────────────────────────────────────────────────────────── + + [AvaloniaFact] + public void Back_closes_manage_funds() + { + var shell = GetShell(); + ResetToRoot(shell); + try + { + MyProjectsVm(shell).OpenManageProject(new MyProjectItemViewModel + { + Name = "P", + ProjectType = "fund", + ProjectIdentifier = "angor1qtest000000000000000000000000000000000", + }); + + shell.CanHandlePlatformBack().Should().BeTrue(); + shell.TryHandlePlatformBack().Should().BeTrue(); + MyProjectsVm(shell).SelectedManageProject.Should().BeNull(); + shell.TryHandlePlatformBack().Should().BeFalse(); + } + finally + { + ResetToRoot(shell); + } + } +} diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml index e8741e287..85ca0ae2a 100644 --- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml +++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml @@ -101,9 +101,9 @@ - + - + - + Idempotent responsive-layout subscription — re-created on every logical-tree attach because OnDetachedFromLogicalTree disposes it (views are cached and re-attached on section switches). + 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: Start/End date columns stack into one column so the pickers and + /// the month-preset buttons get the full card width (issue #920). + /// + private void ApplyResponsiveLayout(bool isCompact) + { + if (_fundraisingDatesGrid == null || _startDatePanel == null || _endDatePanel == null) return; + + if (isCompact) + { + _fundraisingDatesGrid.ColumnDefinitions[1].Width = new GridLength(0); + _fundraisingDatesGrid.ColumnDefinitions[2].Width = new GridLength(0); + Grid.SetColumn(_endDatePanel, 0); + Grid.SetRow(_endDatePanel, 1); + _endDatePanel.Margin = new Thickness(0, 20, 0, 0); + } + else + { + _fundraisingDatesGrid.ColumnDefinitions[1].Width = new GridLength(24); + _fundraisingDatesGrid.ColumnDefinitions[2].Width = GridLength.Star; + Grid.SetColumn(_endDatePanel, 2); + Grid.SetRow(_endDatePanel, 0); + _endDatePanel.Margin = new Thickness(0); + } } private CreateProjectViewModel? Vm => DataContext as CreateProjectViewModel; @@ -30,6 +89,9 @@ private void ResolveNamedElements() _fundAmountPresets = this.FindControl("FundAmountPresets"); _subPricePresets = this.FindControl("SubPricePresets"); _durationPresets = this.FindControl("DurationPresets"); + _fundraisingDatesGrid = this.FindControl("FundraisingDatesGrid"); + _startDatePanel = this.FindControl("StartDatePanel"); + _endDatePanel = this.FindControl("EndDatePanel"); if (_investAmountPresets != null) _investAmountPresets.SelectionChanged += (_, _) => OnAmountPresetSelected(_investAmountPresets); diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep5View.axaml index 31f1f147c..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 @@ + + + + + + + + + + + - - - - - - - - - - - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + 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; } } diff --git a/src/design/App/UI/Shared/Controls/BalancedWrapPanel.cs b/src/design/App/UI/Shared/Controls/BalancedWrapPanel.cs new file mode 100644 index 000000000..2cdb0b3b7 --- /dev/null +++ b/src/design/App/UI/Shared/Controls/BalancedWrapPanel.cs @@ -0,0 +1,115 @@ +using Avalonia; +using Avalonia.Controls; + +namespace App.UI.Shared.Controls; + +/// +/// Lays out children in rows of at most items, distributing +/// them so rows are as balanced as possible (5 items → 3+2, 6 → 3+3, 4 → 2+2), +/// and stretches each row's children to share the full available width equally. +/// +/// Used for preset-pill rows ("3/6/12/18/24 Months" etc.) so they divide the row +/// nicely instead of hugging their content or leaving ragged gaps (issue #920 follow-up). +/// +public class BalancedWrapPanel : Panel +{ + public static readonly StyledProperty MaxPerRowProperty = + AvaloniaProperty.Register(nameof(MaxPerRow), 3); + + public static readonly StyledProperty ColumnSpacingProperty = + AvaloniaProperty.Register(nameof(ColumnSpacing), 8); + + public static readonly StyledProperty RowSpacingProperty = + AvaloniaProperty.Register(nameof(RowSpacing), 8); + + public int MaxPerRow + { + get => GetValue(MaxPerRowProperty); + set => SetValue(MaxPerRowProperty, value); + } + + public double ColumnSpacing + { + get => GetValue(ColumnSpacingProperty); + set => SetValue(ColumnSpacingProperty, value); + } + + public double RowSpacing + { + get => GetValue(RowSpacingProperty); + set => SetValue(RowSpacingProperty, value); + } + + static BalancedWrapPanel() + { + AffectsMeasure(MaxPerRowProperty, ColumnSpacingProperty, RowSpacingProperty); + } + + /// Items per row, balanced: e.g. 5 items / max 3 → [3, 2]. + private int[] ComputeRows(int count) + { + if (count == 0) return Array.Empty(); + int maxPerRow = Math.Max(1, MaxPerRow); + int rows = (count + maxPerRow - 1) / maxPerRow; + int baseCount = count / rows; + int extra = count % rows; + var result = new int[rows]; + for (int i = 0; i < rows; i++) + result[i] = baseCount + (i < extra ? 1 : 0); + return result; + } + + protected override Size MeasureOverride(Size availableSize) + { + var visible = Children.Where(c => c.IsVisible).ToList(); + var rows = ComputeRows(visible.Count); + double width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width; + double totalHeight = 0; + int index = 0; + + foreach (int itemsInRow in rows) + { + double cellWidth = width > 0 + ? Math.Max(0, (width - ColumnSpacing * (itemsInRow - 1)) / itemsInRow) + : double.PositiveInfinity; + double rowHeight = 0; + for (int i = 0; i < itemsInRow; i++, index++) + { + visible[index].Measure(new Size(cellWidth, double.PositiveInfinity)); + rowHeight = Math.Max(rowHeight, visible[index].DesiredSize.Height); + } + totalHeight += rowHeight; + } + + if (rows.Length > 1) + totalHeight += RowSpacing * (rows.Length - 1); + + return new Size(width, totalHeight); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var visible = Children.Where(c => c.IsVisible).ToList(); + var rows = ComputeRows(visible.Count); + double y = 0; + int index = 0; + + foreach (int itemsInRow in rows) + { + double cellWidth = Math.Max(0, (finalSize.Width - ColumnSpacing * (itemsInRow - 1)) / itemsInRow); + double rowHeight = 0; + for (int i = 0; i < itemsInRow; i++) + rowHeight = Math.Max(rowHeight, visible[index + i].DesiredSize.Height); + + double x = 0; + for (int i = 0; i < itemsInRow; i++, index++) + { + visible[index].Arrange(new Rect(x, y, cellWidth, rowHeight)); + x += cellWidth + ColumnSpacing; + } + y += rowHeight + RowSpacing; + } + + return finalSize; + } +} diff --git a/src/design/App/UI/Shared/PaymentFlow/PaymentFlowView.axaml b/src/design/App/UI/Shared/PaymentFlow/PaymentFlowView.axaml index 1bca11a65..28e378ee3 100644 --- a/src/design/App/UI/Shared/PaymentFlow/PaymentFlowView.axaml +++ b/src/design/App/UI/Shared/PaymentFlow/PaymentFlowView.axaml @@ -74,18 +74,21 @@ - - + + - - + @@ -173,7 +176,7 @@ - + diff --git a/src/design/App/UI/Themes/V2/Controls/ListBox.axaml b/src/design/App/UI/Themes/V2/Controls/ListBox.axaml index 3bff53649..3b826dd3f 100644 --- a/src/design/App/UI/Themes/V2/Controls/ListBox.axaml +++ b/src/design/App/UI/Themes/V2/Controls/ListBox.axaml @@ -14,7 +14,32 @@ + + + + + +