diff --git a/src/design/App.Test.Integration/InvestAndRecoverTest.cs b/src/design/App.Test.Integration/InvestAndRecoverTest.cs
index b9b1d3639..2df723de7 100644
--- a/src/design/App.Test.Integration/InvestAndRecoverTest.cs
+++ b/src/design/App.Test.Integration/InvestAndRecoverTest.cs
@@ -370,7 +370,8 @@ public async Task FullInvestAndRecoverFlow()
var signedDeadline = DateTime.UtcNow + TestHelpers.IndexerLagTimeout;
while (DateTime.UtcNow < signedDeadline)
{
- await portfolioVm.LoadInvestmentsFromSdkAsync();
+ await window.ClickButton("PortfolioRefreshButton");
+ await Task.Delay(500);
Dispatcher.UIThread.RunJobs();
signedInvestment = portfolioVm.Investments.FirstOrDefault(i =>
@@ -412,7 +413,8 @@ public async Task FullInvestAndRecoverFlow()
var preSpendDeadline = DateTime.UtcNow + TestHelpers.IndexerLagTimeout;
while (DateTime.UtcNow < preSpendDeadline)
{
- await manageVm!.LoadClaimableTransactionsAsync();
+ await window.ClickButton("ManageProjectRefreshButton");
+ await Task.Delay(500);
Dispatcher.UIThread.RunJobs();
var stage1 = manageVm.Stages.FirstOrDefault(s => s.Number == 1 && s.CanClaim);
@@ -471,7 +473,8 @@ public async Task FullInvestAndRecoverFlow()
var claimableDeadline = DateTime.UtcNow + TestHelpers.IndexerLagTimeout;
while (DateTime.UtcNow < claimableDeadline)
{
- await manageVm.LoadClaimableTransactionsAsync();
+ await window.ClickButton("ManageProjectRefreshButton");
+ await Task.Delay(500);
Dispatcher.UIThread.RunJobs();
var claimableStage = manageVm.Stages.FirstOrDefault(s => s.Number == 1 && s.AvailableTransactions.Count > 0);
@@ -509,7 +512,8 @@ public async Task FullInvestAndRecoverFlow()
i.ProjectName == foundProject.ProjectName || i.ProjectIdentifier == foundProject.ProjectId);
investment.Should().NotBeNull();
- await portfolioVm.LoadInvestmentsFromSdkAsync();
+ await window.ClickButton("PortfolioRefreshButton");
+ await Task.Delay(500);
Dispatcher.UIThread.RunJobs();
var sdkInvestment = portfolioVm.Investments.FirstOrDefault(i =>
diff --git a/src/design/App/App.axaml.cs b/src/design/App/App.axaml.cs
index 651e184cb..11a739f91 100644
--- a/src/design/App/App.axaml.cs
+++ b/src/design/App/App.axaml.cs
@@ -4,6 +4,7 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using App.Composition;
+using App.UI.Shared;
using App.UI.Shell;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
@@ -42,6 +43,9 @@ public override void OnFrameworkInitializationCompleted()
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
+ // Android / iOS / WASM — no window, just set the main view directly.
+ // Force mobile layout since there's no resizable window.
+ LayoutModeService.Instance.UpdateWidth(400);
singleView.MainView = new ShellView();
}
diff --git a/src/design/App/UI/Sections/FindProjects/FindProjectsView.axaml.cs b/src/design/App/UI/Sections/FindProjects/FindProjectsView.axaml.cs
index 8883cfac8..fc2639b3d 100644
--- a/src/design/App/UI/Sections/FindProjects/FindProjectsView.axaml.cs
+++ b/src/design/App/UI/Sections/FindProjects/FindProjectsView.axaml.cs
@@ -1,8 +1,12 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
+using Avalonia.VisualTree;
+using App.UI.Shared;
using App.UI.Shared.Controls;
+using App.UI.Shell;
using System.Reactive.Linq;
namespace App.UI.Sections.FindProjects;
@@ -10,10 +14,12 @@ namespace App.UI.Sections.FindProjects;
public partial class FindProjectsView : UserControl
{
private IDisposable? _visibilitySubscription;
+ private IDisposable? _layoutSubscription;
// Cached FindControl results — avoid repeated tree walks on every visibility update
private Panel? _detailPanel;
private Panel? _investPanel;
+ private ScrollableView? _projectListScrollable;
/// Design-time only.
public FindProjectsView() => InitializeComponent();
@@ -26,6 +32,7 @@ public FindProjectsView(FindProjectsViewModel vm)
// Cache panels once
_detailPanel = this.FindControl("ProjectDetailPanel");
_investPanel = this.FindControl("InvestPagePanel");
+ _projectListScrollable = this.FindControl("ProjectListPanel");
// Wire refresh button
var refreshBtn = this.FindControl
-
-
-
-
-
-
-
-
-
+
diff --git a/src/design/App/UI/Sections/Home/HomeView.axaml b/src/design/App/UI/Sections/Home/HomeView.axaml
index c9823dedf..98f7f8cd0 100644
--- a/src/design/App/UI/Sections/Home/HomeView.axaml
+++ b/src/design/App/UI/Sections/Home/HomeView.axaml
@@ -13,27 +13,35 @@
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
+
-
-
+
-
-
+
+
-
-
+
+
-
-
-
+
+
-
-
+
-
-
+
-
-
+
+
@@ -107,5 +117,4 @@
-
diff --git a/src/design/App/UI/Sections/Home/HomeView.axaml.cs b/src/design/App/UI/Sections/Home/HomeView.axaml.cs
index 942b8faaa..221eaa4d0 100644
--- a/src/design/App/UI/Sections/Home/HomeView.axaml.cs
+++ b/src/design/App/UI/Sections/Home/HomeView.axaml.cs
@@ -1,12 +1,44 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
+using Avalonia.LogicalTree;
using App.UI.Shell;
+using App.UI.Shared;
using Avalonia.VisualTree;
+using ReactiveUI;
namespace App.UI.Sections.Home;
public partial class HomeView : UserControl
{
+ private IDisposable? _layoutSubscription;
+
+ // Cached controls for responsive layout
+ private Grid? _homeGrid;
+ private Grid? _fundCard;
+ private Grid? _getFundedCard;
+ private Border? _tiledLogoBorder;
+
+ // Cached controls for mobile sizing
+ // Vue mobile (App.vue line 214-241): icons w-10 h-10, text-base titles, text-xs descriptions
+ // Vue desktop (App.vue line 2827-2863): icons w-24 h-24, text-3xl titles, 20px descriptions
+ private Viewbox? _fundCardIcon;
+ private TextBlock? _fundCardTitle;
+ private TextBlock? _fundCardDesc;
+ private StackPanel? _fundCardContent;
+ private Border? _fundCardBtnBorder;
+ private Viewbox? _getFundedCardIcon;
+ private TextBlock? _getFundedCardTitle;
+ private TextBlock? _getFundedCardDesc;
+ private StackPanel? _getFundedCardContent;
+ private Border? _getFundedBtnBorder;
+
+ // Vue mobile uses shorter description text than desktop
+ private const string FundDescMobile = "Discover and fund innovative Bitcoin projects.";
+ private const string FundDescDesktop = "Discover and fund innovative Bitcoin projects on the Angor platform.";
+ private const string GetFundedDescMobile = "Create and launch your own projects.";
+ private const string GetFundedDescDesktop = "Create and launch your own projects to raise funding.";
+
/// Design-time only.
public HomeView() => InitializeComponent();
@@ -16,6 +48,177 @@ public HomeView(HomeViewModel vm)
DataContext = vm;
AddHandler(Button.ClickEvent, OnButtonClick, RoutingStrategies.Bubble);
+
+ // Cache responsive layout controls
+ _homeGrid = this.FindControl("HomeGrid");
+ _fundCard = this.FindControl("FundCard");
+ _getFundedCard = this.FindControl("GetFundedCard");
+ _tiledLogoBorder = this.FindControl("TiledLogoBorder");
+
+ // Cache mobile sizing controls
+ _fundCardIcon = this.FindControl("FundCardIcon");
+ _fundCardTitle = this.FindControl("FundCardTitle");
+ _fundCardDesc = this.FindControl("FundCardDesc");
+ _fundCardContent = this.FindControl("FundCardContent");
+ _fundCardBtnBorder = this.FindControl("FundCardBtnBorder");
+ _getFundedCardIcon = this.FindControl("GetFundedCardIcon");
+ _getFundedCardTitle = this.FindControl("GetFundedCardTitle");
+ _getFundedCardDesc = this.FindControl("GetFundedCardDesc");
+ _getFundedCardContent = this.FindControl("GetFundedCardContent");
+ _getFundedBtnBorder = this.FindControl("GetFundedBtnBorder");
+
+ // ── Responsive layout: two-col (desktop) → stacked (compact) ──
+ // No ScrollableView wrapping — the HomeGrid fills available space directly,
+ // so star rows work correctly on both desktop and mobile.
+ _layoutSubscription = LayoutModeService.Instance.WhenAnyValue(x => x.IsCompact)
+ .Subscribe(isCompact => ApplyResponsiveLayout(isCompact));
+ }
+
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ if (_homeGrid == null || _fundCard == null || _getFundedCard == null) return;
+
+ // CRITICAL: Never replace ColumnDefinitions/RowDefinitions collections.
+ // Only modify existing column/row widths. Replacing collections causes
+ // Avalonia's layout engine to crash (SIGABRT) because child controls
+ // briefly reference invalid column/row indices during the swap.
+ //
+ // The XAML Grid always has 3 columns and 3 rows:
+ // Desktop: Col0=* (card), Col1=24 (gap), Col2=* (card) | Row0=* (content), Row1=0, Row2=0
+ // Compact: Col0=* (card), Col1=0, Col2=0 | Row0=* (card), Row1=16 (gap), Row2=* (card)
+ var cols = _homeGrid.ColumnDefinitions;
+ var rows = _homeGrid.RowDefinitions;
+
+ if (_tiledLogoBorder != null)
+ _tiledLogoBorder.IsVisible = true;
+
+ if (isCompact)
+ {
+ // ── MOBILE LAYOUT ──
+ _homeGrid.Margin = new Thickness(16, 16, 16, 16);
+
+ // Collapse columns 1-2, expand rows for stacked cards
+ if (cols.Count >= 3) { cols[0].Width = GridLength.Star; cols[1].Width = new GridLength(0); cols[2].Width = new GridLength(0); }
+ if (rows.Count >= 3) { rows[0].Height = GridLength.Star; rows[1].Height = new GridLength(16); rows[2].Height = GridLength.Star; }
+
+ Grid.SetColumn(_fundCard, 0);
+ Grid.SetRow(_fundCard, 0);
+ Grid.SetColumn(_getFundedCard, 0);
+ Grid.SetRow(_getFundedCard, 2);
+
+ _fundCard.MinHeight = 0;
+ _getFundedCard.MinHeight = 0;
+
+ ApplyMobileCardSizing(_fundCardIcon, _fundCardTitle, _fundCardDesc, _fundCardContent, _fundCardBtnBorder);
+ ApplyMobileCardSizing(_getFundedCardIcon, _getFundedCardTitle, _getFundedCardDesc, _getFundedCardContent, _getFundedBtnBorder);
+ if (_fundCardDesc != null) _fundCardDesc.Text = FundDescMobile;
+ if (_getFundedCardDesc != null) _getFundedCardDesc.Text = GetFundedDescMobile;
+ }
+ else
+ {
+ // ── DESKTOP LAYOUT ──
+ _homeGrid.Margin = new Thickness(24);
+
+ // Expand columns for side-by-side, collapse rows 1-2
+ if (cols.Count >= 3) { cols[0].Width = GridLength.Star; cols[1].Width = new GridLength(24); cols[2].Width = GridLength.Star; }
+ if (rows.Count >= 3) { rows[0].Height = GridLength.Star; rows[1].Height = new GridLength(0); rows[2].Height = new GridLength(0); }
+
+ Grid.SetColumn(_fundCard, 0);
+ Grid.SetRow(_fundCard, 0);
+ Grid.SetColumn(_getFundedCard, 2);
+ Grid.SetRow(_getFundedCard, 0);
+
+ _fundCard.MinHeight = 480;
+ _getFundedCard.MinHeight = 480;
+
+ ApplyDesktopCardSizing(_fundCardIcon, _fundCardTitle, _fundCardDesc, _fundCardContent, _fundCardBtnBorder);
+ ApplyDesktopCardSizing(_getFundedCardIcon, _getFundedCardTitle, _getFundedCardDesc, _getFundedCardContent, _getFundedBtnBorder);
+ if (_fundCardDesc != null) _fundCardDesc.Text = FundDescDesktop;
+ if (_getFundedCardDesc != null) _getFundedCardDesc.Text = GetFundedDescDesktop;
+ }
+ }
+
+ ///
+ /// Apply mobile sizing to a home card.
+ /// Sized up from Vue's text-base/text-xs for readability on mobile devices.
+ ///
+ private static void ApplyMobileCardSizing(
+ Viewbox? icon, TextBlock? title, TextBlock? desc,
+ StackPanel? content, Border? btnBorder)
+ {
+ if (icon != null)
+ {
+ icon.Width = 48;
+ icon.Height = 48;
+ icon.Margin = new Thickness(0, 0, 0, 12); // mb-3
+ }
+ if (title != null)
+ {
+ title.FontSize = 20; // readable on mobile (Vue text-base=16 is too small on device)
+ title.Margin = new Thickness(0, 0, 0, 6);
+ title.Classes.Set("Title", false);
+ }
+ if (desc != null)
+ {
+ desc.FontSize = 14; // readable on mobile (Vue text-xs=12 is too small on device)
+ desc.LineHeight = 20;
+ desc.Margin = new Thickness(0, 0, 0, 16);
+ }
+ if (content != null)
+ {
+ content.Margin = new Thickness(20); // slightly more breathing room
+ }
+ if (btnBorder != null)
+ {
+ // Center the button with a max width cap
+ btnBorder.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center;
+ btnBorder.MaxWidth = 320;
+ }
+ }
+
+ ///
+ /// Apply desktop sizing to a home card.
+ /// Vue desktop: icons w-24 h-24 (80px), mb-8 (32px), text-3xl title, 20px desc,
+ /// p-12 (48px) padding, centered button.
+ ///
+ private static void ApplyDesktopCardSizing(
+ Viewbox? icon, TextBlock? title, TextBlock? desc,
+ StackPanel? content, Border? btnBorder)
+ {
+ if (icon != null)
+ {
+ icon.Width = 80;
+ icon.Height = 80;
+ icon.Margin = new Thickness(0, 0, 0, 32); // mb-8
+ }
+ if (title != null)
+ {
+ title.ClearValue(TextBlock.FontSizeProperty); // Reset to style default
+ title.Margin = new Thickness(0, 0, 0, 16); // mb-4
+ title.Classes.Set("Title", true);
+ }
+ if (desc != null)
+ {
+ desc.FontSize = 20;
+ desc.LineHeight = 32; // leading-relaxed for 20px
+ desc.Margin = new Thickness(0, 0, 0, 32); // mb-8
+ }
+ if (content != null)
+ {
+ content.Margin = new Thickness(48); // p-12 = 48px
+ }
+ if (btnBorder != null)
+ {
+ btnBorder.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center;
+ btnBorder.MaxWidth = double.PositiveInfinity;
+ }
+ }
+
+ protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
+ {
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
+ base.OnDetachedFromLogicalTree(e);
}
private void OnButtonClick(object? sender, RoutedEventArgs e)
diff --git a/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml b/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml
index 9173333ad..e7b518102 100644
--- a/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml
+++ b/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml
@@ -77,17 +77,86 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
@@ -220,7 +289,7 @@
-
-
-
+
diff --git a/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml.cs b/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml.cs
index 94262fa3f..920015164 100644
--- a/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml.cs
+++ b/src/design/App/UI/Sections/MyProjects/CreateProjectView.axaml.cs
@@ -1,9 +1,12 @@
using System.Threading;
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
+using Avalonia.Media;
using Avalonia.VisualTree;
using App.UI.Sections.MyProjects.Deploy;
using App.UI.Sections.MyProjects.Steps;
+using App.UI.Shared;
using App.UI.Shell;
using ReactiveUI;
@@ -18,11 +21,24 @@ public partial class CreateProjectView : UserControl
private Border[] _stepLines = [];
private Button[] _stepButtons = [];
+ // Responsive layout — cached controls
+ private Grid? _wizardMainGrid;
+ private Grid? _stepperColumn;
+ private Border? _mobileWizardHeader;
+ private TextBlock? _mobileStepTitle;
+ private Border[] _progressSegments = [];
+ private Border? _navFooter;
+ private StackPanel? _stepContentPanel;
+
private IDisposable? _deploySubscription;
private IDisposable? _stepSubscription;
private IDisposable? _typeSubscription;
+ private IDisposable? _layoutSubscription;
private CancellationTokenSource? _autoSavedCts;
+ // Track current compact state for UpdateMobileHeader
+ private bool _isCompact;
+
public CreateProjectView()
{
InitializeComponent();
@@ -30,6 +46,30 @@ public CreateProjectView()
AddHandler(Button.ClickEvent, OnButtonClick, RoutingStrategies.Bubble);
+ // Cache responsive controls
+ _wizardMainGrid = this.FindControl("WizardMainGrid");
+ _stepperColumn = this.FindControl("StepperColumn");
+ _mobileWizardHeader = this.FindControl("MobileWizardHeader");
+ _mobileStepTitle = this.FindControl("MobileStepTitle");
+ _navFooter = this.FindControl("NavFooter");
+ _stepContentPanel = this.FindControl("StepContentPanel");
+
+ // Cache progress bar segments
+ _progressSegments =
+ [
+ this.FindControl("ProgressSeg1")!,
+ this.FindControl("ProgressSeg2")!,
+ this.FindControl("ProgressSeg3")!,
+ this.FindControl("ProgressSeg4")!,
+ this.FindControl("ProgressSeg5")!,
+ this.FindControl("ProgressSeg6")!,
+ ];
+
+ // Subscribe to layout mode changes
+ _layoutSubscription = LayoutModeService.Instance
+ .WhenAnyValue(x => x.IsCompact)
+ .Subscribe(ApplyResponsiveLayout);
+
DataContextChanged += OnDataContextSet;
}
@@ -48,10 +88,18 @@ private void SubscribeToVm()
if (DataContext is CreateProjectViewModel vm)
{
_stepSubscription = vm.WhenAnyValue(x => x.CurrentStep)
- .Subscribe(_ => UpdateStepper());
+ .Subscribe(_ =>
+ {
+ UpdateStepper();
+ UpdateMobileHeader();
+ });
_typeSubscription = vm.WhenAnyValue(x => x.ProjectType)
- .Subscribe(_ => UpdateStepperLabels());
+ .Subscribe(_ =>
+ {
+ UpdateStepperLabels();
+ UpdateMobileHeader();
+ });
_deploySubscription = vm.DeployFlow.WhenAnyValue(x => x.IsVisible)
.Subscribe(isVisible =>
@@ -67,6 +115,7 @@ protected override void OnLoaded(RoutedEventArgs e)
base.OnLoaded(e);
ResolveNamedElements();
UpdateStepper();
+ UpdateMobileHeader();
}
protected override void OnAttachedToLogicalTree(Avalonia.LogicalTree.LogicalTreeAttachmentEventArgs e)
@@ -77,6 +126,11 @@ protected override void OnAttachedToLogicalTree(Avalonia.LogicalTree.LogicalTree
// disposed in OnDetachedFromLogicalTree when the user navigates away).
if (_deploySubscription == null)
SubscribeToVm();
+
+ // Re-subscribe layout if needed
+ _layoutSubscription ??= LayoutModeService.Instance
+ .WhenAnyValue(x => x.IsCompact)
+ .Subscribe(ApplyResponsiveLayout);
}
protected override void OnDetachedFromLogicalTree(Avalonia.LogicalTree.LogicalTreeAttachmentEventArgs e)
@@ -88,8 +142,133 @@ protected override void OnDetachedFromLogicalTree(Avalonia.LogicalTree.LogicalTr
_stepSubscription = null;
_typeSubscription?.Dispose();
_typeSubscription = null;
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
+ }
+
+ #region Responsive Layout
+
+ ///
+ /// Responsive layout: compact → hide stepper sidebar, show mobile header with progress bar.
+ /// Vue: App.vue lines 585-650 — completely different template branch on mobile.
+ /// Desktop (>=1024px): two-column — left stepper sidebar (250px) + right content.
+ /// Mobile (<1024px): no stepper sidebar, mobile header with step title + close + progress bar.
+ ///
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ _isCompact = isCompact;
+
+ if (_wizardMainGrid == null) return;
+
+ if (isCompact)
+ {
+ // Hide stepper sidebar column
+ if (_stepperColumn != null) _stepperColumn.IsVisible = false;
+
+ // Collapse the stepper column width to 0
+ _wizardMainGrid.ColumnDefinitions.Clear();
+ _wizardMainGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(0)));
+ _wizardMainGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+
+ // Show mobile header
+ if (_mobileWizardHeader != null) _mobileWizardHeader.IsVisible = true;
+
+ // Add top margin to main grid to clear the mobile header
+ // Mobile header height: ~16 (padding) + 22 (title) + 12 (spacing) + 6 (progress) + 16 (padding bottom) ≈ 72
+ _wizardMainGrid.Margin = new Thickness(0, 72, 0, 0);
+
+ // Reduce content panel side margins for compact screens
+ // Vue: mobile content uses p-4 (16px) instead of 32px
+ if (_stepContentPanel != null)
+ _stepContentPanel.Margin = new Thickness(16, 16, 16, 120); // 120px bottom for tab bar clearance
+
+ // Nav footer: flush with tab bar — no bottom margin needed since
+ // the footer is docked to the bottom of Row 1, directly above Row 2 (tab bar)
+ if (_navFooter != null)
+ _navFooter.Margin = new Thickness(0);
+ }
+ else
+ {
+ // Show stepper sidebar column
+ if (_stepperColumn != null) _stepperColumn.IsVisible = true;
+
+ // Restore two-column layout
+ _wizardMainGrid.ColumnDefinitions.Clear();
+ _wizardMainGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(250)));
+ _wizardMainGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+
+ // Hide mobile header
+ if (_mobileWizardHeader != null) _mobileWizardHeader.IsVisible = false;
+
+ // Restore margins
+ _wizardMainGrid.Margin = new Thickness(0);
+
+ if (_stepContentPanel != null)
+ _stepContentPanel.Margin = new Thickness(32, 32, 32, 24);
+
+ if (_navFooter != null)
+ _navFooter.Margin = new Thickness(0);
+ }
+
+ UpdateMobileHeader();
}
+ ///
+ /// Update the mobile header step title and progress bar segments.
+ /// Vue: getStepTitle(currentStep) for title text.
+ /// Progress bar: green gradient for steps <= currentStep, gray for future.
+ ///
+ private void UpdateMobileHeader()
+ {
+ if (!_isCompact || Vm == null) return;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ // Update step title
+ if (_mobileStepTitle != null)
+ {
+ var names = Vm.StepNames;
+ var stepIdx = Vm.CurrentStep - 1;
+ if (stepIdx >= 0 && stepIdx < names.Length)
+ _mobileStepTitle.Text = names[stepIdx];
+ }
+
+ // Update progress segments
+ // Vue: step <= currentStep → green gradient, else gray-200
+ var greenGradient = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
+ GradientStops =
+ {
+ new GradientStop(Color.Parse("#2D5A3D"), 0),
+ new GradientStop(Color.Parse("#4D7A5D"), 1),
+ }
+ };
+
+ for (int i = 0; i < _progressSegments.Length; i++)
+ {
+ var seg = _progressSegments[i];
+ if (seg == null) continue;
+
+ if (i + 1 <= Vm.CurrentStep)
+ {
+ seg.Background = greenGradient;
+ }
+ else
+ {
+ // Use DynamicResource StrokeSubtle — find from resources
+ if (this.TryFindResource("StrokeSubtle", this.ActualThemeVariant, out var res) && res is IBrush brush)
+ seg.Background = brush;
+ else
+ seg.Background = new SolidColorBrush(Color.Parse("#E5E7EB"));
+ }
+ }
+ }, Avalonia.Threading.DispatcherPriority.Loaded);
+ }
+
+ #endregion
+
private void ResolveNamedElements()
{
_stepCircles =
@@ -149,6 +328,7 @@ private void OnButtonClick(object? sender, RoutedEventArgs e)
case "StartButton":
Vm?.DismissWelcome();
UpdateStepper();
+ UpdateMobileHeader();
break;
case "Step5WelcomeButton":
Vm?.DismissStep5Welcome();
@@ -169,6 +349,10 @@ private void OnButtonClick(object? sender, RoutedEventArgs e)
case "DeployButton":
Vm?.Deploy();
break;
+ // Mobile close button — same as cancel/back to my projects
+ case "MobileCloseBtn":
+ NavigateBackToMyProjects();
+ break;
// Note: UploadBannerButton and UploadAvatarButton are handled directly by Step3
// Step 5 buttons — events bubble up from child UC
case "GenerateStagesButton": Vm?.GenerateInvestmentStages(); break;
@@ -278,6 +462,7 @@ public void ResetVisualState()
{
// Reset stepper
UpdateStepper();
+ UpdateMobileHeader();
// Delegate to child step UCs
this.FindControl("Step1View")?.ResetVisualState();
diff --git a/src/design/App/UI/Sections/MyProjects/CreateProjectViewModel.cs b/src/design/App/UI/Sections/MyProjects/CreateProjectViewModel.cs
index 0a30f8be6..c3ff1f7b6 100644
--- a/src/design/App/UI/Sections/MyProjects/CreateProjectViewModel.cs
+++ b/src/design/App/UI/Sections/MyProjects/CreateProjectViewModel.cs
@@ -167,9 +167,9 @@ public CreateProjectViewModel(DeployFlowViewModel deployFlow, ICurrencyService c
DeployFlow = deployFlow;
_currencyService = currencyService;
_networkConfiguration = networkConfiguration;
- // Default start date to today
+ // Default start date to today (UTC)
StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd");
- InvestStartDate = DateTime.Now;
+ InvestStartDate = DateTime.UtcNow;
// Generate sample stages when a payout pattern is selected (legacy)
this.WhenAnyValue(x => x.SelectedPayoutPattern)
@@ -607,7 +607,7 @@ private CreateProjectDto BuildCreateProjectDto()
{
"fund" => SdkProjectType.Fund,
"subscription" => SdkProjectType.Subscribe,
- "invest" => SdkProjectType.Invest,
+ "invest" or "investment" => SdkProjectType.Invest,
_ => throw new InvalidOperationException(
$"Unknown project type '{ProjectType}'. Cannot deploy project with an unrecognized type.")
};
@@ -1066,7 +1066,7 @@ public void ResetWizard()
PenaltyDays = 90;
ApprovalThreshold = "0.001";
SubscriptionPrice = "";
- InvestStartDate = DateTime.Now;
+ InvestStartDate = DateTime.UtcNow;
InvestEndDate = null;
// Step 5: Stages/Payouts
@@ -1144,18 +1144,18 @@ private void PrepopulateInvestmentDefaults(string id)
{
// Step 2: Profile
ProjectName = $"Debug Project {id}";
- ProjectAbout = $"Auto-populated debug project {id} for testing on testnet. Created at {DateTime.Now:HH:mm:ss}.";
+ ProjectAbout = $"Auto-populated debug project {id} for testing on testnet. Created at {DateTime.UtcNow:HH:mm:ss} UTC.";
ProjectWebsite = "https://angor.io";
// Step 4: Funding config
TargetAmount = "0.01";
PenaltyDays = 0;
- InvestStartDate = DateTime.Now.Date;
- InvestEndDate = DateTime.Now.Date;
+ InvestStartDate = DateTime.UtcNow.Date;
+ InvestEndDate = DateTime.UtcNow.Date;
// Step 5: Stages — 3 stages released today (10%, 30%, 60%)
Stages.Clear();
- var today = DateTime.Now.Date;
+ var today = DateTime.UtcNow.Date;
var targetBtc = 0.01;
Stages.Add(new ProjectStageViewModel
@@ -1198,7 +1198,7 @@ private void PrepopulateFundDefaults(string id)
{
// Step 2: Profile
ProjectName = $"Debug Fund {id}";
- ProjectAbout = $"Auto-populated debug fund {id} for testing on testnet. Created at {DateTime.Now:HH:mm:ss}.";
+ ProjectAbout = $"Auto-populated debug fund {id} for testing on testnet. Created at {DateTime.UtcNow:HH:mm:ss} UTC.";
ProjectWebsite = "https://angor.io";
// Step 4: Funding config
@@ -1206,9 +1206,10 @@ private void PrepopulateFundDefaults(string id)
ApprovalThreshold = "0.01";
PenaltyDays = 0;
- // Step 5: Payouts — Monthly, day = today, installments 3 and 6
+ // Step 5: Payouts — Monthly, day = today (UTC), installments 3 and 6
+ // Use UtcNow.Day to stay consistent with StartDate which is also UTC-based.
PayoutFrequency = "Monthly";
- MonthlyPayoutDate = DateTime.Now.Day;
+ MonthlyPayoutDate = DateTime.UtcNow.Day;
SelectedInstallmentCounts.Clear();
SelectedInstallmentCounts.Add(3);
SelectedInstallmentCounts.Add(6);
@@ -1224,15 +1225,15 @@ private void PrepopulateSubscriptionDefaults(string id)
{
// Step 2: Profile
ProjectName = $"Debug Subscription {id}";
- ProjectAbout = $"Auto-populated debug subscription {id} for testing on testnet. Created at {DateTime.Now:HH:mm:ss}.";
+ ProjectAbout = $"Auto-populated debug subscription {id} for testing on testnet. Created at {DateTime.UtcNow:HH:mm:ss} UTC.";
ProjectWebsite = "https://angor.io";
// Step 4: Subscription price
SubscriptionPrice = "0.0001";
- // Step 5: Payouts — Monthly, day = today, installments 3 and 6
+ // Step 5: Payouts — Monthly, day = today (UTC), installments 3 and 6
PayoutFrequency = "Monthly";
- MonthlyPayoutDate = DateTime.Now.Day;
+ MonthlyPayoutDate = DateTime.UtcNow.Day;
SelectedInstallmentCounts.Clear();
SelectedInstallmentCounts.Add(3);
SelectedInstallmentCounts.Add(6);
diff --git a/src/design/App/UI/Sections/MyProjects/ManageProjectContentView.axaml.cs b/src/design/App/UI/Sections/MyProjects/ManageProjectContentView.axaml.cs
index b3ac88441..be03617d1 100644
--- a/src/design/App/UI/Sections/MyProjects/ManageProjectContentView.axaml.cs
+++ b/src/design/App/UI/Sections/MyProjects/ManageProjectContentView.axaml.cs
@@ -1,6 +1,11 @@
+using System;
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
+using Avalonia.LogicalTree;
+using App.UI.Shared;
using App.UI.Shared.Helpers;
+using ReactiveUI;
namespace App.UI.Sections.MyProjects;
@@ -12,6 +17,19 @@ namespace App.UI.Sections.MyProjects;
///
public partial class ManageProjectContentView : UserControl
{
+ private IDisposable? _layoutSubscription;
+
+ // Cached responsive layout controls
+ private Grid? _manageStatsGrid;
+ private Border? _manageStatCard0;
+ private Border? _manageStatCard1;
+ private Border? _manageStatCard2;
+ private Border? _manageStatCard3;
+ private Grid? _manageStatsRowGrid;
+ private Border? _manageNextStageCard;
+ private Border? _manageTxStatsCard;
+ private StackPanel? _contentStack;
+
public ManageProjectContentView()
{
InitializeComponent();
@@ -31,6 +49,137 @@ public ManageProjectContentView()
if (DataContext is ManageProjectViewModel vm)
ClipboardHelper.CopyToClipboard(this, vm.ProjectId);
};
+
+ // Cache responsive controls
+ _manageStatsGrid = this.FindControl("ManageStatsGrid");
+ _manageStatCard0 = this.FindControl("ManageStatCard0");
+ _manageStatCard1 = this.FindControl("ManageStatCard1");
+ _manageStatCard2 = this.FindControl("ManageStatCard2");
+ _manageStatCard3 = this.FindControl("ManageStatCard3");
+ _manageStatsRowGrid = this.FindControl("ManageStatsRowGrid");
+ _manageNextStageCard = this.FindControl("ManageNextStageCard");
+ _manageTxStatsCard = this.FindControl("ManageTxStatsCard");
+ _contentStack = this.FindControl("ContentStack");
+
+ // Subscribe to layout mode changes
+ _layoutSubscription = LayoutModeService.Instance
+ .WhenAnyValue(x => x.IsCompact)
+ .Subscribe(ApplyResponsiveLayout);
+ }
+
+ ///
+ /// Responsive layout: compact → stats stack single column, side-by-side → stacked,
+ /// stage pills stack vertically, bottom padding for tab bar clearance.
+ /// Vue: <=1024px → stats repeat(2,1fr), stats-row 1fr; <=640px → stats 1fr.
+ /// Vue: <=768px → .stage-header-left column, .stage-pills column, padding-bottom 96px.
+ /// We use IsCompact (<=1024px) → 1-col stacked for both.
+ ///
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ if (_manageStatsGrid == null) return;
+
+ // Toggle CSS class for style-selector-driven changes (stage pills, etc.)
+ Classes.Set("Compact", isCompact);
+
+ // Bottom padding for tab bar clearance
+ // Vue: <=768px → .content-grid { padding-bottom: 96px }
+ if (_contentStack != null)
+ _contentStack.Margin = isCompact ? new Thickness(0, 0, 0, 96) : new Thickness(0);
+
+ if (isCompact)
+ {
+ // Stats grid: single column stacked
+ _manageStatsGrid.ColumnDefinitions.Clear();
+ _manageStatsGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _manageStatsGrid.RowDefinitions.Clear();
+ for (int i = 0; i < 4; i++)
+ _manageStatsGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ SetCardCompact(_manageStatCard0, 0);
+ SetCardCompact(_manageStatCard1, 1);
+ SetCardCompact(_manageStatCard2, 2);
+ SetCardCompact(_manageStatCard3, 3);
+
+ // Stats row: stack Next Stage on top of Transaction Stats
+ // Vue: <=1024px → grid-template-columns: 1fr
+ if (_manageStatsRowGrid != null)
+ {
+ _manageStatsRowGrid.ColumnDefinitions.Clear();
+ _manageStatsRowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _manageStatsRowGrid.RowDefinitions.Clear();
+ _manageStatsRowGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ _manageStatsRowGrid.RowDefinitions.Add(new RowDefinition(new GridLength(24)));
+ _manageStatsRowGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ if (_manageNextStageCard != null)
+ {
+ Grid.SetColumn(_manageNextStageCard, 0);
+ Grid.SetRow(_manageNextStageCard, 0);
+ }
+ if (_manageTxStatsCard != null)
+ {
+ Grid.SetColumn(_manageTxStatsCard, 0);
+ Grid.SetRow(_manageTxStatsCard, 2);
+ }
+ }
+ }
+ else
+ {
+ // Stats grid: 4 columns
+ _manageStatsGrid.ColumnDefinitions.Clear();
+ for (int i = 0; i < 4; i++)
+ _manageStatsGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _manageStatsGrid.RowDefinitions.Clear();
+
+ SetCardDesktop(_manageStatCard0, 0, new Thickness(0, 0, 8, 0));
+ SetCardDesktop(_manageStatCard1, 1, new Thickness(8, 0, 8, 0));
+ SetCardDesktop(_manageStatCard2, 2, new Thickness(8, 0, 8, 0));
+ SetCardDesktop(_manageStatCard3, 3, new Thickness(8, 0, 0, 0));
+
+ // Stats row: side by side (1fr, 24px spacer, 1fr)
+ if (_manageStatsRowGrid != null)
+ {
+ _manageStatsRowGrid.ColumnDefinitions.Clear();
+ _manageStatsRowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _manageStatsRowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(24)));
+ _manageStatsRowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _manageStatsRowGrid.RowDefinitions.Clear();
+
+ if (_manageNextStageCard != null)
+ {
+ Grid.SetColumn(_manageNextStageCard, 0);
+ Grid.SetRow(_manageNextStageCard, 0);
+ }
+ if (_manageTxStatsCard != null)
+ {
+ Grid.SetColumn(_manageTxStatsCard, 2);
+ Grid.SetRow(_manageTxStatsCard, 0);
+ }
+ }
+ }
+ }
+
+ private static void SetCardCompact(Border? card, int row)
+ {
+ if (card == null) return;
+ Grid.SetColumn(card, 0);
+ Grid.SetRow(card, row);
+ card.Margin = new Thickness(0, row > 0 ? 12 : 0, 0, 0);
+ }
+
+ private static void SetCardDesktop(Border? card, int col, Thickness margin)
+ {
+ if (card == null) return;
+ Grid.SetColumn(card, col);
+ Grid.SetRow(card, 0);
+ card.Margin = margin;
+ }
+
+ protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
+ {
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
+ base.OnDetachedFromLogicalTree(e);
}
///
diff --git a/src/design/App/UI/Sections/MyProjects/ManageProjectView.axaml b/src/design/App/UI/Sections/MyProjects/ManageProjectView.axaml
index 994425e20..901cd8625 100644
--- a/src/design/App/UI/Sections/MyProjects/ManageProjectView.axaml
+++ b/src/design/App/UI/Sections/MyProjects/ManageProjectView.axaml
@@ -103,6 +103,7 @@
_logger;
private ManageProjectViewModel? Vm => DataContext as ManageProjectViewModel;
+ private IDisposable? _layoutSubscription;
+ private readonly ILogger _logger;
+
+ // Cached responsive controls
+ private DockPanel? _navBar;
+ private Panel? _navSpacer;
+ private StackPanel? _contentStack;
public ManageProjectView()
{
InitializeComponent();
_logger = App.Services.GetRequiredService().CreateLogger();
+ // Cache responsive controls
+ _navBar = this.FindControl("ManageNavBar");
+ _navSpacer = this.FindControl("ManageNavSpacer");
+ _contentStack = this.FindControl("ManageContentStack");
+
// ── Wire content -> modals bridge: stage buttons open claim/spent modals ──
var contentView = this.FindControl("ContentView");
if (contentView != null)
@@ -46,6 +60,44 @@ public ManageProjectView()
// ── View Private Keys button (opens shell modal password step) ──
var viewPKBtn = this.FindControl("ViewPrivateKeysButton");
if (viewPKBtn != null) viewPKBtn.Click += OnViewPrivateKeysClick;
+
+ // Subscribe to layout mode changes
+ _layoutSubscription = LayoutModeService.Instance
+ .WhenAnyValue(x => x.IsCompact)
+ .Subscribe(ApplyResponsiveLayout);
+ }
+
+ ///
+ /// Responsive layout: compact → hide nav bar (bottom tab bar provides navigation),
+ /// reduce side margins and gap.
+ /// Vue: <=768px → nav hidden, content padding 16px sides + 16px gap + 96px bottom.
+ ///
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ // Hide desktop nav bar on compact — the shell's bottom tab bar provides navigation
+ // Vue: .sticky-nav-bar { display: none !important; } at <=768px
+ if (_navBar != null) _navBar.IsVisible = !isCompact;
+
+ // Adjust spacer: no nav bar means no spacer needed
+ if (_navSpacer != null) _navSpacer.Height = isCompact ? 0 : 92;
+
+ // Adjust content margins and spacing
+ // Vue: <=768px → .content-grid { gap: 16px; padding: 0 16px 96px 16px; }
+ // Note: the 96px bottom padding is handled by ManageProjectContentView's _contentStack
+ if (_contentStack != null)
+ {
+ _contentStack.Spacing = isCompact ? 16 : 24;
+ _contentStack.Margin = isCompact
+ ? new Avalonia.Thickness(16, 0, 16, 0)
+ : new Avalonia.Thickness(24, 0, 24, 24);
+ }
+ }
+
+ protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
+ {
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
+ base.OnDetachedFromLogicalTree(e);
}
// Track the back button handler to prevent accumulation across SetBackAction calls
@@ -95,6 +147,10 @@ private void OnShareClick(object? sender, RoutedEventArgs e)
}
}
+ // ─────────────────────────────────────────────────────────────────
+ // REFRESH
+ // ─────────────────────────────────────────────────────────────────
+
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
try
@@ -119,9 +175,7 @@ private void OnViewPrivateKeysClick(object? sender, RoutedEventArgs e)
var shell = this.FindAncestorOfType();
if (shell?.DataContext is ShellViewModel shellVm && !shellVm.IsModalOpen)
{
- // Skip password modal — password is not used (SimplePasswordProvider returns "default-key").
- // Open the keys display modal directly.
- var modal = new PrivateKeysDisplayModal(
+ var modal = new PrivateKeysPasswordModal(
Vm.ProjectId, Vm.FounderKey, Vm.RecoveryKey,
Vm.NostrNpub, Vm.Nip05, Vm.NostrNsec, Vm.NostrHex);
shellVm.ShowModal(modal);
diff --git a/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml b/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml
index 73b239d35..15ce2098d 100644
--- a/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml
+++ b/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml
@@ -13,8 +13,12 @@
-
-
+
+
+
-
-
-
+
+
+
+
+
-
+
-
-
+
+
-
-
+
-
-
+
-
-
+
+
+
@@ -77,11 +99,13 @@
FontSize="14"
Foreground="{DynamicResource TextMuted}" />
+
+
+
@@ -105,17 +130,19 @@
FontSize="14"
Foreground="{DynamicResource TextMuted}" />
+
-
+
-
-
+
+
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -222,7 +246,7 @@
-
+
diff --git a/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml.cs b/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml.cs
index 220e34f27..43a1ce3a5 100644
--- a/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml.cs
+++ b/src/design/App/UI/Sections/MyProjects/MyProjectsView.axaml.cs
@@ -2,6 +2,7 @@
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
+using App.UI.Shared;
using App.UI.Shared.Controls;
using App.UI.Shell;
@@ -10,6 +11,20 @@ namespace App.UI.Sections.MyProjects;
public partial class MyProjectsView : UserControl
{
private CompositeDisposable? _subscriptions;
+ private IDisposable? _layoutSubscription;
+
+ // Cached controls for responsive layout
+ private Grid? _projectListGrid;
+ private Border? _myProjectsSidebar;
+ private ScrollableView? _myProjectsContent;
+ // Sidebar hero elements — hidden on compact (Vue mobile shows only action buttons)
+ private Panel? _sidebarLogo;
+ private TextBlock? _sidebarTitle;
+ private TextBlock? _sidebarSubtitle;
+ private Grid? _sidebarStats;
+ private Button? _howFundingWorksBtn;
+ private StackPanel? _mobileActionPanel;
+ private StackPanel? _sidebarCTAButtons;
/// Design-time only.
public MyProjectsView() => InitializeComponent();
@@ -17,15 +32,13 @@ public partial class MyProjectsView : UserControl
public MyProjectsView(MyProjectsViewModel vm)
{
InitializeComponent();
+ DataContext = vm;
- // Set the wizard's DataContext before the parent's to avoid DataContext inheritance.
- // The child views have DataContext="{x:Null}" in XAML to block propagation;
- // without this, ~100 compiled-binding InvalidCastExceptions fire on every construction.
+ // Set the create wizard's DataContext from the parent VM
+ // (CreateProjectView is XAML-embedded, so it can't use constructor injection)
if (CreateWizardView != null)
CreateWizardView.DataContext = vm.CreateProjectVm;
- DataContext = vm;
-
_subscriptions = new CompositeDisposable();
AddHandler(Button.ClickEvent, OnButtonClick, RoutingStrategies.Bubble);
@@ -40,6 +53,84 @@ public MyProjectsView(MyProjectsViewModel vm)
// Check if we should auto-open the wizard (from Home "Launch a Project" button)
AttachedToVisualTree += OnAttachedToVisualTree;
+
+ // ── Cache responsive layout controls ──
+ _projectListGrid = this.FindControl("ProjectListGrid");
+ _myProjectsSidebar = this.FindControl("MyProjectsSidebar");
+ _myProjectsContent = this.FindControl("MyProjectsContent");
+ _sidebarLogo = this.FindControl("SidebarLogo");
+ _sidebarTitle = this.FindControl("SidebarTitle");
+ _sidebarSubtitle = this.FindControl("SidebarSubtitle");
+ _sidebarStats = this.FindControl("SidebarStats");
+ _howFundingWorksBtn = this.FindControl("HowFundingWorksBtn");
+ _mobileActionPanel = this.FindControl("MobileActionPanel");
+ _sidebarCTAButtons = this.FindControl("SidebarCTAButtons");
+
+ // ── Responsive layout: 380px sidebar + content (desktop) → stacked (compact) ──
+ _layoutSubscription = LayoutModeService.Instance.WhenAnyValue(x => x.IsCompact)
+ .Subscribe(isCompact => ApplyResponsiveLayout(isCompact));
+ }
+
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ if (_projectListGrid == null || _myProjectsSidebar == null || _myProjectsContent == null) return;
+
+ // CRITICAL: modify existing column/row widths in-place — never Clear()+Add().
+ // XAML Grid always has 2 columns and 2 rows:
+ // Desktop: Col0=380 (sidebar), Col1=* (content) | Row0=Auto (unused), Row1=* (content in row 0)
+ // Compact: Col0=* (full width), Col1=0 (hidden) | Row0=Auto (sidebar buttons), Row1=* (content)
+ var cols = _projectListGrid.ColumnDefinitions;
+ var rows = _projectListGrid.RowDefinitions;
+
+ // Hide sidebar hero content on compact — Vue mobile shows only action buttons
+ if (_sidebarLogo != null) _sidebarLogo.IsVisible = !isCompact;
+ if (_sidebarTitle != null) _sidebarTitle.IsVisible = !isCompact;
+ if (_sidebarSubtitle != null) _sidebarSubtitle.IsVisible = !isCompact;
+ if (_sidebarStats != null) _sidebarStats.IsVisible = !isCompact;
+ if (_howFundingWorksBtn != null) _howFundingWorksBtn.IsVisible = !isCompact;
+ if (_mobileActionPanel != null) _mobileActionPanel.IsVisible = isCompact;
+ if (_sidebarCTAButtons != null) _sidebarCTAButtons.IsVisible = !isCompact;
+
+ if (isCompact)
+ {
+ // Collapse sidebar column, use rows for stacked layout
+ if (cols.Count >= 2) { cols[0].Width = GridLength.Star; cols[1].Width = new GridLength(0); }
+ if (rows.Count >= 2) { rows[0].Height = GridLength.Auto; rows[1].Height = GridLength.Star; }
+
+ Grid.SetColumn(_myProjectsSidebar, 0);
+ Grid.SetRow(_myProjectsSidebar, 0);
+ _myProjectsSidebar.Margin = new Avalonia.Thickness(0, 0, 0, 16);
+ _myProjectsSidebar.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
+ // Strip card styling — just show the button
+ _myProjectsSidebar.Background = null;
+ _myProjectsSidebar.BorderThickness = new Avalonia.Thickness(0);
+ _myProjectsSidebar.BoxShadow = new Avalonia.Media.BoxShadows(default);
+ _myProjectsSidebar.Padding = new Avalonia.Thickness(0);
+
+ Grid.SetColumn(_myProjectsContent, 0);
+ Grid.SetRow(_myProjectsContent, 1);
+ _myProjectsContent.ContentPadding = new Avalonia.Thickness(0, 0, 0, 96);
+ }
+ else
+ {
+ // Side by side: 380px sidebar + * content, single row
+ if (cols.Count >= 2) { cols[0].Width = new GridLength(380); cols[1].Width = GridLength.Star; }
+ if (rows.Count >= 2) { rows[0].Height = GridLength.Star; rows[1].Height = new GridLength(0); }
+
+ Grid.SetColumn(_myProjectsSidebar, 0);
+ Grid.SetRow(_myProjectsSidebar, 0);
+ _myProjectsSidebar.Margin = new Avalonia.Thickness(0, 0, 24, 0);
+ _myProjectsSidebar.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Stretch;
+ // Restore card styling — clear local overrides so XAML DynamicResource values take effect
+ _myProjectsSidebar.ClearValue(Avalonia.Controls.Border.BackgroundProperty);
+ _myProjectsSidebar.ClearValue(Avalonia.Controls.Border.BorderThicknessProperty);
+ _myProjectsSidebar.ClearValue(Avalonia.Controls.Border.BoxShadowProperty);
+ _myProjectsSidebar.ClearValue(Avalonia.Controls.Border.PaddingProperty);
+
+ Grid.SetColumn(_myProjectsContent, 1);
+ Grid.SetRow(_myProjectsContent, 0);
+ _myProjectsContent.ContentPadding = new Avalonia.Thickness(0);
+ }
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -61,10 +152,13 @@ private void SubscribeToVisibility(MyProjectsViewModel vm)
{
if (CreateWizardPanel != null) CreateWizardPanel.IsVisible = showWizard;
UpdateListVisibility(vm);
- // Update shell title
+ // Update shell title + mobile detail state
var shell = this.FindAncestorOfType();
if (shell?.DataContext is ShellViewModel shellVm)
+ {
shellVm.SectionTitleOverride = showWizard ? "Create New Project" : null;
+ shellVm.IsCreatingProject = showWizard;
+ }
})
.DisposeWith(_subscriptions!);
@@ -88,10 +182,12 @@ private void SubscribeToVisibility(MyProjectsViewModel vm)
UpdateListVisibility(vm);
- // Set shell title to project name when managing
+ // Set shell title + mobile detail state for manage funds
var shell = this.FindAncestorOfType();
if (shell?.DataContext is ShellViewModel shellVm)
{
+ shellVm.IsManageFundsOpen = manageVm != null;
+
if (manageVm != null)
shellVm.SectionTitleOverride = manageVm.Project.Name;
else if (!vm.ShowCreateWizard)
@@ -132,6 +228,8 @@ protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs
{
_subscriptions?.Dispose();
_subscriptions = null;
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
base.OnDetachedFromLogicalTree(e);
}
@@ -140,8 +238,10 @@ private void UpdateListVisibility(MyProjectsViewModel vm)
var showWizard = vm.ShowCreateWizard;
var showManage = vm.SelectedManageProject != null;
- if (MainContentPanel != null)
- MainContentPanel.IsVisible = !showWizard && !showManage;
+ if (EmptyStatePanel != null)
+ EmptyStatePanel.IsVisible = !showWizard && !showManage && !vm.HasProjects;
+ if (ProjectListPanel != null)
+ ProjectListPanel.IsVisible = !showWizard && !showManage && vm.HasProjects;
}
private void OnButtonClick(object? sender, RoutedEventArgs e)
@@ -152,6 +252,7 @@ private void OnButtonClick(object? sender, RoutedEventArgs e)
switch (btn.Name)
{
case "LaunchFromListButton":
+ case "MobileLaunchButton":
OpenCreateWizard(vm);
return;
@@ -193,6 +294,24 @@ private void OnButtonClick(object? sender, RoutedEventArgs e)
}
return;
}
+
+ // EmptyState button doesn't have a Name — check by content
+ if (btn.Content is Avalonia.Controls.StackPanel sp)
+ {
+ foreach (var child in sp.Children)
+ {
+ if (child is TextBlock tb && tb.Text == "Launch a Project")
+ {
+ OpenCreateWizard(vm);
+ return;
+ }
+ }
+ }
+ // Also check direct TextBlock content inside button from EmptyState
+ if (btn.Content is string s && s == "Launch a Project")
+ {
+ OpenCreateWizard(vm);
+ }
}
private void OpenCreateWizard(MyProjectsViewModel vm)
diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep1View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep1View.axaml
index 141b2db40..9fbfc942f 100644
--- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep1View.axaml
+++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep1View.axaml
@@ -112,6 +112,9 @@
+
diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml.cs b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml.cs
index 7c3d79207..22565eef6 100644
--- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml.cs
+++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep4View.axaml.cs
@@ -78,7 +78,7 @@ private void OnDurationPresetSelected(ListBox lb)
if (lb.SelectedItem is not ListBoxItem item) return;
if (item.Tag is string tag && int.TryParse(tag, out var months) && Vm != null)
{
- Vm.InvestEndDate = DateTime.Now.AddMonths(months);
+ Vm.InvestEndDate = DateTime.UtcNow.AddMonths(months);
}
}
diff --git a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep6View.axaml b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep6View.axaml
index 4a6dd25dc..e7fcb4cbe 100644
--- a/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep6View.axaml
+++ b/src/design/App/UI/Sections/MyProjects/Steps/CreateProjectStep6View.axaml
@@ -89,9 +89,8 @@
Foreground="{DynamicResource TextStrong}" FontWeight="Bold"
HorizontalAlignment="Right">
-
+
-
@@ -129,7 +128,7 @@
-
+
@@ -169,16 +168,24 @@
CornerRadius="10"
BorderBrush="{DynamicResource Accent}" BorderThickness="1"
Background="{DynamicResource PrimaryGreenBrush04}">
-
-
+
+
-
+
-
+
diff --git a/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml b/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml
index 63f7c0350..d11de96dd 100644
--- a/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml
+++ b/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml
@@ -55,8 +55,9 @@
+
-
-
+
@@ -162,7 +164,7 @@
-
@@ -194,7 +196,7 @@
-
@@ -240,8 +242,9 @@
+
-
+
diff --git a/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml.cs b/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml.cs
index 296731c88..f31f3aba1 100644
--- a/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml.cs
+++ b/src/design/App/UI/Sections/Portfolio/PortfolioView.axaml.cs
@@ -2,6 +2,7 @@
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
+using App.UI.Shared;
using App.UI.Shared.Controls;
using App.UI.Shell;
using System.Reactive.Linq;
@@ -11,6 +12,12 @@ namespace App.UI.Sections.Portfolio;
public partial class PortfolioView : UserControl
{
private IDisposable? _visibilitySubscription;
+ private IDisposable? _layoutSubscription;
+
+ // Cached controls for responsive layout
+ private Grid? _portfolioGrid;
+ private Border? _sidebar;
+ private ScrollableView? _content;
/// Design-time only.
public PortfolioView() => InitializeComponent();
@@ -32,6 +39,57 @@ public PortfolioView(PortfolioViewModel vm)
// Wire Penalties button to open shell modal
var penaltiesBtn = this.FindControl("PenaltiesButton");
if (penaltiesBtn != null) penaltiesBtn.Click += OnPenaltiesClick;
+
+ // ── Cache responsive layout controls ──
+ _portfolioGrid = this.FindControl("PortfolioListPanel");
+ _sidebar = this.FindControl("PortfolioSidebar");
+ _content = this.FindControl("PortfolioContent");
+
+ // ── Responsive layout: 380px sidebar + content (desktop) → stacked (compact) ──
+ _layoutSubscription = LayoutModeService.Instance.WhenAnyValue(x => x.IsCompact)
+ .Subscribe(isCompact => ApplyResponsiveLayout(isCompact));
+ }
+
+ private void ApplyResponsiveLayout(bool isCompact)
+ {
+ if (_portfolioGrid == null || _sidebar == null || _content == null) return;
+
+ if (isCompact)
+ {
+ // Stacked: single column, sidebar above content
+ _portfolioGrid.ColumnDefinitions.Clear();
+ _portfolioGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _portfolioGrid.RowDefinitions.Clear();
+ _portfolioGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ _portfolioGrid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+
+ Grid.SetColumn(_sidebar, 0);
+ Grid.SetRow(_sidebar, 0);
+ _sidebar.Margin = new Avalonia.Thickness(0, 0, 0, 24);
+ _sidebar.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
+
+ Grid.SetColumn(_content, 0);
+ Grid.SetRow(_content, 1);
+ _content.ContentPadding = new Avalonia.Thickness(0, 0, 0, 96);
+ }
+ else
+ {
+ // Side by side: 380px sidebar + * content
+ _portfolioGrid.ColumnDefinitions.Clear();
+ _portfolioGrid.ColumnDefinitions.Add(new ColumnDefinition(380, GridUnitType.Pixel));
+ _portfolioGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _portfolioGrid.RowDefinitions.Clear();
+ _portfolioGrid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+
+ Grid.SetColumn(_sidebar, 0);
+ Grid.SetRow(_sidebar, 0);
+ _sidebar.Margin = new Avalonia.Thickness(0, 0, 24, 0);
+ _sidebar.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Stretch;
+
+ Grid.SetColumn(_content, 1);
+ Grid.SetRow(_content, 0);
+ _content.ContentPadding = new Avalonia.Thickness(0, 0, 16, 0);
+ }
}
private void SubscribeToVisibility()
@@ -50,11 +108,19 @@ private void SubscribeToVisibility()
var visibilitySub = vm.WhenAnyValue(
x => x.HasInvestments,
x => x.SelectedInvestment,
- (hasInvestments, selected) => hasInvestments && selected == null)
- .Subscribe(visible =>
+ (hasInvestments, selected) => (hasInvestments, selected))
+ .Subscribe(tuple =>
{
+ var (hasInvestments, selected) = tuple;
+ var visible = hasInvestments && selected == null;
+
if (PortfolioListPanel != null)
PortfolioListPanel.IsVisible = visible;
+
+ // Publish detail view state to ShellViewModel for mobile back-button visibility
+ var shell = this.FindAncestorOfType();
+ if (shell?.DataContext is ShellViewModel shellVm)
+ shellVm.IsInvestmentDetailOpen = selected != null;
});
_visibilitySubscription = new System.Reactive.Disposables.CompositeDisposable(_visibilitySubscription, visibilitySub);
@@ -79,6 +145,8 @@ protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs
{
_visibilitySubscription?.Dispose();
_visibilitySubscription = null;
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
base.OnDetachedFromLogicalTree(e);
}
diff --git a/src/design/App/UI/Sections/Portfolio/PortfolioViewModel.cs b/src/design/App/UI/Sections/Portfolio/PortfolioViewModel.cs
index 49942ae81..77f158a9a 100644
--- a/src/design/App/UI/Sections/Portfolio/PortfolioViewModel.cs
+++ b/src/design/App/UI/Sections/Portfolio/PortfolioViewModel.cs
@@ -616,7 +616,7 @@ public async Task LoadInvestmentsFromSdkAsync()
ShortDescription = dto.Description ?? "",
TotalInvested = investedBtc.ToString("F8", CultureInfo.InvariantCulture),
FundingAmount = $"{investedBtc:F4} {_currencyService.Symbol}",
- FundingDate = DateTime.Now.ToString("M/dd/yyyy"),
+ FundingDate = DateTime.UtcNow.ToString("M/dd/yyyy"),
TypeLabel = typeLabel,
StatusText = statusText,
StatusClass = statusClass,
@@ -1220,7 +1220,7 @@ public void AddInvestmentFromProject(ProjectItemViewModel project, string invest
ProjectName = project.ProjectName,
ShortDescription = project.ShortDescription,
FundingAmount = $"{investmentAmount} {_currencyService.Symbol}",
- FundingDate = DateTime.Now.ToString("M/dd/yyyy"),
+ FundingDate = DateTime.UtcNow.ToString("M/dd/yyyy"),
TypeLabel = typeLabel,
StatusText = isAutoApproved ? $"{typeLabel} Active" : "Awaiting Approval",
StatusClass = isAutoApproved ? "active" : "waiting",
@@ -1241,9 +1241,9 @@ public void AddInvestmentFromProject(ProjectItemViewModel project, string invest
TargetAmount = project.Target,
TotalRaised = project.Raised,
TotalInvestors = project.InvestorCount,
- StartDate = DateTime.Now.ToString("MMM dd, yyyy"),
+ StartDate = DateTime.UtcNow.ToString("MMM dd, yyyy"),
EndDate = project.EndDate,
- TransactionDate = DateTime.Now.ToString("MMM dd, yyyy"),
+ TransactionDate = DateTime.UtcNow.ToString("MMM dd, yyyy"),
ApprovalStatus = isAutoApproved ? "Approved" : "Pending",
ProjectIdentifier = project.ProjectId,
Stages = stages,
diff --git a/src/design/App/UI/Shared/Controls/EmptyState.axaml b/src/design/App/UI/Shared/Controls/EmptyState.axaml
index 686e456ba..3b13864c6 100644
--- a/src/design/App/UI/Shared/Controls/EmptyState.axaml
+++ b/src/design/App/UI/Shared/Controls/EmptyState.axaml
@@ -36,6 +36,7 @@
diff --git a/src/design/App/UI/Shared/Controls/PrivateKeysPasswordModalViewModel.cs b/src/design/App/UI/Shared/Controls/PrivateKeysPasswordModalViewModel.cs
new file mode 100644
index 000000000..913ee3f0c
--- /dev/null
+++ b/src/design/App/UI/Shared/Controls/PrivateKeysPasswordModalViewModel.cs
@@ -0,0 +1,62 @@
+namespace App.UI.Shared.Controls;
+
+///
+/// ViewModel for the Private Keys Password modal — owns password validation state.
+/// Follows the [Reactive] validation pattern established by CreateProjectViewModel.
+/// Vue ref: ManageFunds.vue lines 559-611.
+///
+public partial class PrivateKeysPasswordModalViewModel : ReactiveObject
+{
+ // ── Form input ──
+ [Reactive] private string password = "";
+
+ // ── Validation ──
+ [Reactive] private string passwordError = "";
+
+ public bool HasPasswordError => !string.IsNullOrEmpty(PasswordError);
+
+ // ── Key data (passed through to PrivateKeysDisplayModal) ──
+ public string ProjectId { get; }
+ public string FounderKey { get; }
+ public string RecoveryKey { get; }
+ public string NostrNpub { get; }
+ public string Nip05 { get; }
+ public string NostrNsec { get; }
+ public string NostrHex { get; }
+
+ public PrivateKeysPasswordModalViewModel(
+ string projectId, string founderKey, string recoveryKey,
+ string nostrNpub, string nip05, string nostrNsec, string nostrHex)
+ {
+ ProjectId = projectId;
+ FounderKey = founderKey;
+ RecoveryKey = recoveryKey;
+ NostrNpub = nostrNpub;
+ Nip05 = nip05;
+ NostrNsec = nostrNsec;
+ NostrHex = nostrHex;
+
+ // Clear error on typing (Vue: @input clears errors)
+ this.WhenAnyValue(x => x.Password)
+ .Subscribe(_ =>
+ {
+ PasswordError = "";
+ this.RaisePropertyChanged(nameof(HasPasswordError));
+ });
+ }
+
+ ///
+ /// Validate password. Returns true if valid.
+ ///
+ public bool ValidateAndViewKeys()
+ {
+ if (string.IsNullOrWhiteSpace(Password))
+ {
+ PasswordError = "Password is required";
+ this.RaisePropertyChanged(nameof(HasPasswordError));
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/design/App/UI/Shared/Controls/ProjectCard.axaml b/src/design/App/UI/Shared/Controls/ProjectCard.axaml
index b7d400f71..ebdea5fbd 100644
--- a/src/design/App/UI/Shared/Controls/ProjectCard.axaml
+++ b/src/design/App/UI/Shared/Controls/ProjectCard.axaml
@@ -160,7 +160,7 @@
FontSize="13"
FontWeight="Bold"
Foreground="{DynamicResource TextStrong}" />
-
@@ -178,7 +178,7 @@
-
diff --git a/src/design/App/UI/Shared/Controls/ShareModal.axaml b/src/design/App/UI/Shared/Controls/ShareModal.axaml
index 07dbcfb41..b1152ce72 100644
--- a/src/design/App/UI/Shared/Controls/ShareModal.axaml
+++ b/src/design/App/UI/Shared/Controls/ShareModal.axaml
@@ -70,8 +70,8 @@
-
-
+
+ VerticalAlignment="Center"
+ TextWrapping="NoWrap"
+ TextTrimming="CharacterEllipsis" />
-
+ FontSize="12"
+ Foreground="{DynamicResource TextMuted}"
+ TextWrapping="NoWrap"
+ TextTrimming="CharacterEllipsis" />
-
+
-
-
-
+ Foreground="{DynamicResource PrimaryGreenBrush}"
+ IsVisible="{Binding !IsRefreshing, RelativeSource={RelativeSource TemplatedParent}}" />
-
-
-
-
-
-
-
+ IsVisible="{TemplateBinding IsRefreshing}"
+ Classes="fa-spin" />
-
+
-
-
-
-
-
-
-
+
+ Foreground="#f59e0b" />
diff --git a/src/design/App/UI/Shared/Helpers/RangeObservableCollection.cs b/src/design/App/UI/Shared/Helpers/RangeObservableCollection.cs
new file mode 100644
index 000000000..c8aa5449a
--- /dev/null
+++ b/src/design/App/UI/Shared/Helpers/RangeObservableCollection.cs
@@ -0,0 +1,42 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+
+namespace App.UI.Shared.Helpers;
+
+///
+/// ObservableCollection that supports batch operations with a single Reset notification,
+/// avoiding per-item layout passes in bound UI controls.
+///
+public class RangeObservableCollection : ObservableCollection
+{
+ private bool _suppressNotification;
+
+ ///
+ /// Replace all items with the given collection, firing a single Reset notification.
+ ///
+ public void ReplaceAll(IEnumerable items)
+ {
+ _suppressNotification = true;
+ try
+ {
+ Items.Clear();
+ foreach (var item in items)
+ Items.Add(item);
+ }
+ finally
+ {
+ _suppressNotification = false;
+ }
+
+ OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ OnPropertyChanged(new PropertyChangedEventArgs("Count"));
+ OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
+ }
+
+ protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
+ {
+ if (!_suppressNotification)
+ base.OnCollectionChanged(e);
+ }
+}
diff --git a/src/design/App/UI/Shared/Helpers/ShellService.cs b/src/design/App/UI/Shared/Helpers/ShellService.cs
new file mode 100644
index 000000000..cc2419f82
--- /dev/null
+++ b/src/design/App/UI/Shared/Helpers/ShellService.cs
@@ -0,0 +1,36 @@
+using App.UI.Shell;
+
+namespace App.UI.Shared.Helpers;
+
+///
+/// Static service providing decoupled access to shell-level operations (toast, modal, navigation).
+/// Eliminates the need for views to walk the visual tree via FindAncestorOfType<ShellView>().
+/// Registered once from ShellView constructor; safe to call before registration (no-ops).
+///
+public static class ShellService
+{
+ private static ShellViewModel? _vm;
+
+ ///
+ /// Register the shell ViewModel instance. Called once from ShellView constructor.
+ ///
+ public static void Register(ShellViewModel vm) => _vm = vm;
+
+ ///
+ /// Show a toast notification with auto-dismiss.
+ ///
+ public static void ShowToast(string message, int durationMs = 2000)
+ => _vm?.ShowToast(message, durationMs);
+
+ ///
+ /// Show a modal overlay above the entire app.
+ ///
+ public static void ShowModal(object content)
+ => _vm?.ShowModal(content);
+
+ ///
+ /// Close the current shell-level modal overlay.
+ ///
+ public static void HideModal()
+ => _vm?.HideModal();
+}
diff --git a/src/design/App/UI/Shared/LayoutMode.cs b/src/design/App/UI/Shared/LayoutMode.cs
new file mode 100644
index 000000000..6c618d3d5
--- /dev/null
+++ b/src/design/App/UI/Shared/LayoutMode.cs
@@ -0,0 +1,99 @@
+using Avalonia;
+using ReactiveUI;
+
+namespace App.UI.Shared;
+
+///
+/// Responsive layout mode — matches the Vue prototype's breakpoints.
+/// Vue: lg = 1024px (sidebar visible), xl = 1280px (header stats visible).
+///
+/// Since this is a desktop app (not a browser), we track the window width
+/// and derive the same breakpoints the Vue prototype uses with Tailwind CSS.
+///
+public enum LayoutMode
+{
+ /// Width < 768px. Single column, bottom tab bar, minimal chrome.
+ Mobile,
+
+ /// 768px <= Width < 1024px. Bottom tab bar still visible, two-column layouts may stack.
+ Tablet,
+
+ /// Width >= 1024px. Full sidebar, header, multi-column layouts.
+ Desktop,
+}
+
+///
+/// Reactive singleton that tracks the app window width and exposes
+/// the current plus convenience booleans.
+///
+/// Sections and controls observe these properties to adapt their layout.
+///
+/// Usage:
+/// - ShellView subscribes to MainWindow.ClientSizeChanged and calls UpdateWidth()
+/// - Any view can inject/access LayoutModeService.Instance to observe IsMobile, IsTablet, etc.
+/// - XAML bindings use the AdaptiveLayout attached property instead (see below)
+///
+/// Breakpoints (matching Vue prototype Tailwind defaults):
+/// - Mobile: width < 768
+/// - Tablet: 768 <= width < 1024
+/// - Desktop: width >= 1024
+///
+public partial class LayoutModeService : ReactiveObject
+{
+ public static LayoutModeService Instance { get; } = new();
+
+ // Breakpoint thresholds (px) — matches Vue/Tailwind
+ public const double MobileBreakpoint = 768;
+ public const double TabletBreakpoint = 1024;
+
+ [Reactive] private LayoutMode currentMode = LayoutMode.Desktop;
+ [Reactive] private double windowWidth;
+
+ /// True when the window is narrower than 1024px (mobile or tablet).
+ public bool IsCompact => CurrentMode != LayoutMode.Desktop;
+
+ /// True when the window is narrower than 768px.
+ public bool IsMobile => CurrentMode == LayoutMode.Mobile;
+
+ /// True when the window is 768-1023px.
+ public bool IsTablet => CurrentMode == LayoutMode.Tablet;
+
+ /// True when the window is 1024px or wider.
+ public bool IsDesktop => CurrentMode == LayoutMode.Desktop;
+
+ private LayoutModeService()
+ {
+ // Raise convenience booleans whenever mode changes
+ this.WhenAnyValue(x => x.CurrentMode)
+ .Subscribe(_ =>
+ {
+ this.RaisePropertyChanged(nameof(IsCompact));
+ this.RaisePropertyChanged(nameof(IsMobile));
+ this.RaisePropertyChanged(nameof(IsTablet));
+ this.RaisePropertyChanged(nameof(IsDesktop));
+ });
+ }
+
+ ///
+ /// Called by ShellView whenever the window size changes.
+ /// Recalculates the layout mode from the new width.
+ /// When the mode crosses a breakpoint, the change is deferred to the next
+ /// UI frame to avoid mutating Grid definitions while Avalonia's layout
+ /// pass is still active (which causes SIGABRT on macOS).
+ ///
+ public void UpdateWidth(double width)
+ {
+ WindowWidth = width;
+ var newMode = width switch
+ {
+ < MobileBreakpoint => LayoutMode.Mobile,
+ < TabletBreakpoint => LayoutMode.Tablet,
+ _ => LayoutMode.Desktop,
+ };
+
+ // Set mode synchronously — the SIGABRT crash is prevented by ShellView/HomeView
+ // using in-place width modification on their grids. Inner views that still use
+ // Clear()+Add() need the synchronous update to work correctly.
+ CurrentMode = newMode;
+ }
+}
diff --git a/src/design/App/UI/Shared/Services/ProjectValidator.cs b/src/design/App/UI/Shared/Services/ProjectValidator.cs
index 456cb5157..2c7de1e39 100644
--- a/src/design/App/UI/Shared/Services/ProjectValidator.cs
+++ b/src/design/App/UI/Shared/Services/ProjectValidator.cs
@@ -94,7 +94,7 @@ public ValidationResult ValidatePenaltyDays(int days)
///
public ValidationResult ValidateFundingEndDate(DateTime endDate)
{
- if (endDate.Date < DateTime.Now.Date)
+ if (endDate.Date < DateTime.UtcNow.Date)
return ValidationResult.Fail("Funding end date must be on or after today");
// Debug mode: allow same-day end date, no maximum period
@@ -102,10 +102,10 @@ public ValidationResult ValidateFundingEndDate(DateTime endDate)
return ValidationResult.Success();
// Production mode: must be in the future (not today) and within one year
- if (endDate.Date <= DateTime.Now.Date)
+ if (endDate.Date <= DateTime.UtcNow.Date)
return ValidationResult.Fail("Funding end date must be after today");
- var daysUntilEnd = (endDate - DateTime.Now).TotalDays;
+ var daysUntilEnd = (endDate - DateTime.UtcNow).TotalDays;
if (daysUntilEnd > 365)
return ValidationResult.Fail("Funding period cannot exceed one year");
diff --git a/src/design/App/UI/Shell/MainWindow.axaml b/src/design/App/UI/Shell/MainWindow.axaml
index 26cdce1d8..9e4d1f13b 100644
--- a/src/design/App/UI/Shell/MainWindow.axaml
+++ b/src/design/App/UI/Shell/MainWindow.axaml
@@ -6,7 +6,9 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="1400"
Height="800"
- WindowStartupLocation="CenterScreen"
+ MinWidth="360"
+ MinHeight="500"
+ WindowStartupLocation="CenterScreen"
x:Class="App.UI.Shell.MainWindow"
Title="Angor">
diff --git a/src/design/App/UI/Shell/MainWindow.axaml.cs b/src/design/App/UI/Shell/MainWindow.axaml.cs
index 4f9d4d0c9..fdc3d8596 100644
--- a/src/design/App/UI/Shell/MainWindow.axaml.cs
+++ b/src/design/App/UI/Shell/MainWindow.axaml.cs
@@ -1,5 +1,6 @@
using Avalonia.Platform;
using Avalonia.Styling;
+using App.UI.Shared;
namespace App.UI.Shell;
@@ -12,6 +13,13 @@ public MainWindow()
Application.Current!
.GetObservable(ThemeVariantScope.ActualThemeVariantProperty)
.Subscribe(_ => SetWindowIconForCurrentTheme());
+
+ // Wire LayoutModeService — track window width for responsive breakpoints
+ // Vue: lg = 1024px (sidebar visible), md = 768px (tablet)
+ this.GetObservable(ClientSizeProperty).Subscribe(size =>
+ {
+ LayoutModeService.Instance.UpdateWidth(size.Width);
+ });
}
private void SetWindowIconForCurrentTheme()
diff --git a/src/design/App/UI/Shell/ShellView.axaml b/src/design/App/UI/Shell/ShellView.axaml
index 934bc0f22..e6eed7331 100644
--- a/src/design/App/UI/Shell/ShellView.axaml
+++ b/src/design/App/UI/Shell/ShellView.axaml
@@ -7,20 +7,73 @@
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="650"
x:Class="App.UI.Shell.ShellView"
x:DataType="shell:ShellViewModel"
- Background="{DynamicResource AppBackground}">
+ Background="{DynamicResource AppBackground}"
+ TopLevel.AutoSafeAreaPadding="False">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
-
-
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -167,11 +515,6 @@
-
Heavy blur for modal backdrop — much more prominent than before.
- private static readonly BlurEffect ModalBlur = new() { Radius = 20 };
+ /// PERF: Reduced on mobile (radius 8 vs 20) to avoid GPU strain.
+ private static readonly BlurEffect ModalBlurDesktop = new() { Radius = 20 };
+ private static readonly BlurEffect ModalBlurMobile = new() { Radius = 8 };
/// Animation duration matching Vue prototype modal-fade: 250ms.
private static readonly TimeSpan AnimDuration = TimeSpan.FromMilliseconds(250);
@@ -95,19 +101,145 @@ public partial class ShellView : UserControl
/// Guard to prevent re-entrant close animation.
private bool _isClosing;
+ // ── Cached controls ──
+ private Grid _shellContent = null!;
+ private Control _desktopLogo = null!;
+ private DockPanel _desktopHeader = null!;
+ private Border _desktopSidebar = null!;
+ private Border _contentBorder = null!;
+ private Border _bottomTabBar = null!;
+ private Border _textureOverlay = null!;
+
+ // ── Named controls for mobile tab bar ──
+ private Button _tabHome = null!;
+ private Button _tabInvestor = null!;
+ private Button _tabFounder = null!;
+ private Button _tabFunds = null!;
+ private Button _tabSettings = null!;
+ // Pill indicators removed — Vue-style color-only active state
+ private Border _investorSubTabs = null!;
+ private Border _founderSubTabs = null!;
+ private Button _investorSubTabFind = null!;
+ private Button _investorSubTabFunded = null!;
+ private Button _founderSubTabMyProjects = null!;
+ private Button _founderSubTabFunders = null!;
+
+ // ── Named controls for mobile floating back bars ──
+ private Border _investorBackBar = null!;
+ private Border _investmentDetailBackBar = null!;
+ private Border _manageFundsBackBar = null!;
+ private TextBlock _investorCtaText = null!;
+
+ private IDisposable? _layoutSubscription;
+ private IDisposable? _detailStateSubscription;
+
+ ///
+ /// Cached safe-area insets. Populated from TopLevel.InsetsManager when the
+ /// view is attached. Bottom pads the tab bar so gesture handles don't overlap
+ /// icons; top pads the page content so it doesn't sit under the status bar.
+ /// Auto-padding is disabled (TopLevel.AutoSafeAreaPadding="False") because we
+ /// want the app's Background to paint edge-to-edge — then we add padding only
+ /// where real content sits.
+ ///
+ private double _safeAreaBottom;
+ private double _safeAreaTop;
+
public ShellView()
{
InitializeComponent();
var vm = App.Services.GetRequiredService();
DataContext = vm;
+ ShellService.Register(vm);
+
+ // ── Resolve layout controls ──
+ _shellContent = this.FindControl("ShellContent")!;
+ _desktopLogo = this.FindControl("DesktopLogo")!;
+ _desktopHeader = this.FindControl("DesktopHeader")!;
+ _desktopSidebar = this.FindControl("DesktopSidebar")!;
+ _contentBorder = this.FindControl("ContentBorder")!;
+ _bottomTabBar = this.FindControl("BottomTabBar")!;
+ _textureOverlay = this.FindControl("TextureOverlay")!;
var modalOverlay = this.FindControl("ModalOverlay")!;
- var shellContent = this.FindControl("ShellContent")!;
var backdrop = this.FindControl("ShellModalBackdrop")!;
+ // ── Resolve mobile tab bar controls ──
+ _tabHome = this.FindControl("TabHome")!;
+ _tabInvestor = this.FindControl("TabInvestor")!;
+ _tabFounder = this.FindControl("TabFounder")!;
+ _tabFunds = this.FindControl("TabFunds")!;
+ _tabSettings = this.FindControl("TabSettings")!;
+ // Pill indicators removed — Vue-style color-only active state
+ _investorSubTabs = this.FindControl("InvestorSubTabs")!;
+ _founderSubTabs = this.FindControl("FounderSubTabs")!;
+ _investorSubTabFind = this.FindControl("InvestorSubTabFind")!;
+ _investorSubTabFunded = this.FindControl("InvestorSubTabFunded")!;
+ _founderSubTabMyProjects = this.FindControl("FounderSubTabMyProjects")!;
+ _founderSubTabFunders = this.FindControl("FounderSubTabFunders")!;
+
+ // ── Resolve mobile floating back bar controls ──
+ _investorBackBar = this.FindControl("InvestorBackBar")!;
+ _investmentDetailBackBar = this.FindControl("InvestmentDetailBackBar")!;
+ _manageFundsBackBar = this.FindControl("ManageFundsBackBar")!;
+ _investorCtaText = this.FindControl("InvestorCtaText")!;
+
+ // ── Subscribe to layout mode changes — toggle desktop/compact elements ──
+ ApplyShellLayout(!LayoutModeService.Instance.IsCompact);
+ _layoutSubscription = LayoutModeService.Instance
+ .WhenAnyValue(x => x.IsCompact)
+ .Subscribe(isCompact => ApplyShellLayout(!isCompact));
+
// Apply backdrop transitions once
backdrop.Transitions = BackdropTransitions;
+ // ── React to MobileActiveTab changes — update tab bar active states ──
+ // Rule #9: CSS class toggling only, no BrushTransition, no code-behind color logic.
+ vm.WhenAnyValue(x => x.MobileActiveTab)
+ .Subscribe(tab =>
+ {
+ // Toggle TabBarItemActive class on each tab button
+ _tabHome.Classes.Set("TabBarItemActive", tab == "home");
+ _tabInvestor.Classes.Set("TabBarItemActive", tab == "investor");
+ _tabFounder.Classes.Set("TabBarItemActive", tab == "founder");
+ _tabFunds.Classes.Set("TabBarItemActive", tab == "funds");
+ _tabSettings.Classes.Set("TabBarItemActive", tab == "settings");
+
+ // Toggle MD3 pill indicator active class
+ // Pill indicators removed — active state is color-only via TabBarItemActive class
+
+ // Sub-tab and back-bar visibility is handled by the detail state subscription below.
+ // Trigger a re-evaluation by reading current detail state.
+ UpdateCompactOverlays(vm);
+ });
+
+ // ── React to detail view state changes — toggle sub-tabs vs back bars ──
+ // Vue: sub-tabs hidden when detail views are open; back bars shown instead.
+ _detailStateSubscription = vm.WhenAnyValue(
+ x => x.MobileActiveTab,
+ x => x.IsProjectDetailOpen,
+ x => x.IsInvestPageOpen,
+ x => x.IsInvestmentDetailOpen,
+ x => x.IsManageFundsOpen,
+ x => x.IsCreatingProject,
+ x => x.ProjectDetailActionVerb)
+ .Subscribe(_ => UpdateCompactOverlays(vm));
+
+ // ── React to MobileInvestorSubTab changes — update sub-tab active states ──
+ vm.WhenAnyValue(x => x.MobileInvestorSubTab)
+ .Subscribe(subTab =>
+ {
+ _investorSubTabFind.Classes.Set("SubTabActive", subTab == "find-projects");
+ _investorSubTabFunded.Classes.Set("SubTabActive", subTab == "investments");
+ });
+
+ // ── React to MobileFounderSubTab changes — update sub-tab active states ──
+ vm.WhenAnyValue(x => x.MobileFounderSubTab)
+ .Subscribe(subTab =>
+ {
+ _founderSubTabMyProjects.Classes.Set("SubTabActive", subTab == "my-projects");
+ _founderSubTabFunders.Classes.Set("SubTabActive", subTab == "funders");
+ });
+
// React to ModalContent changes — manage the visual tree directly.
// This replaces the XAML ContentPresenter binding which suffered from
// an intermittent race: IsVisible and Content changing in the same
@@ -150,7 +282,10 @@ public ShellView()
// Make the overlay visible
modalOverlay.IsVisible = true;
- shellContent.Effect = ModalBlur;
+
+ // Apply blur to the shell grid (reduced on mobile for perf)
+ _shellContent.Effect = LayoutModeService.Instance.IsCompact
+ ? ModalBlurMobile : ModalBlurDesktop;
// Force layout so the initial state is rendered
modalOverlay.InvalidateMeasure();
@@ -179,13 +314,13 @@ public ShellView()
backdrop.Opacity = 0;
// Wait for transition to finish, then clean up
- _ = CleanupAfterClose(closingChild, modalOverlay, shellContent);
+ _ = CleanupAfterClose(closingChild, modalOverlay);
}
else if (_currentModalChild == null)
{
// Nothing to animate, just hide
modalOverlay.IsVisible = false;
- shellContent.Effect = null;
+ _shellContent.Effect = null;
}
}
});
@@ -224,12 +359,339 @@ public ShellView()
});
}
+ // ═══════════════════════════════════════════════════════════════
+ // ADAPTIVE LAYOUT
+ // Switches between desktop (sidebar+header) and compact (tab bar)
+ // by adjusting the single Grid's structure and element visibility.
+ // ═══════════════════════════════════════════════════════════════
+
+ ///
+ /// Apply the shell layout for desktop or compact mode.
+ /// Desktop: sidebar column 208px, header row 42px, tab bar hidden.
+ /// Compact: sidebar column 0px, header row 0px, tab bar visible.
+ ///
+ private void ApplyShellLayout(bool isDesktop)
+ {
+ if (_shellContent == null) return;
+
+ // CRITICAL: Never replace ColumnDefinitions/RowDefinitions collections.
+ // Only modify existing column/row widths. Replacing collections causes
+ // Avalonia's layout engine to crash (SIGABRT) because child controls
+ // briefly reference invalid column/row indices during the swap.
+ //
+ // The XAML Grid always has 2 columns and 3 rows:
+ // Desktop: Col0=208 (sidebar), Col1=* (content) | Row0=42 (header), Row1=* (content), Row2=0 (no tab bar)
+ // Compact: Col0=* (content), Col1=0 (hidden) | Row0=0 (no header), Row1=* (content), Row2=Auto (tab bar)
+ var cols = _shellContent.ColumnDefinitions;
+ var rows = _shellContent.RowDefinitions;
+
+ if (isDesktop)
+ {
+ _shellContent.Margin = new Avalonia.Thickness(0, 24, 24, 24);
+ _shellContent.ColumnSpacing = 0;
+ _shellContent.RowSpacing = 20;
+
+ // Sidebar 208px, content fills rest
+ if (cols.Count >= 2) { cols[0].Width = new GridLength(208); cols[1].Width = GridLength.Star; }
+ if (rows.Count >= 3) { rows[0].Height = new GridLength(42); rows[2].Height = new GridLength(0); }
+
+ Grid.SetColumn(_contentBorder, 1);
+ _contentBorder.Classes.Set("Panel", true);
+
+ _desktopLogo.IsVisible = true;
+ _desktopHeader.IsVisible = true;
+ _desktopSidebar.IsVisible = true;
+
+ _bottomTabBar.IsVisible = false;
+ _textureOverlay.IsVisible = true;
+ _investorSubTabs.IsVisible = false;
+ _founderSubTabs.IsVisible = false;
+ _investorBackBar.IsVisible = false;
+ _investmentDetailBackBar.IsVisible = false;
+ _manageFundsBackBar.IsVisible = false;
+ }
+ else
+ {
+ // Pad the top by the status-bar inset so page content isn't hidden
+ // under it. Bottom inset is handled on the tab bar Border itself.
+ _shellContent.Margin = new Avalonia.Thickness(0, _safeAreaTop, 0, 0);
+ _shellContent.ColumnSpacing = 0;
+ _shellContent.RowSpacing = 0;
+
+ // Content fills full width, sidebar collapses to 0
+ if (cols.Count >= 2) { cols[0].Width = GridLength.Star; cols[1].Width = new GridLength(0); }
+ if (rows.Count >= 3) { rows[0].Height = new GridLength(0); rows[2].Height = GridLength.Auto; }
+
+ Grid.SetColumn(_contentBorder, 0);
+ _contentBorder.Classes.Set("Panel", false);
+
+ _desktopLogo.IsVisible = false;
+ _desktopHeader.IsVisible = false;
+ _desktopSidebar.IsVisible = false;
+
+ _bottomTabBar.IsVisible = true;
+ _bottomTabBar.Padding = new Avalonia.Thickness(0, 0, 0, _safeAreaBottom);
+ _textureOverlay.IsVisible = false;
+
+ if (DataContext is ShellViewModel vm)
+ {
+ UpdateCompactOverlays(vm);
+ }
+ }
+
+ // Column spans for overlay elements
+ var subTabColSpan = isDesktop ? 2 : 1;
+ Grid.SetColumnSpan(_investorSubTabs, subTabColSpan);
+ Grid.SetColumnSpan(_founderSubTabs, subTabColSpan);
+ Grid.SetColumnSpan(_investorBackBar, subTabColSpan);
+ Grid.SetColumnSpan(_investmentDetailBackBar, subTabColSpan);
+ Grid.SetColumnSpan(_manageFundsBackBar, subTabColSpan);
+ Grid.SetColumnSpan(_bottomTabBar, isDesktop ? 2 : 1);
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ // Query the real platform safe-area so we only pad the tab bar by what the OS
+ // actually reserves (gesture handle / nav bar). Hardcoded 24dp showed as a dead
+ // strip on devices/emulators where the system bar does not overlap the app.
+ var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
+ var insets = topLevel?.InsetsManager;
+ if (insets != null)
+ {
+ // True edge-to-edge: paint the app's own Background behind the (now
+ // transparent) nav/status bars. Without this, Avalonia's canvas stops
+ // at the system bars and the OS shows its default opaque strip below
+ // our tab bar (the "black rectangle" bug).
+ insets.DisplayEdgeToEdgePreference = true;
+ insets.SystemBarColor = Colors.Transparent;
+
+ ApplySafeAreaInsets(insets.SafeAreaPadding);
+ insets.SafeAreaChanged += OnSafeAreaChanged;
+ }
+ }
+
+ private void OnSafeAreaChanged(object? sender, Avalonia.Controls.Platform.SafeAreaChangedArgs e)
+ {
+ ApplySafeAreaInsets(e.SafeAreaPadding);
+ }
+
+ private void ApplySafeAreaInsets(Avalonia.Thickness padding)
+ {
+ _safeAreaBottom = padding.Bottom;
+ _safeAreaTop = padding.Top;
+ if (_bottomTabBar != null && _bottomTabBar.IsVisible)
+ _bottomTabBar.Padding = new Avalonia.Thickness(0, 0, 0, _safeAreaBottom);
+ if (_shellContent != null && LayoutModeService.Instance.IsCompact)
+ _shellContent.Margin = new Avalonia.Thickness(0, _safeAreaTop, 0, 0);
+ }
+
+ protected override void OnDetachedFromLogicalTree(Avalonia.LogicalTree.LogicalTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromLogicalTree(e);
+ _layoutSubscription?.Dispose();
+ _layoutSubscription = null;
+ _detailStateSubscription?.Dispose();
+ _detailStateSubscription = null;
+
+ var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
+ var insets = topLevel?.InsetsManager;
+ if (insets != null)
+ insets.SafeAreaChanged -= OnSafeAreaChanged;
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // MOBILE TAB BAR CLICK HANDLERS
+ // Vue: @click="handleMobileTabChange('home')" etc. in App.vue
+ // ═══════════════════════════════════════════════════════════════
+
+ private void OnTabHome(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleMobileTabChange("home");
+ }
+
+ private void OnTabInvestor(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleMobileTabChange("investor");
+ }
+
+ private void OnTabFounder(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleMobileTabChange("founder");
+ }
+
+ private void OnTabFunds(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleMobileTabChange("funds");
+ }
+
+ private void OnTabSettings(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleMobileTabChange("settings");
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // FLOATING SUB-TAB CLICK HANDLERS
+ // Vue: @click="handleInvestorSubTabChange('find-projects')" etc.
+ // ═══════════════════════════════════════════════════════════════
+
+ private void OnInvestorSubTabFind(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleInvestorSubTabChange("find-projects");
+ }
+
+ private void OnInvestorSubTabFunded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleInvestorSubTabChange("investments");
+ }
+
+ private void OnFounderSubTabMyProjects(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleFounderSubTabChange("my-projects");
+ }
+
+ private void OnFounderSubTabFunders(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.HandleFounderSubTabChange("funders");
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // FLOATING BACK BAR CLICK HANDLERS
+ // Vue: Back buttons + CTAs on the mobile floating bar
+ // ═══════════════════════════════════════════════════════════════
+
+ ///
+ /// Back from project detail or invest page.
+ /// Vue (line 6203): back button in investor back bar.
+ ///
+ private void OnInvestorBackClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.BackFromInvestorDetail();
+ }
+
+ ///
+ /// Invest/Submit CTA on the investor back bar.
+ /// Vue: showInvestPage ? submit : navigate to invest page.
+ ///
+ private void OnInvestorCtaClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.MobileInvestAction();
+ }
+
+ ///
+ /// Share button on the investor back bar.
+ /// Opens share modal for the currently selected project.
+ ///
+ private void OnInvestorShareClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.ShareCurrentInvestorProject();
+ }
+
+ ///
+ /// "Back to Investments" button on the investment detail back bar.
+ /// Vue (line 6234): full-width green back button.
+ ///
+ private void OnInvestmentDetailBackClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.BackFromInvestmentDetail();
+ }
+
+ ///
+ /// "Back to My Projects" button on the manage funds back bar.
+ /// Vue (line 6247): full-width green back button.
+ ///
+ private void OnManageFundsBackClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ vm.CloseManageFundsFromShell();
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // COMPACT OVERLAY VISIBILITY
+ // Manages sub-tab panels and floating back bars based on
+ // active tab + detail view state, only in compact mode.
+ // ═══════════════════════════════════════════════════════════════
+
+ ///
+ /// Re-evaluate visibility of sub-tab panels and floating back bars.
+ /// Called whenever MobileActiveTab or any detail state flag changes.
+ /// Vue conditions (from App.vue):
+ /// Investor sub-tabs: mobileActiveTab === 'investor' && !isCreatingProject && !showProjectDetail && !showInvestPage && !showInvestmentDetail
+ /// Founder sub-tabs: mobileActiveTab === 'founder' && !isCreatingProject && !showManageFunds
+ /// Investor back bar: (showProjectDetail || showInvestPage) && mobileActiveTab === 'investor'
+ /// Investment detail back bar: showInvestmentDetail && mobileActiveTab === 'investor' (currentPage === 'investments')
+ /// Manage funds back bar: showManageFunds && !isCreatingProject && mobileActiveTab === 'founder'
+ ///
+ private void UpdateCompactOverlays(ShellViewModel vm)
+ {
+ var isCompact = LayoutModeService.Instance.IsCompact;
+ var tab = vm.MobileActiveTab;
+
+ // ── Investor sub-tabs ──
+ // Vue (line 6163): v-if="mobileActiveTab === 'investor' && !isCreatingProject && !showProjectDetail && !showInvestPage && !showInvestmentDetail"
+ _investorSubTabs.IsVisible = isCompact
+ && tab == "investor"
+ && !vm.IsCreatingProject
+ && !vm.IsProjectDetailOpen
+ && !vm.IsInvestPageOpen
+ && !vm.IsInvestmentDetailOpen;
+
+ // ── Founder sub-tabs ──
+ // Vue (line 6500): v-if="mobileActiveTab === 'founder' && !isCreatingProject && !showManageFunds"
+ _founderSubTabs.IsVisible = isCompact
+ && tab == "founder"
+ && !vm.IsCreatingProject
+ && !vm.IsManageFundsOpen;
+
+ // ── Investor back bar (Back + Invest CTA + Share) ──
+ // Vue (line 6203): v-if="(showProjectDetail || showInvestPage) && mobileActiveTab === 'investor'"
+ _investorBackBar.IsVisible = isCompact
+ && tab == "investor"
+ && (vm.IsProjectDetailOpen || vm.IsInvestPageOpen);
+
+ // Update CTA text: use the project-type action verb on both detail and invest pages
+ if (_investorCtaText != null)
+ _investorCtaText.Text = vm.ProjectDetailActionVerb;
+
+ // ── Investment detail back bar ──
+ // Vue (line 6234): v-if="showInvestmentDetail && currentPage === 'investments'"
+ // currentPage === 'investments' maps to mobileActiveTab === 'investor' && mobileInvestorSubTab === 'investments'
+ _investmentDetailBackBar.IsVisible = isCompact
+ && vm.IsInvestmentDetailOpen
+ && tab == "investor";
+
+ // ── Manage funds back bar ──
+ // Vue (line 6247): v-if="showManageFunds && selectedManageFundsProject && currentPage === 'my-projects' && !isCreatingProject"
+ _manageFundsBackBar.IsVisible = isCompact
+ && vm.IsManageFundsOpen
+ && !vm.IsCreatingProject
+ && tab == "founder";
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // MODAL LIFECYCLE
+ // ═══════════════════════════════════════════════════════════════
+
///
/// Wait for the close transition to finish, then remove the modal from the tree.
/// Only hides the overlay/blur if no new modal was opened in the meantime
/// (i.e., multi-step modal flows where ShowModal is called right after HideModal).
///
- private async Task CleanupAfterClose(Control closingChild, Panel modalOverlay, Grid shellContent)
+ private async Task CleanupAfterClose(Control closingChild, Panel modalOverlay)
{
// Wait for the transition duration + small buffer
await Task.Delay(AnimDuration + TimeSpan.FromMilliseconds(50));
@@ -244,7 +706,7 @@ await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
_currentModalChild = null;
modalOverlay.IsVisible = false;
- shellContent.Effect = null;
+ _shellContent.Effect = null;
}
_isClosing = false;
});
diff --git a/src/design/App/UI/Shell/ShellViewModel.cs b/src/design/App/UI/Shell/ShellViewModel.cs
index b5a9b930c..2a4a6faa2 100644
--- a/src/design/App/UI/Shell/ShellViewModel.cs
+++ b/src/design/App/UI/Shell/ShellViewModel.cs
@@ -9,6 +9,7 @@
using App.UI.Sections.MyProjects;
using App.UI.Sections.Portfolio;
using App.UI.Shared;
+using App.UI.Shared.Helpers;
using App.UI.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -97,7 +98,7 @@ public SharedSignature AddSignature(string projectId, string projectTitle, strin
requiresApproval = threshold > 0 && amountSats >= threshold;
}
- var now = DateTime.Now;
+ var now = DateTime.UtcNow;
var sig = new SharedSignature
{
@@ -281,6 +282,28 @@ public partial class ShellViewModel : ReactiveObject
[Reactive] private bool isSettingsOpen;
[Reactive] private string? sectionTitleOverride;
+ // ── Mobile tab bar state ──
+ // Vue: mobileActiveTab, mobileInvestorSubTab, mobileFounderSubTab in App.vue
+ [Reactive] private string mobileActiveTab = "home";
+ [Reactive] private string mobileInvestorSubTab = "find-projects";
+ [Reactive] private string mobileFounderSubTab = "my-projects";
+
+ // ── Detail view state tracking (for mobile sub-tab/back-button visibility) ──
+ // Vue: showProjectDetail, showInvestPage, showInvestmentDetail, showManageFunds, isCreatingProject
+ // Sections set these when entering/exiting detail views so ShellView can react.
+ [Reactive] private bool isProjectDetailOpen;
+ [Reactive] private bool isInvestPageOpen;
+ /// CTA verb for the mobile floating bar — "Invest", "Fund", or "Subscribe".
+ [Reactive] private string projectDetailActionVerb = "Invest";
+ [Reactive] private bool isInvestmentDetailOpen;
+ [Reactive] private bool isManageFundsOpen;
+ [Reactive] private bool isCreatingProject;
+
+ ///
+ /// Reference to the LayoutModeService singleton for XAML bindings.
+ ///
+ public LayoutModeService Layout => LayoutModeService.Instance;
+
///
/// When true, the next MyProjectsView instance will auto-open the create wizard.
/// Consumed (reset to false) by MyProjectsView on init.
@@ -378,6 +401,7 @@ public ShellViewModel(PortfolioViewModel portfolioVm, SignatureStore signatureSt
{
IsSettingsOpen = false;
SectionTitleOverride = null;
+ SyncMobileTabState();
this.RaisePropertyChanged(nameof(CurrentSectionContent));
this.RaisePropertyChanged(nameof(SelectedSectionName));
});
@@ -386,6 +410,7 @@ public ShellViewModel(PortfolioViewModel portfolioVm, SignatureStore signatureSt
.Where(open => open)
.Subscribe(_ =>
{
+ SyncMobileTabState();
this.RaisePropertyChanged(nameof(CurrentSectionContent));
this.RaisePropertyChanged(nameof(SelectedSectionName));
});
@@ -526,6 +551,229 @@ private async Task LoadTotalInvestedAsync(WalletInfo? wallet)
}
}
+ // ── Mobile tab bar navigation ──
+ // Vue: handleMobileTabChange, handleInvestorSubTabChange, handleFounderSubTabChange
+
+ ///
+ /// Handle a mobile bottom tab bar tap.
+ /// Vue: handleMobileTabChange() in App.vue (line 9075).
+ /// Maps the 5 mobile tabs to the sidebar nav items.
+ ///
+ public void HandleMobileTabChange(string tab)
+ {
+ // Vue special case (line 9077): clicking founder tab while ManageFunds is open
+ // calls backFromManageFunds() and returns early.
+ if (tab == "founder" && IsManageFundsOpen)
+ {
+ CloseManageFundsFromShell();
+ return;
+ }
+
+ MobileActiveTab = tab;
+
+ switch (tab)
+ {
+ case "home":
+ SelectNavByLabel("Home");
+ break;
+ case "funds":
+ SelectNavByLabel("Funds");
+ break;
+ case "investor":
+ // Remember last sub-tab (Vue: changePage(mobileInvestorSubTab.value))
+ SelectNavByLabel(MobileInvestorSubTab == "investments" ? "Funded" : "Find Projects");
+ break;
+ case "founder":
+ SelectNavByLabel(MobileFounderSubTab == "funders" ? "Funders" : "My Projects");
+ break;
+ case "settings":
+ NavigateToSettings();
+ break;
+ }
+ }
+
+ ///
+ /// Handle investor floating sub-tab change.
+ /// Vue: handleInvestorSubTabChange() in App.vue.
+ ///
+ public void HandleInvestorSubTabChange(string subTab)
+ {
+ MobileInvestorSubTab = subTab;
+ MobileActiveTab = "investor";
+ SelectNavByLabel(subTab == "investments" ? "Funded" : "Find Projects");
+ }
+
+ ///
+ /// Handle founder floating sub-tab change.
+ /// Vue: handleFounderSubTabChange() in App.vue.
+ ///
+ public void HandleFounderSubTabChange(string subTab)
+ {
+ MobileFounderSubTab = subTab;
+ MobileActiveTab = "founder";
+ SelectNavByLabel(subTab == "funders" ? "Funders" : "My Projects");
+ }
+
+ ///
+ /// Sync the mobile tab state when desktop sidebar navigation changes.
+ /// Vue: changePage() sync logic in App.vue (line 8546).
+ ///
+ private void SyncMobileTabState()
+ {
+ if (IsSettingsOpen)
+ {
+ MobileActiveTab = "settings";
+ return;
+ }
+
+ switch (SelectedNavItem?.Label)
+ {
+ case "Home":
+ MobileActiveTab = "home";
+ break;
+ case "Funds":
+ MobileActiveTab = "funds";
+ break;
+ case "Find Projects":
+ MobileActiveTab = "investor";
+ MobileInvestorSubTab = "find-projects";
+ break;
+ case "Funded":
+ MobileActiveTab = "investor";
+ MobileInvestorSubTab = "investments";
+ break;
+ case "My Projects":
+ MobileActiveTab = "founder";
+ MobileFounderSubTab = "my-projects";
+ break;
+ case "Funders":
+ MobileActiveTab = "founder";
+ MobileFounderSubTab = "funders";
+ break;
+ }
+ }
+
+ /// Select a sidebar nav item by label.
+ private void SelectNavByLabel(string label)
+ {
+ var item = NavEntries.OfType().FirstOrDefault(n => n.Label == label);
+ if (item != null)
+ SelectedNavItem = item;
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // MOBILE BACK BUTTON ACTIONS
+ // Vue: backToProjects(), backToProjectDetail(), backToInvestments(),
+ // backFromManageFunds() in App.vue
+ // ═══════════════════════════════════════════════════════════════
+
+ ///
+ /// Back from ProjectDetail/InvestPage in the investor flow.
+ /// Vue: showInvestPage ? backToProjectDetail() : backToProjects()
+ ///
+ public void BackFromInvestorDetail()
+ {
+ if (IsInvestPageOpen)
+ {
+ // Back from invest page → project detail
+ if (_viewCache.TryGetValue("Find Projects", out var v) &&
+ v is FindProjectsView { DataContext: FindProjectsViewModel fpVm })
+ {
+ fpVm.CloseInvestPage();
+ }
+ }
+ else if (IsProjectDetailOpen)
+ {
+ // Back from project detail → project list
+ if (_viewCache.TryGetValue("Find Projects", out var v) &&
+ v is FindProjectsView { DataContext: FindProjectsViewModel fpVm })
+ {
+ fpVm.CloseProjectDetail();
+ }
+ }
+ }
+
+ ///
+ /// Back from InvestmentDetail → portfolio list.
+ /// Vue: backToInvestments()
+ ///
+ public void BackFromInvestmentDetail()
+ {
+ _portfolioVm.CloseInvestmentDetail();
+ }
+
+ ///
+ /// Close ManageFunds from the shell (used by founder tab special case and back button).
+ /// Vue: backFromManageFunds()
+ ///
+ public void CloseManageFundsFromShell()
+ {
+ if (_viewCache.TryGetValue("My Projects", out var v) &&
+ v is MyProjectsView { DataContext: MyProjectsViewModel mpVm })
+ {
+ mpVm.CloseManageProject();
+ }
+ // Ensure the founder tab is set and sub-tabs re-appear
+ MobileActiveTab = "founder";
+ MobileFounderSubTab = "my-projects";
+ }
+
+ ///
+ /// Share the currently selected investor project via the shell modal.
+ ///
+ public void ShareCurrentInvestorProject()
+ {
+ if (IsModalOpen) return;
+ if (!_viewCache.TryGetValue("Find Projects", out var v) ||
+ v is not FindProjectsView { DataContext: FindProjectsViewModel fpVm } ||
+ fpVm.SelectedProject is not { } project)
+ return;
+
+ ShowModal(new Shared.Controls.ShareModal(project.ProjectName, project.ShortDescription));
+ }
+
+ ///
+ /// Action for the invest/submit CTA button on the mobile back bar.
+ /// Vue: showInvestPage ? handleInvestPageAction() : viewInvestPage()
+ ///
+ public void MobileInvestAction()
+ {
+ if (IsInvestPageOpen)
+ {
+ // Trigger invest submit via the FindProjectsViewModel's InvestPageViewModel
+ if (_viewCache.TryGetValue("Find Projects", out var iv) &&
+ iv is FindProjectsView { DataContext: FindProjectsViewModel fpVm2 } &&
+ fpVm2.InvestPageViewModel is { } investVm)
+ {
+ if (investVm.CanSubmit)
+ investVm.Submit();
+ }
+ }
+ else if (IsProjectDetailOpen)
+ {
+ // Open invest page from project detail
+ if (_viewCache.TryGetValue("Find Projects", out var v) &&
+ v is FindProjectsView { DataContext: FindProjectsViewModel fpVm })
+ {
+ fpVm.OpenInvestPage();
+ }
+ }
+ }
+
+ ///
+ /// Reset all detail view state flags. Called on breakpoint crossing
+ /// or when navigating to a different section to ensure clean state.
+ /// Vue: changePage() resets showProjectDetail, showInvestPage, selectedProject.
+ ///
+ public void ResetDetailViewState()
+ {
+ IsProjectDetailOpen = false;
+ IsInvestPageOpen = false;
+ IsInvestmentDetailOpen = false;
+ IsManageFundsOpen = false;
+ IsCreatingProject = false;
+ }
+
public void ResetAfterDataWipe()
{
_signatureStore.Clear();
diff --git a/src/design/App/UI/Themes/V2/Resources/Colors.Core.axaml b/src/design/App/UI/Themes/V2/Resources/Colors.Core.axaml
index 6768e8af8..d0b17ea69 100644
--- a/src/design/App/UI/Themes/V2/Resources/Colors.Core.axaml
+++ b/src/design/App/UI/Themes/V2/Resources/Colors.Core.axaml
@@ -76,6 +76,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -222,6 +246,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -345,6 +394,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/design/App/UI/Themes/V2/Styles/Utilities.axaml b/src/design/App/UI/Themes/V2/Styles/Utilities.axaml
index 6ba44d525..519aeb063 100644
--- a/src/design/App/UI/Themes/V2/Styles/Utilities.axaml
+++ b/src/design/App/UI/Themes/V2/Styles/Utilities.axaml
@@ -108,4 +108,26 @@
+
+
+
+
+
diff --git a/src/design/App/UI/Themes/V2/Theme.axaml b/src/design/App/UI/Themes/V2/Theme.axaml
index 16d94fb11..9557497f3 100644
--- a/src/design/App/UI/Themes/V2/Theme.axaml
+++ b/src/design/App/UI/Themes/V2/Theme.axaml
@@ -49,6 +49,7 @@
+