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 @@ -209,8 +213,7 @@ - + - @@ -316,8 +326,7 @@ - + - - - - - - - - - - - - + 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 @@ - - + + + - - + + - - + + - - - - - + - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -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 @@ + + + - - - + @@ -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 - + - + 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"> + + + + + + + + + + + + + + + + - + + + + - - - + + + + + + + - - + + @@ -100,8 +153,9 @@ - - + + + - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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