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 @@
+
+
+
+
+
+