From d45d3faa89f0db8ac154aab14e548f0554f3289a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Mon, 2 Mar 2026 00:44:29 +0100 Subject: [PATCH 1/3] Whats new implementation. --- .../CalendarAppShellViewModel.cs | 23 ++++- Wino.Core.Domain/BasicTypesJsonContext.cs | 3 + Wino.Core.Domain/Constants.cs | 1 + Wino.Core.Domain/Interfaces/IAppMigration.cs | 15 ++++ .../Interfaces/IDialogServiceBase.cs | 7 ++ .../Interfaces/INotificationBuilder.cs | 6 ++ Wino.Core.Domain/Interfaces/IUpdateManager.cs | 26 ++++++ .../Models/Updates/UpdateNoteSection.cs | 27 ++++++ .../Models/Updates/UpdateNotes.cs | 13 +++ .../Translations/en_US/resources.json | 5 +- Wino.Mail.ViewModels/MailAppShellViewModel.cs | 19 +++- Wino.Mail.WinUI/App.xaml.cs | 55 +++++++++++- Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json | 40 +++++++++ Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml | 70 +++++++++++++++ .../Dialogs/WhatIsNewDialog.xaml.cs | 72 +++++++++++++++ Wino.Mail.WinUI/Services/DialogService.cs | 4 +- Wino.Mail.WinUI/Services/DialogServiceBase.cs | 15 +++- .../Services/NotificationBuilder.cs | 14 +++ Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 1 + Wino.Services/ServicesContainerSetup.cs | 1 + Wino.Services/UpdateManager.cs | 88 +++++++++++++++++++ 21 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 Wino.Core.Domain/Interfaces/IAppMigration.cs create mode 100644 Wino.Core.Domain/Interfaces/IUpdateManager.cs create mode 100644 Wino.Core.Domain/Models/Updates/UpdateNoteSection.cs create mode 100644 Wino.Core.Domain/Models/Updates/UpdateNotes.cs create mode 100644 Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json create mode 100644 Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml create mode 100644 Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml.cs create mode 100644 Wino.Services/UpdateManager.cs diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index 7ca56bba..42eaaaa1 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -72,10 +72,14 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, IAccountService accountService, ICalendarService calendarService, IAccountCalendarStateService accountCalendarStateService, - INavigationService navigationService) + INavigationService navigationService, + IDialogServiceBase dialogService, + IUpdateManager updateManager) { _accountService = accountService; _calendarService = calendarService; + _dialogService = dialogService; + _updateManager = updateManager; AccountCalendarStateService = accountCalendarStateService; AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested; @@ -123,9 +127,24 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, await InitializeAccountCalendarsAsync(); + await ShowWhatIsNewIfNeededAsync(); + TodayClicked(); } + private async Task ShowWhatIsNewIfNeededAsync() + { + if (!_updateManager.ShouldShowUpdateNotes()) + return; + + var notes = await _updateManager.GetLatestUpdateNotesAsync(); + + if (notes.Sections.Count == 0) + return; + + await _dialogService.ShowWhatIsNewDialogAsync(notes); + } + private async void AccountCalendarStateCollectivelyChanged(object sender, GroupedAccountCalendarViewModel e) { // When using three-state checkbox, multiple accounts will be selected/unselected at the same time. @@ -271,6 +290,8 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, private DateTime? _navigationDate; private readonly IAccountService _accountService; private readonly ICalendarService _calendarService; + private readonly IDialogServiceBase _dialogService; + private readonly IUpdateManager _updateManager; #region Commands diff --git a/Wino.Core.Domain/BasicTypesJsonContext.cs b/Wino.Core.Domain/BasicTypesJsonContext.cs index b9dba02d..f6085c1c 100644 --- a/Wino.Core.Domain/BasicTypesJsonContext.cs +++ b/Wino.Core.Domain/BasicTypesJsonContext.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Wino.Core.Domain.Models.Updates; namespace Wino.Core.Domain; @@ -8,4 +9,6 @@ namespace Wino.Core.Domain; [JsonSerializable(typeof(int))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(UpdateNotes))] +[JsonSerializable(typeof(List))] public partial class BasicTypesJsonContext : JsonSerializerContext; diff --git a/Wino.Core.Domain/Constants.cs b/Wino.Core.Domain/Constants.cs index 6c57207b..9a9bb2f6 100644 --- a/Wino.Core.Domain/Constants.cs +++ b/Wino.Core.Domain/Constants.cs @@ -19,6 +19,7 @@ public static class Constants public const string ToastModeKey = nameof(ToastModeKey); public const string ToastModeMail = nameof(ToastModeMail); public const string ToastModeCalendar = nameof(ToastModeCalendar); + public const string ToastMigrationRequiredKey = nameof(ToastMigrationRequiredKey); public const string ClientLogFile = "Client_.log"; public const string ServerLogFile = "Server_.log"; diff --git a/Wino.Core.Domain/Interfaces/IAppMigration.cs b/Wino.Core.Domain/Interfaces/IAppMigration.cs new file mode 100644 index 00000000..cb2f31c2 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IAppMigration.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Interfaces; + +/// +/// Represents a one-time app or data migration that runs when a user updates to a new version. +/// +public interface IAppMigration +{ + /// Gets the unique identifier for this migration, used to track completion in local settings. + string MigrationId { get; } + + /// Executes the migration logic. + Task ExecuteAsync(); +} diff --git a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs index 10189f83..11a9f6f3 100644 --- a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs +++ b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs @@ -5,6 +5,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Common; using Wino.Core.Domain.Models.Printing; +using Wino.Core.Domain.Models.Updates; namespace Wino.Core.Domain.Interfaces; @@ -30,4 +31,10 @@ public interface IDialogServiceBase Task> PickFilesAsync(params object[] typeFilters); Task PickFilePathAsync(string saveFileName); Task ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null); + + /// + /// Presents the "What's New" dialog for the current version. + /// This dialog is undismissable and runs any pending migrations when the user clicks "Get Started". + /// + Task ShowWhatIsNewDialogAsync(UpdateNotes notes); } diff --git a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs index aae5b2b2..bf43239a 100644 --- a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs +++ b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs @@ -40,4 +40,10 @@ public interface INotificationBuilder /// Creates a calendar reminder toast for the specified calendar item. /// Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds); + + /// + /// Shows a notification that a migration is required for the new app version. + /// Synchronization is stopped and the user is prompted to open the app. + /// + void CreateMigrationRequiredNotification(); } diff --git a/Wino.Core.Domain/Interfaces/IUpdateManager.cs b/Wino.Core.Domain/Interfaces/IUpdateManager.cs new file mode 100644 index 00000000..9a593439 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IUpdateManager.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Updates; + +namespace Wino.Core.Domain.Interfaces; + +public interface IUpdateManager +{ + /// Loads and parses the update notes for the current version from the bundled asset file. + Task GetLatestUpdateNotesAsync(); + + /// Returns true if the current version's update notes have not yet been shown to the user. + bool ShouldShowUpdateNotes(); + + /// Stores a flag in local settings indicating the update notes for the current version have been seen. + void MarkUpdateNotesAsSeen(); + + /// Returns true if any registered migration has not yet been completed. + bool HasPendingMigrations(); + + /// Runs all pending migrations in order and marks each as completed in local settings. + Task RunPendingMigrationsAsync(); + + /// Registers migrations to be tracked and executed by this manager. + void RegisterMigrations(IEnumerable migrations); +} diff --git a/Wino.Core.Domain/Models/Updates/UpdateNoteSection.cs b/Wino.Core.Domain/Models/Updates/UpdateNoteSection.cs new file mode 100644 index 00000000..0bb8f7c8 --- /dev/null +++ b/Wino.Core.Domain/Models/Updates/UpdateNoteSection.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Wino.Core.Domain.Models.Updates; + +public class UpdateNoteSection +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("imageUrl")] + public string ImageUrl { get; set; } = string.Empty; + + [JsonPropertyName("imageWidth")] + public double? ImageWidth { get; set; } + + [JsonPropertyName("imageHeight")] + public double? ImageHeight { get; set; } + + /// Gets the image width for binding, returning NaN for auto-sizing when not specified. + public double ActualImageWidth => ImageWidth ?? double.NaN; + + /// Gets the image height for binding, returning NaN for auto-sizing when not specified. + public double ActualImageHeight => ImageHeight ?? double.NaN; +} diff --git a/Wino.Core.Domain/Models/Updates/UpdateNotes.cs b/Wino.Core.Domain/Models/Updates/UpdateNotes.cs new file mode 100644 index 00000000..50e3e708 --- /dev/null +++ b/Wino.Core.Domain/Models/Updates/UpdateNotes.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Wino.Core.Domain.Models.Updates; + +public class UpdateNotes +{ + [JsonPropertyName("hasMigrations")] + public bool HasMigrations { get; set; } + + [JsonPropertyName("sections")] + public List Sections { get; set; } = []; +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index ba2f5ee1..51de54c8 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -983,5 +983,8 @@ "CalendarAccountSettings_DefaultShowAs": "Default Show As Status", "CalendarAccountSettings_DefaultShowAsDescription": "Default availability status for new events created with this account", "CalendarAccountSettings_PrimaryCalendar": "Primary Calendar", - "CalendarAccountSettings_PrimaryCalendarDescription": "Mark this calendar as the primary calendar for the account" + "CalendarAccountSettings_PrimaryCalendarDescription": "Mark this calendar as the primary calendar for the account", + "WhatIsNew_GetStartedButton": "Get Started", + "WhatIsNew_MigrationNotification_Title": "Wino Mail Updated", + "WhatIsNew_MigrationNotification_Message": "Open the app to complete the update and see what's new." } diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 2aa65413..9848c150 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -78,6 +78,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private readonly IMailDialogService _dialogService; private readonly IMimeFileService _mimeFileService; private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; + private readonly IUpdateManager _updateManager; private readonly INativeAppService _nativeAppService; private readonly IMailService _mailService; @@ -100,7 +101,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IStatePersistanceService statePersistanceService, IConfigurationService configurationService, IStartupBehaviorService startupBehaviorService, - IWebView2RuntimeValidatorService webView2RuntimeValidatorService) + IWebView2RuntimeValidatorService webView2RuntimeValidatorService, + IUpdateManager updateManager) { StatePersistenceService = statePersistanceService; @@ -121,6 +123,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, _notificationBuilder = notificationBuilder; _winoRequestDelegator = winoRequestDelegator; _webView2RuntimeValidatorService = webView2RuntimeValidatorService; + _updateManager = updateManager; } protected override void OnDispatcherAssigned() @@ -251,6 +254,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, await ForceAllAccountSynchronizationsAsync(); } + await ShowWhatIsNewIfNeededAsync(); await MakeSureEnableStartupLaunchAsync(); } @@ -264,6 +268,19 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } } + private async Task ShowWhatIsNewIfNeededAsync() + { + if (!_updateManager.ShouldShowUpdateNotes()) + return; + + var notes = await _updateManager.GetLatestUpdateNotesAsync(); + + if (notes.Sections.Count == 0) + return; + + await _dialogService.ShowWhatIsNewDialogAsync(notes); + } + private async Task MakeSureEnableStartupLaunchAsync() { if (!_configurationService.Get(IsActivateStartupLaunchAskedKey, false)) diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 161a5fcc..870bb2a3 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -22,6 +22,7 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Domain.Models.Updates; using Wino.Mail.Services; using Wino.Mail.ViewModels; using Wino.Mail.WinUI.Activation; @@ -153,9 +154,18 @@ public partial class App : WinoApplication, _preferencesService = Services.GetRequiredService(); _accountService = Services.GetRequiredService(); + // Check whether the new version requires a migration before starting sync. + var updateManager = Services.GetRequiredService(); + var updateNotes = await updateManager.GetLatestUpdateNotesAsync(); + bool hasPendingMigration = updateNotes.HasMigrations && updateManager.HasPendingMigrations(); + _preferencesService.PreferenceChanged -= PreferencesServiceChanged; _preferencesService.PreferenceChanged += PreferencesServiceChanged; - RestartAutoSynchronizationLoop(); + + // Hold off sync loop when a migration is required in startup-task (tray-only) mode. + // In foreground mode the sync loop starts normally; the ViewModel dialog handles migrations before sync kicks in. + if (!hasPendingMigration || !IsStartupTaskLaunch()) + RestartAutoSynchronizationLoop(); // Check if launched from toast notification. if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs)) @@ -179,12 +189,21 @@ public partial class App : WinoApplication, // Otherwise, activate the window normally. if (isStartupTaskLaunch) { - LogActivation("Launched by startup task. Window created but hidden (system tray only)."); - // Window is created but not activated. User can show it from system tray. + if (hasPendingMigration) + { + // Notify the user to open the app to complete the update before sync can resume. + Services.GetRequiredService().CreateMigrationRequiredNotification(); + LogActivation("Migration required for new version. Sync skipped. User notified via toast."); + } + else + { + LogActivation("Launched by startup task. Window created but hidden (system tray only)."); + } } else { // Normal launch - show and activate the window. + // The What's New dialog is shown from MailAppShellViewModel.OnNavigatedTo once XamlRoot is ready. MainWindow?.Activate(); LogActivation("Window created and activated."); } @@ -222,6 +241,13 @@ public partial class App : WinoApplication, { var toastArguments = ToastArguments.Parse(toastArgs.Argument); + // Check migration notification activation first. + if (toastArguments.Contains(Constants.ToastMigrationRequiredKey)) + { + await HandleMigrationToastActivationAsync(); + return; + } + // Check calendar reminder toast activation first. if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) && calendarAction == Constants.ToastCalendarNavigateAction && @@ -251,6 +277,29 @@ public partial class App : WinoApplication, } } + /// + /// Handles activation from the migration-required toast notification. + /// Opens the app so the shell ViewModel can show the What's New dialog and run migrations. + /// + private async Task HandleMigrationToastActivationAsync() + { + LogActivation("Handling migration toast activation."); + + if (!IsAppRunning()) + { + await CreateAndActivateWindow(null!); + } + else + { + EnsureMainWindowVisibleAndForeground(); + } + + // The MailAppShellViewModel.OnNavigatedTo will detect ShouldShowUpdateNotes() == true + // and display the What's New dialog (including running migrations) once the XamlRoot is ready. + // Restart sync in case it was blocked. + RestartAutoSynchronizationLoop(); + } + private async Task HandleCalendarToastNavigationAsync(Guid calendarItemId) { var calendarService = Services.GetRequiredService(); diff --git a/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json b/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json new file mode 100644 index 00000000..628505ad --- /dev/null +++ b/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json @@ -0,0 +1,40 @@ +{ + "hasMigrations": false, + "sections": [ + { + "title": "# Wino Calendar is here!", + "description": "You can now create local or remote CalDAV-compatible calendars, manage recurring events, and respond to invitations — all from within Wino.", + "imageUrl": "ms-appx:///Assets/AboutPageTile.png", + "imageWidth": 128, + "imageHeight": 128 + }, + { + "title": "# S/MIME Signing & Encryption", + "description": "Wino now supports signing and encrypting your emails with personal certificates. Keep your communications secure and verifiable.", + "imageUrl": "ms-appx:///Assets/AboutPageTile.png", + "imageWidth": 128, + "imageHeight": 128 + }, + { + "title": "# Threaded Mail View", + "description": "Emails are now grouped by conversation, making it easier to follow long discussions without losing context.", + "imageUrl": "ms-appx:///Assets/AboutPageTile.png", + "imageWidth": 128, + "imageHeight": 128 + }, + { + "title": "# Smarter Notifications", + "description": "Act on your emails directly from toast notifications — mark as read, delete, or archive without opening the app.", + "imageUrl": "ms-appx:///Assets/AboutPageTile.png", + "imageWidth": 128, + "imageHeight": 128 + }, + { + "title": "# And much more...", + "description": "Folder management, swipe actions, keyboard shortcuts, a custom print dialog, and significant performance improvements are all included in this release.\n\nThank you for using Wino Mail!", + "imageUrl": "ms-appx:///Assets/AboutPageTile.png", + "imageWidth": 128, + "imageHeight": 128 + } + ] +} diff --git a/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml b/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml new file mode 100644 index 00000000..fb7c1c14 --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml @@ -0,0 +1,70 @@ + + + + 480 + 560 + 480 + 700 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/WelcomePage.xaml.cs b/Wino.Mail.WinUI/Views/WelcomePage.xaml.cs index 0e7c14e7..93440684 100644 --- a/Wino.Mail.WinUI/Views/WelcomePage.xaml.cs +++ b/Wino.Mail.WinUI/Views/WelcomePage.xaml.cs @@ -1,3 +1,5 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using CommunityToolkit.WinUI.Controls; using Wino.Views.Abstract; @@ -5,12 +7,19 @@ namespace Wino.Views; public sealed partial class WelcomePage : WelcomePageAbstract { - private readonly MarkdownConfig _config; - public WelcomePage() { InitializeComponent(); + } - _config = new MarkdownConfig(); + private void OnTabSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not Segmented segmented) + return; + + bool isFeaturesTab = segmented.SelectedIndex == 0; + + FeaturesFlipView.Visibility = isFeaturesTab ? Visibility.Visible : Visibility.Collapsed; + WhatsNewFlipView.Visibility = isFeaturesTab ? Visibility.Collapsed : Visibility.Visible; } } diff --git a/Wino.Mail.WinUI/Views/WelcomePageV2.xaml b/Wino.Mail.WinUI/Views/WelcomePageV2.xaml new file mode 100644 index 00000000..989513e2 --- /dev/null +++ b/Wino.Mail.WinUI/Views/WelcomePageV2.xaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/WelcomePageV2.xaml.cs b/Wino.Mail.WinUI/Views/WelcomePageV2.xaml.cs new file mode 100644 index 00000000..908f3e01 --- /dev/null +++ b/Wino.Mail.WinUI/Views/WelcomePageV2.xaml.cs @@ -0,0 +1,22 @@ +using Microsoft.UI.Xaml.Controls; +using Wino.Views.Abstract; + +namespace Wino.Views; + +public sealed partial class WelcomePageV2 : WelcomePageV2Abstract +{ + public WelcomePageV2() + { + InitializeComponent(); + } + + private void OnFlipViewSelectionChanged(object sender, SelectionChangedEventArgs e) + { + FlipViewPager.SelectedPageIndex = UpdateFlipView.SelectedIndex; + } + + private void OnPipsPagerSelectedIndexChanged(PipsPager sender, PipsPagerSelectedIndexChangedEventArgs args) + { + UpdateFlipView.SelectedIndex = sender.SelectedPageIndex; + } +} diff --git a/Wino.Mail.WinUI/WelcomeWindow.xaml b/Wino.Mail.WinUI/WelcomeWindow.xaml new file mode 100644 index 00000000..f4b46e4e --- /dev/null +++ b/Wino.Mail.WinUI/WelcomeWindow.xaml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/Wino.Mail.WinUI/WelcomeWindow.xaml.cs b/Wino.Mail.WinUI/WelcomeWindow.xaml.cs new file mode 100644 index 00000000..0f192d14 --- /dev/null +++ b/Wino.Mail.WinUI/WelcomeWindow.xaml.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Interfaces; +using WinUIEx; + +namespace Wino.Mail.WinUI; + +public sealed partial class WelcomeWindow : WindowEx +{ + public Frame GetRootFrame() => RootFrame; + + public WelcomeWindow() + { + InitializeComponent(); + + MinWidth = 980; + MinHeight = 900; + Title = "Wino Mail"; + this.SetIcon("Assets/Wino_Icon.ico"); + + ConfigureWindowChrome(); + } + + private void ConfigureWindowChrome() + { + AppWindow.TitleBar.ExtendsContentIntoTitleBar = true; + + Width = 980; + Height = 720; + + //this.IsResizable = false; + //this.IsMaximizable = false; + + this.CenterOnScreen(); + + var themeService = WinoApplication.Current.Services.GetService(); + themeService?.UpdateSystemCaptionButtonColors(); + } +} diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index 5ebfc99d..da387108 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -98,6 +98,13 @@ + + + + + + + @@ -143,6 +150,14 @@ + + + + + + + + diff --git a/Wino.Messages/Client/Navigation/GetStartedFromWelcomeRequested.cs b/Wino.Messages/Client/Navigation/GetStartedFromWelcomeRequested.cs new file mode 100644 index 00000000..58b11c00 --- /dev/null +++ b/Wino.Messages/Client/Navigation/GetStartedFromWelcomeRequested.cs @@ -0,0 +1,7 @@ +namespace Wino.Messaging.Client.Navigation; + +/// +/// User clicked "Get Started" on the welcome page. +/// App should close the welcome window and open the shell window. +/// +public record GetStartedFromWelcomeRequested; diff --git a/Wino.Services/Migrations/VNextDelayMigration.cs b/Wino.Services/Migrations/VNextDelayMigration.cs new file mode 100644 index 00000000..16790661 --- /dev/null +++ b/Wino.Services/Migrations/VNextDelayMigration.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Wino.Core.Domain.Interfaces; + +namespace Wino.Services.Migrations; + +public class VNextDelayMigration : IAppMigration +{ + public string MigrationId => "vnext-delay"; + + public Task ExecuteAsync() => Task.Delay(3000); +} diff --git a/Wino.Services/UpdateManager.cs b/Wino.Services/UpdateManager.cs index f7bff55a..b5be9131 100644 --- a/Wino.Services/UpdateManager.cs +++ b/Wino.Services/UpdateManager.cs @@ -6,12 +6,14 @@ using System.Threading.Tasks; using Wino.Core.Domain; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Updates; +using Wino.Services.Migrations; namespace Wino.Services; public class UpdateManager : IUpdateManager { private const string UpdateNotesResourcePath = "ms-appx:///Assets/UpdateNotes/vnext.json"; + private const string FeaturesResourcePath = "ms-appx:///Assets/UpdateNotes/features.json"; private const string UpdateNotesSeenKeyFormat = "UpdateNotes_{0}_Shown"; private const string MigrationCompletedKeyFormat = "Migration_{0}_Completed"; @@ -21,6 +23,7 @@ public class UpdateManager : IUpdateManager private readonly List _migrations = []; private string _versionSeenKey = string.Empty; + private UpdateNotes _latestUpdateNotes = new(); public UpdateManager(IFileService fileService, IConfigurationService configurationService, @@ -29,6 +32,7 @@ public class UpdateManager : IUpdateManager _fileService = fileService; _configurationService = configurationService; _nativeAppService = nativeAppService; + _migrations.Add(new VNextDelayMigration()); } private string GetVersionSeenKey() @@ -50,27 +54,61 @@ public class UpdateManager : IUpdateManager var json = await _fileService.GetFileContentByApplicationUriAsync(UpdateNotesResourcePath); if (string.IsNullOrEmpty(json)) - return new UpdateNotes(); + { + _latestUpdateNotes = new UpdateNotes(); + return _latestUpdateNotes; + } - return JsonSerializer.Deserialize(json, BasicTypesJsonContext.Default.UpdateNotes) ?? new UpdateNotes(); + _latestUpdateNotes = JsonSerializer.Deserialize(json, BasicTypesJsonContext.Default.UpdateNotes) ?? new UpdateNotes(); + return _latestUpdateNotes; } catch (Exception) { - return new UpdateNotes(); + _latestUpdateNotes = new UpdateNotes(); + return _latestUpdateNotes; } } + // T public bool ShouldShowUpdateNotes() => !_configurationService.Get(GetVersionSeenKey(), false); + public async Task> GetFeaturesAsync() + { + try + { + var json = await _fileService.GetFileContentByApplicationUriAsync(FeaturesResourcePath); + + if (string.IsNullOrEmpty(json)) + return []; + + return JsonSerializer.Deserialize(json, BasicTypesJsonContext.Default.ListUpdateNoteSection) ?? []; + } + catch (Exception) + { + return []; + } + } + public void MarkUpdateNotesAsSeen() => _configurationService.Set(GetVersionSeenKey(), true); public bool HasPendingMigrations() - => _migrations.Any(m => !_configurationService.Get(string.Format(MigrationCompletedKeyFormat, m.MigrationId), false)); + { + if (!_latestUpdateNotes.HasPendingMigrations) + return false; + + return _migrations.Any(m => !_configurationService.Get(string.Format(MigrationCompletedKeyFormat, m.MigrationId), false)); + } public async Task RunPendingMigrationsAsync() { + if (!_latestUpdateNotes.HasPendingMigrations) + _latestUpdateNotes = await GetLatestUpdateNotesAsync(); + + if (!_latestUpdateNotes.HasPendingMigrations) + return; + foreach (var migration in _migrations) { var key = string.Format(MigrationCompletedKeyFormat, migration.MigrationId); From aaa6e8a2c97f9de81eee874e2c36092517dfe337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 6 Mar 2026 03:42:08 +0100 Subject: [PATCH 3/3] Removed migrations. New onboarding screen and wizard like steps. --- .claude/instructions/winui3.instructions.md | 160 ++++++ Wino.Core.Domain/BasicTypesJsonContext.cs | 1 - Wino.Core.Domain/Constants.cs | 2 - .../Enums/AccountSetupStepStatus.cs | 9 + Wino.Core.Domain/Enums/WinoPage.cs | 6 +- Wino.Core.Domain/Interfaces/IAppMigration.cs | 15 - .../Interfaces/INotificationBuilder.cs | 6 - Wino.Core.Domain/Interfaces/IUpdateManager.cs | 10 +- .../Models/Accounts/AccountSetupStepModel.cs | 24 + .../Models/Updates/UpdateMigration.cs | 12 - .../Models/Updates/UpdateNotes.cs | 9 - .../Translations/en_US/resources.json | 42 +- .../Data/BreadcrumbNavigationItemViewModel.cs | 5 +- .../WelcomeHostPageViewModel.cs | 13 + .../AccountManagementViewModel.cs | 244 +-------- .../AccountSetupProgressPageViewModel.cs | 477 ++++++++++++++++++ .../ImapCalDavSettingsNavigationContext.cs | 13 +- .../Data/WelcomeWizardContext.cs | 79 +++ .../ImapCalDavSettingsPageViewModel.cs | 31 +- .../ProviderSelectionPageViewModel.cs | 122 +++++ .../SpecialImapCredentialsPageViewModel.cs | 127 +++++ .../WelcomePageV2ViewModel.cs | 6 +- Wino.Mail.WinUI/App.xaml.cs | 84 ++- Wino.Mail.WinUI/AppThemes/Clouds.xaml | 2 +- Wino.Mail.WinUI/AppThemes/Forest.xaml | 2 +- Wino.Mail.WinUI/AppThemes/Garden.xaml | 2 +- Wino.Mail.WinUI/AppThemes/Nighty.xaml | 2 +- Wino.Mail.WinUI/AppThemes/Snowflake.xaml | 2 +- Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json | 5 - Wino.Mail.WinUI/CoreUWPContainerSetup.cs | 1 + Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml | 24 - .../Dialogs/WhatIsNewDialog.xaml.cs | 51 +- Wino.Mail.WinUI/Helpers/XamlHelpers.cs | 11 + .../Models/Personalization/CustomAppTheme.cs | 2 +- .../Personalization/PreDefinedAppTheme.cs | 4 +- Wino.Mail.WinUI/Services/NavigationService.cs | 10 +- .../Services/NotificationBuilder.cs | 14 - Wino.Mail.WinUI/ShellWindow.xaml | 3 + Wino.Mail.WinUI/Styles/DataTemplates.xaml | 16 +- .../AccountSetupProgressPageAbstract.cs | 8 + .../Abstract/ProviderSelectionPageAbstract.cs | 8 + .../SpecialImapCredentialsPageAbstract.cs | 8 + .../Views/Abstract/WelcomeHostPageAbstract.cs | 8 + .../Views/Account/ImapCalDavSettingsPage.xaml | 40 +- .../Views/AccountSetupProgressPage.xaml | 131 +++++ .../Views/AccountSetupProgressPage.xaml.cs | 11 + .../Views/Calendar/CalendarAppShell.xaml | 5 +- .../Views/ProviderSelectionPage.xaml | 125 +++++ .../Views/ProviderSelectionPage.xaml.cs | 17 + .../Views/SpecialImapCredentialsPage.xaml | 147 ++++++ .../Views/SpecialImapCredentialsPage.xaml.cs | 25 + Wino.Mail.WinUI/Views/WelcomeHostPage.xaml | 69 +++ Wino.Mail.WinUI/Views/WelcomeHostPage.xaml.cs | 96 ++++ Wino.Mail.WinUI/WelcomeWindow.xaml.cs | 3 - .../Migrations/VNextDelayMigration.cs | 11 - Wino.Services/UpdateManager.cs | 37 -- 56 files changed, 1843 insertions(+), 554 deletions(-) create mode 100644 .claude/instructions/winui3.instructions.md create mode 100644 Wino.Core.Domain/Enums/AccountSetupStepStatus.cs delete mode 100644 Wino.Core.Domain/Interfaces/IAppMigration.cs create mode 100644 Wino.Core.Domain/Models/Accounts/AccountSetupStepModel.cs delete mode 100644 Wino.Core.Domain/Models/Updates/UpdateMigration.cs create mode 100644 Wino.Core.ViewModels/WelcomeHostPageViewModel.cs create mode 100644 Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs create mode 100644 Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs create mode 100644 Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs create mode 100644 Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/AccountSetupProgressPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/ProviderSelectionPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/SpecialImapCredentialsPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/WelcomeHostPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/AccountSetupProgressPage.xaml create mode 100644 Wino.Mail.WinUI/Views/AccountSetupProgressPage.xaml.cs create mode 100644 Wino.Mail.WinUI/Views/ProviderSelectionPage.xaml create mode 100644 Wino.Mail.WinUI/Views/ProviderSelectionPage.xaml.cs create mode 100644 Wino.Mail.WinUI/Views/SpecialImapCredentialsPage.xaml create mode 100644 Wino.Mail.WinUI/Views/SpecialImapCredentialsPage.xaml.cs create mode 100644 Wino.Mail.WinUI/Views/WelcomeHostPage.xaml create mode 100644 Wino.Mail.WinUI/Views/WelcomeHostPage.xaml.cs delete mode 100644 Wino.Services/Migrations/VNextDelayMigration.cs diff --git a/.claude/instructions/winui3.instructions.md b/.claude/instructions/winui3.instructions.md new file mode 100644 index 00000000..811306c6 --- /dev/null +++ b/.claude/instructions/winui3.instructions.md @@ -0,0 +1,160 @@ +--- +description: 'WinUI 3 and Windows App SDK coding guidelines. Prevents common UWP API misuse, enforces correct XAML namespaces, threading, windowing, and MVVM patterns for desktop Windows apps.' +applyTo: '**/*.xaml, **/*.cs, **/*.csproj' +--- + +# WinUI 3 / Windows App SDK + +## Critical Rules — NEVER Use Legacy UWP APIs + +These UWP patterns are **wrong** for WinUI 3 desktop apps. Always use the Windows App SDK equivalent. + +- **NEVER** use `Windows.UI.Popups.MessageDialog`. Use `ContentDialog` with `XamlRoot` set. +- **NEVER** show a `ContentDialog` without setting `dialog.XamlRoot = this.Content.XamlRoot` first. +- **NEVER** use `CoreDispatcher.RunAsync` or `Dispatcher.RunAsync`. Use `DispatcherQueue.TryEnqueue`. +- **NEVER** use `Window.Current`. Track the main window via a static `App.MainWindow` property. +- **NEVER** use `Windows.UI.Xaml.*` namespaces. Use `Microsoft.UI.Xaml.*`. +- **NEVER** use `Windows.UI.Composition`. Use `Microsoft.UI.Composition`. +- **NEVER** use `Windows.UI.Colors`. Use `Microsoft.UI.Colors`. +- **NEVER** use `ApplicationView` or `CoreWindow` for window management. Use `Microsoft.UI.Windowing.AppWindow`. +- **NEVER** use `CoreApplicationViewTitleBar`. Use `AppWindowTitleBar`. +- **NEVER** use `GetForCurrentView()` patterns (e.g., `UIViewSettings.GetForCurrentView()`). These do not exist in desktop WinUI 3. Use `AppWindow` APIs instead. +- **NEVER** use UWP `PrintManager` directly. Use `IPrintManagerInterop` with a window handle. +- **NEVER** use `DataTransferManager` directly for sharing. Use `IDataTransferManagerInterop` with a window handle. +- **NEVER** use UWP `IBackgroundTask`. Use `Microsoft.Windows.AppLifecycle` activation. +- **NEVER** use `WebAuthenticationBroker`. Use `OAuth2Manager` (Windows App SDK 1.7+). + +## XAML Patterns + +- The default XAML namespace maps to `Microsoft.UI.Xaml`, not `Windows.UI.Xaml`. +- Prefer `{x:Bind}` over `{Binding}` for compiled, type-safe, higher-performance bindings. +- Set `x:DataType` on `DataTemplate` elements when using `{x:Bind}` — this is required for compiled bindings in templates. On Page/UserControl, `x:DataType` enables compile-time binding validation but is not strictly required if the DataContext does not change. +- Use `Mode=OneWay` for dynamic values, `Mode=OneTime` for static, `Mode=TwoWay` only for editable inputs. +- Do not bind static constants — set them directly in XAML. + +## Threading + +- Use `DispatcherQueue.TryEnqueue(() => { ... })` to update UI from background threads. +- `TryEnqueue` returns `bool`, not a `Task` — it is fire-and-forget. +- Check thread access with `DispatcherQueue.HasThreadAccess` before dispatching. +- WinUI 3 uses standard STA (not ASTA). No built-in reentrancy protection — be cautious with async code that pumps messages. + +## Windowing + +- Get the `AppWindow` from a WinUI 3 `Window` via `WindowNative.GetWindowHandle` → `Win32Interop.GetWindowIdFromWindow` → `AppWindow.GetFromWindowId`. +- Use `AppWindow` for resize, move, title, and presenter operations. +- Custom title bar: use `AppWindow.TitleBar` properties, not `CoreApplicationViewTitleBar`. +- Track the main window as `App.MainWindow` (a static property set in `OnLaunched`). + +## Dialogs and Pickers + +- **ContentDialog**: Always set `dialog.XamlRoot = this.Content.XamlRoot` before calling `ShowAsync()`. +- **File/Folder Pickers**: Initialize with `WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd)` where `hwnd` comes from `WindowNative.GetWindowHandle(App.MainWindow)`. +- **Share/Print**: Use COM interop interfaces (`IDataTransferManagerInterop`, `IPrintManagerInterop`) with window handles. + +## MVVM and Data Binding + +- Prefer `CommunityToolkit.Mvvm` (`[ObservableProperty]`, `[RelayCommand]`) for MVVM infrastructure. +- Use `Microsoft.Extensions.DependencyInjection` for service registration and injection. +- Keep UI (Views) focused on layout and bindings; keep logic in ViewModels and services. +- Use `async`/`await` for I/O and long-running work to keep the UI responsive. + +## Project Setup + +- Target `net10.0-windows10.0.22621.0` (or appropriate TFM for the project's target SDK). +- Set `true` in the project file. +- Reference the latest stable `Microsoft.WindowsAppSDK` NuGet package. +- Use `System.Text.Json` with source generators for JSON serialization. + +## C# Code Style + +- Use file-scoped namespaces. +- Enable nullable reference types. Use `is null` / `is not null` instead of `== null`. +- Prefer pattern matching over `as`/`is` with null checks. +- PascalCase for types, methods, properties. camelCase for private fields. +- Allman brace style (opening brace on its own line). +- Prefer explicit types for built-in types; use `var` only when the type is obvious. + +## Accessibility + +- Set `AutomationProperties.Name` on all interactive controls. +- Use `AutomationProperties.HeadingLevel` on section headers. +- Hide decorative elements with `AutomationProperties.AccessibilityView="Raw"`. +- Ensure full keyboard navigation (Tab, Enter, Space, arrow keys). +- Meet WCAG color contrast requirements. + +## Performance + +- Prefer `{x:Bind}` (compiled) over `{Binding}` (reflection-based). +- **NativeAOT:** Under Native AOT compilation, `{Binding}` (reflection-based) does not work at all. Only `{x:Bind}` (compiled bindings) is supported. If the project uses NativeAOT, use `{x:Bind}` exclusively. +- Use `x:Load` or `x:DeferLoadStrategy` for UI elements that are not immediately needed. +- Use `ItemsRepeater` with virtualization for large lists. +- Avoid deep layout nesting — prefer `Grid` over nested `StackPanel` chains. +- Use `async`/`await` for all I/O; never block the UI thread. + +## App Settings (Packaged vs Unpackaged) + +- **Packaged apps**: `ApplicationData.Current.LocalSettings` works as expected. +- **Unpackaged apps**: Use a custom settings file (e.g., JSON in `Environment.GetFolderPath(SpecialFolder.LocalApplicationData)`). +- Do not assume `ApplicationData` is always available — check packaging status first. + +## Typography + +- **Always** use built-in TextBlock styles (`CaptionTextBlockStyle`, `BodyTextBlockStyle`, `BodyStrongTextBlockStyle`, `SubtitleTextBlockStyle`, `TitleTextBlockStyle`, `TitleLargeTextBlockStyle`, `DisplayTextBlockStyle`). +- Prefer using the built-in TextBlock styles over hardcoding `FontSize`, `FontWeight`, or `FontFamily`. +- Font: Segoe UI Variable is the default — do not change it. +- Use sentence casing for all UI text. + + +## Theming & Colors + +- **Always** use `{ThemeResource}` for brushes and colors to support Light, Dark, and High Contrast themes automatically. +- **Never** hardcode color values (`#FFFFFF`, `Colors.White`, etc.) for UI elements. Use theme resources like `TextFillColorPrimaryBrush`, `CardBackgroundFillColorDefaultBrush`, `CardStrokeColorDefaultBrush`. +- Use `SystemAccentColor` (and `Light1`–`Light3`, `Dark1`–`Dark3` variants) for the user's accent color palette. +- For borders: use `CardStrokeColorDefaultBrush` or `ControlStrokeColorDefaultBrush`. + +## Spacing & Layout + +- Use a **4px grid system**: all margins, padding, and spacing values must be multiples of 4px. +- Standard spacing: 4 (compact), 8 (controls), 12 (small gutters), 16 (content padding), 24 (large gutters). +- Prefer `Grid` over deeply nested `StackPanel` chains for performance. +- Use `Auto` for content-sized rows/columns, `*` for proportional sizing. Avoid fixed pixel sizes. +- Use `VisualStateManager` with `AdaptiveTrigger` for responsive layouts at breakpoints (640px, 1008px). +- Use `ControlCornerRadius` (4px) for small controls and `OverlayCornerRadius` (8px) for cards, dialogs, flyouts. + +## Materials & Elevation + +- Use **Mica** (`MicaBackdrop`) for the app window backdrop. Requires transparent layers above to show through. +- Use **Acrylic** for transient surfaces only (flyouts, menus, navigation panes). +- Use `LayerFillColorDefaultBrush` for content layers above Mica. +- Use `ThemeShadow` with Z-axis `Translation` for elevation. Cards: 4–8 px, Flyouts: 32 px, Dialogs: 128 px. + +## Motion & Transitions + +- Use built-in theme transitions (`EntranceThemeTransition`, `RepositionThemeTransition`, `ContentThemeTransition`, `AddDeleteThemeTransition`). +- Avoid custom storyboard animations when a built-in transition exists. + +## Control Selection + +- Use `NavigationView` for primary app navigation (not custom sidebars). +- Use `InfoBar` for persistent in-app notifications (not custom banners). +- Use `TeachingTip` for contextual guidance (not custom popups). +- Use `NumberBox` for numeric input (not TextBox with manual validation). +- Use `ToggleSwitch` for on/off settings (not CheckBox). +- Use `ItemsView` as the modern collection control for displaying data with built-in selection, virtualization, and layout flexibility. +- Use `ListView`/`GridView` for standard virtualized lists and grids, especially when built-in selection support is needed. +- Use `ItemsRepeater` only for fully custom virtualizing layouts where you need complete control over rendering and do not need built-in selection or interaction handling. +- Use `Expander` for collapsible sections (not custom visibility toggling). + +## Error Handling + +- Always wrap `async void` event handlers in try/catch to prevent unhandled crashes. +- Use `InfoBar` (with `Severity = Error`) for user-facing error messages, not `ContentDialog` for routine errors. +- Handle `App.UnhandledException` for logging and graceful recovery. + +## Testing + +- **NEVER** use a plain MSTest or xUnit project for tests that instantiate WinUI 3 XAML types. Use a **Unit Test App (WinUI in Desktop)** project, which provides the Xaml runtime and UI thread. +- Use `[TestMethod]` for pure logic tests. Use `[UITestMethod]` for any test that creates or interacts with `Microsoft.UI.Xaml` types (controls, pages, user controls). +- Place testable business logic in a **Class Library (WinUI in Desktop)** project, separate from the main app. +- Build the solution before running tests to enable Visual Studio test discovery. diff --git a/Wino.Core.Domain/BasicTypesJsonContext.cs b/Wino.Core.Domain/BasicTypesJsonContext.cs index dbe88a7f..f6085c1c 100644 --- a/Wino.Core.Domain/BasicTypesJsonContext.cs +++ b/Wino.Core.Domain/BasicTypesJsonContext.cs @@ -10,6 +10,5 @@ namespace Wino.Core.Domain; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(UpdateNotes))] -[JsonSerializable(typeof(UpdateMigration))] [JsonSerializable(typeof(List))] public partial class BasicTypesJsonContext : JsonSerializerContext; diff --git a/Wino.Core.Domain/Constants.cs b/Wino.Core.Domain/Constants.cs index 9a9bb2f6..8be631b2 100644 --- a/Wino.Core.Domain/Constants.cs +++ b/Wino.Core.Domain/Constants.cs @@ -19,8 +19,6 @@ public static class Constants public const string ToastModeKey = nameof(ToastModeKey); public const string ToastModeMail = nameof(ToastModeMail); public const string ToastModeCalendar = nameof(ToastModeCalendar); - public const string ToastMigrationRequiredKey = nameof(ToastMigrationRequiredKey); - public const string ClientLogFile = "Client_.log"; public const string ServerLogFile = "Server_.log"; public const string LogArchiveFileName = "WinoLogs.zip"; diff --git a/Wino.Core.Domain/Enums/AccountSetupStepStatus.cs b/Wino.Core.Domain/Enums/AccountSetupStepStatus.cs new file mode 100644 index 00000000..a0bb99a3 --- /dev/null +++ b/Wino.Core.Domain/Enums/AccountSetupStepStatus.cs @@ -0,0 +1,9 @@ +namespace Wino.Core.Domain.Enums; + +public enum AccountSetupStepStatus +{ + Pending, + InProgress, + Succeeded, + Failed +} diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index c44b0f47..43637436 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -35,5 +35,9 @@ public enum WinoPage EventDetailsPage, SignatureAndEncryptionPage, StoragePage, - WelcomePageV2 + WelcomePageV2, + WelcomeHostPage, + ProviderSelectionPage, + AccountSetupProgressPage, + SpecialImapCredentialsPage } diff --git a/Wino.Core.Domain/Interfaces/IAppMigration.cs b/Wino.Core.Domain/Interfaces/IAppMigration.cs deleted file mode 100644 index cb2f31c2..00000000 --- a/Wino.Core.Domain/Interfaces/IAppMigration.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; - -namespace Wino.Core.Domain.Interfaces; - -/// -/// Represents a one-time app or data migration that runs when a user updates to a new version. -/// -public interface IAppMigration -{ - /// Gets the unique identifier for this migration, used to track completion in local settings. - string MigrationId { get; } - - /// Executes the migration logic. - Task ExecuteAsync(); -} diff --git a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs index bf43239a..aae5b2b2 100644 --- a/Wino.Core.Domain/Interfaces/INotificationBuilder.cs +++ b/Wino.Core.Domain/Interfaces/INotificationBuilder.cs @@ -40,10 +40,4 @@ public interface INotificationBuilder /// Creates a calendar reminder toast for the specified calendar item. /// Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds); - - /// - /// Shows a notification that a migration is required for the new app version. - /// Synchronization is stopped and the user is prompted to open the app. - /// - void CreateMigrationRequiredNotification(); } diff --git a/Wino.Core.Domain/Interfaces/IUpdateManager.cs b/Wino.Core.Domain/Interfaces/IUpdateManager.cs index cb5ecef9..d0e4a900 100644 --- a/Wino.Core.Domain/Interfaces/IUpdateManager.cs +++ b/Wino.Core.Domain/Interfaces/IUpdateManager.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; using System.Threading.Tasks; using Wino.Core.Domain.Models.Updates; +using System.Collections.Generic; namespace Wino.Core.Domain.Interfaces; @@ -18,12 +18,4 @@ public interface IUpdateManager /// Stores a flag in local settings indicating the update notes for the current version have been seen. void MarkUpdateNotesAsSeen(); - /// Returns true if any registered migration has not yet been completed. - bool HasPendingMigrations(); - - /// Runs all pending migrations in order and marks each as completed in local settings. - Task RunPendingMigrationsAsync(); - - /// Registers migrations to be tracked and executed by this manager. - void RegisterMigrations(IEnumerable migrations); } diff --git a/Wino.Core.Domain/Models/Accounts/AccountSetupStepModel.cs b/Wino.Core.Domain/Models/Accounts/AccountSetupStepModel.cs new file mode 100644 index 00000000..65befde6 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/AccountSetupStepModel.cs @@ -0,0 +1,24 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Accounts; + +public partial class AccountSetupStepModel : ObservableObject +{ + public string Title { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPending))] + [NotifyPropertyChangedFor(nameof(IsInProgress))] + [NotifyPropertyChangedFor(nameof(IsSucceeded))] + [NotifyPropertyChangedFor(nameof(IsFailed))] + public partial AccountSetupStepStatus Status { get; set; } = AccountSetupStepStatus.Pending; + + [ObservableProperty] + public partial string ErrorMessage { get; set; } + + public bool IsPending => Status == AccountSetupStepStatus.Pending; + public bool IsInProgress => Status == AccountSetupStepStatus.InProgress; + public bool IsSucceeded => Status == AccountSetupStepStatus.Succeeded; + public bool IsFailed => Status == AccountSetupStepStatus.Failed; +} diff --git a/Wino.Core.Domain/Models/Updates/UpdateMigration.cs b/Wino.Core.Domain/Models/Updates/UpdateMigration.cs deleted file mode 100644 index 205e4dc4..00000000 --- a/Wino.Core.Domain/Models/Updates/UpdateMigration.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Wino.Core.Domain.Models.Updates; - -public class UpdateMigration -{ - [JsonPropertyName("titleKey")] - public string TitleKey { get; set; } = string.Empty; - - [JsonPropertyName("descriptionKey")] - public string DescriptionKey { get; set; } = string.Empty; -} diff --git a/Wino.Core.Domain/Models/Updates/UpdateNotes.cs b/Wino.Core.Domain/Models/Updates/UpdateNotes.cs index abab57f1..354f6f6c 100644 --- a/Wino.Core.Domain/Models/Updates/UpdateNotes.cs +++ b/Wino.Core.Domain/Models/Updates/UpdateNotes.cs @@ -1,16 +1,7 @@ using System.Collections.Generic; -using System.Text.Json.Serialization; - namespace Wino.Core.Domain.Models.Updates; public class UpdateNotes { - [JsonPropertyName("hasPendingMigrations")] - public bool HasPendingMigrations { get; set; } - - [JsonPropertyName("migration")] - public UpdateMigration Migration { get; set; } = new(); - - [JsonPropertyName("sections")] public List Sections { get; set; } = []; } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 6a819ed5..1a12d997 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1004,5 +1004,45 @@ "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_AddAccountButton": "Add account", "WelcomeWindow_SkipForNow": "Skip for now — I'll set it up later", - "WelcomeWindow_AppDescription": "A fast, focused inbox — redesigned for Windows 11" + "WelcomeWindow_AppDescription": "A fast, focused inbox — redesigned for Windows 11", + "WelcomeWizard_Step1Title": "Welcome", + "WelcomeWizard_Step2Title": "Add Account", + "WelcomeWizard_Step3Title": "Finish Setup", + "ProviderSelection_Title": "Choose your email provider", + "ProviderSelection_Subtitle": "Select a provider below to add your email account to Wino Mail.", + "ProviderSelection_AccountNameHeader": "Account Name", + "ProviderSelection_AccountNamePlaceholder": "e.g. Personal, Work", + "ProviderSelection_DisplayNameHeader": "Display Name", + "ProviderSelection_DisplayNamePlaceholder": "e.g. John Doe", + "ProviderSelection_EmailHeader": "E-mail Address", + "ProviderSelection_EmailPlaceholder": "e.g. johndoe@example.com", + "ProviderSelection_AppPasswordHeader": "App-Specific Password", + "ProviderSelection_AppPasswordHelp": "How do I get an app-specific password?", + "ProviderSelection_CalendarModeHeader": "Calendar Integration", + "ProviderSelection_CalendarMode_DisabledTitle": "Disabled", + "ProviderSelection_CalendarMode_DisabledDescription": "No calendar integration", + "ProviderSelection_CalendarMode_CalDavTitle": "CalDAV Synchronization", + "ProviderSelection_CalendarMode_CalDavDescription_Apple": "Your calendar events are synced to Apple servers between your devices.", + "ProviderSelection_CalendarMode_CalDavDescription_Yahoo": "Your calendar events are synced to Yahoo servers between your devices.", + "ProviderSelection_CalendarMode_LocalTitle": "Local calendar", + "ProviderSelection_CalendarMode_LocalDescription": "Your events are stored only on your computer. No server connectivity.", + "ProviderSelection_ClearColor": "Clear color", + "ProviderSelection_ContinueButton": "Continue", + "ProviderSelection_SpecialImap_Subtitle": "Enter your account credentials to connect.", + "AccountSetup_Title": "Setting up your account", + "AccountSetup_Step_Authenticating": "Authenticating with {0}", + "AccountSetup_Step_TestingMailAuth": "Testing mail authentication", + "AccountSetup_Step_SyncingFolders": "Synchronizing folder metadata", + "AccountSetup_Step_FetchingProfile": "Fetching profile information", + "AccountSetup_Step_DiscoveringCalDav": "Discovering CalDAV settings", + "AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication", + "AccountSetup_Step_SavingAccount": "Saving account information", + "AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata", + "AccountSetup_Step_SyncingAliases": "Synchronizing aliases", + "AccountSetup_Step_Finalizing": "Finalizing setup", + "AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.", + "AccountSetup_SuccessMessage": "Your account has been set up successfully!", + "AccountSetup_GoBackButton": "Go Back", + "AccountSetup_TryAgainButton": "Try Again", + "ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab." } diff --git a/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs b/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs index 5d808e66..55941039 100644 --- a/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs +++ b/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs @@ -11,12 +11,15 @@ public partial class BreadcrumbNavigationItemViewModel : ObservableObject [ObservableProperty] private bool isActive; + public int StepNumber { get; set; } + public BreadcrumbNavigationRequested Request { get; set; } - public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive) + public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive, int stepNumber = 0) { Request = request; Title = request.PageTitle; IsActive = isActive; + StepNumber = stepNumber; } } diff --git a/Wino.Core.ViewModels/WelcomeHostPageViewModel.cs b/Wino.Core.ViewModels/WelcomeHostPageViewModel.cs new file mode 100644 index 00000000..5ee5dd40 --- /dev/null +++ b/Wino.Core.ViewModels/WelcomeHostPageViewModel.cs @@ -0,0 +1,13 @@ +using Wino.Core.Domain.Interfaces; + +namespace Wino.Core.ViewModels; + +public class WelcomeHostPageViewModel : CoreBaseViewModel +{ + public WelcomeHostPageViewModel(INavigationService navigationService) + { + NavigationService = navigationService; + } + + public INavigationService NavigationService { get; } +} diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index ba566492..50c2d46f 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -86,249 +86,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel return; } - MailAccount createdAccount = null; - IAccountCreationDialog creationDialog = null; - bool creationDialogClosed = false; - - try - { - var providers = ProviderService.GetAvailableProviders(); - - // Select provider. - var accountCreationDialogResult = await ExecuteUIThreadTaskAsync(() => MailDialogService.ShowAccountProviderSelectionDialogAsync(providers)); - - if (accountCreationDialogResult != null) - { - CustomServerInformation customServerInformation = null; - - createdAccount = new MailAccount() - { - ProviderType = accountCreationDialogResult.ProviderType, - Name = accountCreationDialogResult.AccountName, - SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None, - Id = Guid.NewGuid(), - AccountColorHex = accountCreationDialogResult.AccountColorHex, - IsCalendarAccessGranted = true // New accounts have calendar scopes - }; - - if (accountCreationDialogResult.ProviderType == MailProviderType.IMAP4) - { - if (createdAccount.SpecialImapProvider == SpecialImapProvider.iCloud || createdAccount.SpecialImapProvider == SpecialImapProvider.Yahoo) - { - var accountCreationCancellationTokenSource = new CancellationTokenSource(); - creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - - await ExecuteUIThreadTaskAsync(() => creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource)); - await Task.Delay(500); - - await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.SigningIn); - - customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(createdAccount, accountCreationDialogResult) - ?? throw new AccountSetupCanceledException(); - - customServerInformation.Id = Guid.NewGuid(); - customServerInformation.AccountId = createdAccount.Id; - - createdAccount.Address = accountCreationDialogResult.SpecialImapProviderDetails.Address; - createdAccount.SenderName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName; - createdAccount.IsCalendarAccessGranted = customServerInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled; - createdAccount.ServerInformation = customServerInformation; - - await ValidateSpecialImapConnectivityAsync(customServerInformation).ConfigureAwait(false); - } - else - { - var completionSource = new TaskCompletionSource(); - var setupContext = ImapCalDavSettingsNavigationContext.CreateForCreateMode(accountCreationDialogResult, completionSource); - - await ExecuteUIThread(() => Messenger.Send(new BreadcrumbNavigationRequested( - Translator.ImapCalDavSettingsPage_TitleCreate, - WinoPage.ImapCalDavSettingsPage, - setupContext))); - - var setupResult = await completionSource.Task.ConfigureAwait(false) - ?? throw new AccountSetupCanceledException(); - - customServerInformation = setupResult.ServerInformation ?? throw new AccountSetupCanceledException(); - customServerInformation.Id = Guid.NewGuid(); - customServerInformation.AccountId = createdAccount.Id; - - createdAccount.Address = setupResult.EmailAddress; - createdAccount.SenderName = setupResult.DisplayName; - createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; - createdAccount.ServerInformation = customServerInformation; - } - } - else - { - var accountCreationCancellationTokenSource = new CancellationTokenSource(); - creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - - await ExecuteUIThreadTaskAsync(() => creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource)); - await Task.Delay(500); - - await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.SigningIn); - - // OAuth authentication is handled here. - // Use SynchronizationManager to handle OAuth authentication. - - var authTokenInfo = await SynchronizationManager.Instance.HandleAuthorizationAsync( - accountCreationDialogResult.ProviderType, - createdAccount, - createdAccount.ProviderType == MailProviderType.Gmail); - - bool creationCanceled = false; - await ExecuteUIThread(() => creationCanceled = creationDialog.State == AccountCreationDialogState.Canceled); - - if (creationCanceled) - throw new AccountSetupCanceledException(); - - // Update account address with authenticated user information - createdAccount.Address = authTokenInfo.AccountAddress; - } - - // Address is still doesn't have a value for API synchronizers. - // It'll be synchronized with profile information. - - await AccountService.CreateAccountAsync(createdAccount, customServerInformation); - - // Local account has been created. - - // Sync profile information if supported. - if (createdAccount.IsProfileInfoSyncSupported) - { - // Start profile information synchronization. - // It's only available for Outlook and Gmail synchronizers. - - var profileSynchronizationResult = await SynchronizationManager.Instance.SynchronizeProfileAsync(createdAccount.Id); - - if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); - - if (profileSynchronizationResult.ProfileInformation != null) - { - createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName; - createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData; - - if (!string.IsNullOrEmpty(profileSynchronizationResult.ProfileInformation.AccountAddress)) - { - createdAccount.Address = profileSynchronizationResult.ProfileInformation.AccountAddress; - } - - await AccountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation); - } - } - - if (creationDialog != null) - await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.PreparingFolders); - - var folderSynchronizationResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(createdAccount.Id); - - if (folderSynchronizationResult == null || folderSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - - if (createdAccount.IsCalendarAccessGranted) - { - if (creationDialog != null) - await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.CalendarMetadataFetch); - - var calendarMetadataSynchronizationResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions - { - AccountId = createdAccount.Id, - Type = CalendarSynchronizationType.CalendarMetadata - }); - - if (calendarMetadataSynchronizationResult == null || calendarMetadataSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata); - } - - // Sync aliases if supported. - if (createdAccount.IsAliasSyncSupported) - { - // Try to synchronize aliases for the account. - var aliasSynchronizationResult = await SynchronizationManager.Instance.SynchronizeAliasesAsync(createdAccount.Id); - - if (aliasSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) - throw new Exception(Translator.Exception_FailedToSynchronizeAliases); - } - else - { - // Create root primary alias for the account. - // This is only available for accounts that do not support alias synchronization. - - await AccountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address); - } - - if (creationDialog != null) - { - await ExecuteUIThread(() => creationDialog.Complete(false)); - creationDialogClosed = true; - } - - // Send changes to listeners. - await ExecuteUIThread(() => ReportUIChange(new AccountCreatedMessage(createdAccount))); - - // Notify success. - await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreatedTitle, string.Format(Translator.Info_AccountCreatedMessage, createdAccount.Address), InfoBarMessageType.Success)); - } - } - catch (Exception ex) when (ex.Message.Contains(nameof(GmailServiceDisabledException))) - { - // For Google Workspace accounts, Gmail API might be disabled by the admin. - // Wino can't continue synchronization in this case. - // We must notify the user about this and prevent account creation. - - await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.GmailServiceDisabled_Title, Translator.GmailServiceDisabled_Message, InfoBarMessageType.Error)); - - if (createdAccount != null) - { - await AccountService.DeleteAccountAsync(createdAccount); - } - } - catch (AccountSetupCanceledException) - { - // Ignore - } - catch (Exception ex) when (ex.Message.Contains(nameof(AccountSetupCanceledException))) - { - // Ignore - } - catch (ImapClientPoolException testClientPoolException) when (testClientPoolException.CustomServerInformation != null) - { - var properties = testClientPoolException.CustomServerInformation.GetConnectionProperties(); - - properties.Add("ProtocolLog", testClientPoolException.ProtocolLog); - properties.Add("DiagnosticId", PreferencesService.DiagnosticId); - - _winoLogger.TrackEvent("IMAP Test Failed", properties); - - await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, testClientPoolException.Message, InfoBarMessageType.Error)); - } - catch (ImapClientPoolException clientPoolException) when (clientPoolException.InnerException != null) - { - await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, clientPoolException.InnerException.Message, InfoBarMessageType.Error)); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to create account."); - - await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, ex.Message, InfoBarMessageType.Error)); - - // Delete account in case of failure. - if (createdAccount != null) - { - await AccountService.DeleteAccountAsync(createdAccount); - } - } - finally - { - if (creationDialog != null && !creationDialogClosed) - { - bool isCanceled = false; - await ExecuteUIThread(() => isCanceled = creationDialog.State == AccountCreationDialogState.Canceled); - await ExecuteUIThread(() => creationDialog.Complete(isCanceled)); - } - } + Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage)); } public Task StartAddNewAccountAsync() => AddNewAccountAsync(); diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs new file mode 100644 index 00000000..85423bab --- /dev/null +++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Serilog; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Exceptions; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Services; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Navigation; +using Wino.Messaging.UI; + +namespace Wino.Mail.ViewModels; + +public partial class AccountSetupProgressPageViewModel : MailBaseViewModel +{ + private readonly IAccountService _accountService; + private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; + private readonly ICalDavClient _calDavClient; + private readonly IMailDialogService _dialogService; + + public WelcomeWizardContext WizardContext { get; } + + public ObservableCollection Steps { get; } = []; + + [ObservableProperty] + public partial bool IsSetupComplete { get; set; } + + [ObservableProperty] + public partial bool IsSetupFailed { get; set; } + + [ObservableProperty] + public partial string FailureMessage { get; set; } + + private MailAccount _createdAccount; + private bool _dbWritten; + + public AccountSetupProgressPageViewModel( + IAccountService accountService, + ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, + ICalDavClient calDavClient, + IMailDialogService dialogService, + WelcomeWizardContext wizardContext) + { + _accountService = accountService; + _specialImapProviderConfigResolver = specialImapProviderConfigResolver; + _calDavClient = calDavClient; + _dialogService = dialogService; + WizardContext = wizardContext; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + // Only run on fresh navigation, not on back-navigation + if (mode == NavigationMode.Back) return; + + await RunSetupAsync(); + } + + private void BuildSteps() + { + Steps.Clear(); + + if (WizardContext.IsOAuthProvider) + { + Steps.Add(new AccountSetupStepModel + { + Title = string.Format(Translator.AccountSetup_Step_Authenticating, WizardContext.SelectedProvider.Name) + }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); + } + else if (WizardContext.IsSpecialImapProvider) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingMailAuth }); + + if (WizardContext.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_DiscoveringCalDav }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingCalendarAuth }); + } + + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + + if (WizardContext.CalendarSupportMode != ImapCalendarSupportMode.Disabled) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); + } + + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); + } + else // Generic IMAP + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + + var setupResult = WizardContext.ImapCalDavSetupResult; + if (setupResult?.IsCalendarAccessGranted == true && + setupResult.ServerInformation?.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_DiscoveringCalDav }); + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_TestingCalendarAuth }); + } + + if (setupResult?.IsCalendarAccessGranted == true) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); + } + + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); + } + } + + private int _currentStepIndex; + + private void SetStepInProgress(string title) + { + for (int i = 0; i < Steps.Count; i++) + { + if (Steps[i].Title == title) + { + _currentStepIndex = i; + Steps[i].Status = AccountSetupStepStatus.InProgress; + return; + } + } + } + + private void SetCurrentStepSucceeded() + { + if (_currentStepIndex < Steps.Count) + Steps[_currentStepIndex].Status = AccountSetupStepStatus.Succeeded; + } + + private void SetCurrentStepFailed(string errorMessage) + { + if (_currentStepIndex < Steps.Count) + { + Steps[_currentStepIndex].Status = AccountSetupStepStatus.Failed; + Steps[_currentStepIndex].ErrorMessage = errorMessage; + } + } + + private async Task RunSetupAsync() + { + IsSetupComplete = false; + IsSetupFailed = false; + FailureMessage = null; + _dbWritten = false; + _createdAccount = null; + + BuildSteps(); + + try + { + CustomServerInformation customServerInformation = null; + + // Build account in memory + _createdAccount = new MailAccount + { + Id = Guid.NewGuid(), + ProviderType = WizardContext.SelectedProvider.Type, + Name = WizardContext.AccountName, + SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider, + AccountColorHex = WizardContext.AccountColorHex, + IsCalendarAccessGranted = true + }; + + if (WizardContext.IsOAuthProvider) + { + // Step: Authenticating + SetStepInProgress(string.Format(Translator.AccountSetup_Step_Authenticating, WizardContext.SelectedProvider.Name)); + + var authTokenInfo = await SynchronizationManager.Instance.HandleAuthorizationAsync( + WizardContext.SelectedProvider.Type, + _createdAccount, + _createdAccount.ProviderType == MailProviderType.Gmail); + + _createdAccount.Address = authTokenInfo.AccountAddress; + SetCurrentStepSucceeded(); + + // Step: Save to DB + SetStepInProgress(Translator.AccountSetup_Step_SavingAccount); + await _accountService.CreateAccountAsync(_createdAccount, null); + _dbWritten = true; + SetCurrentStepSucceeded(); + + // Step: Profile + SetStepInProgress(Translator.AccountSetup_Step_FetchingProfile); + var profileResult = await SynchronizationManager.Instance.SynchronizeProfileAsync(_createdAccount.Id); + if (profileResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); + + if (profileResult.ProfileInformation != null) + { + _createdAccount.SenderName = profileResult.ProfileInformation.SenderName; + _createdAccount.Base64ProfilePictureData = profileResult.ProfileInformation.Base64ProfilePictureData; + + if (!string.IsNullOrEmpty(profileResult.ProfileInformation.AccountAddress)) + _createdAccount.Address = profileResult.ProfileInformation.AccountAddress; + + await _accountService.UpdateProfileInformationAsync(_createdAccount.Id, profileResult.ProfileInformation); + } + SetCurrentStepSucceeded(); + + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + + // Step: Calendar metadata + SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); + if (_createdAccount.IsCalendarAccessGranted) + { + var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions + { + AccountId = _createdAccount.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }); + if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata); + } + SetCurrentStepSucceeded(); + + // Step: Aliases + SetStepInProgress(Translator.AccountSetup_Step_SyncingAliases); + if (_createdAccount.IsAliasSyncSupported) + { + var aliasResult = await SynchronizationManager.Instance.SynchronizeAliasesAsync(_createdAccount.Id); + if (aliasResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeAliases); + } + else + { + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } + SetCurrentStepSucceeded(); + } + else if (WizardContext.IsSpecialImapProvider) + { + var dialogResult = WizardContext.BuildAccountCreationDialogResult(); + + customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(_createdAccount, dialogResult); + if (customServerInformation == null) throw new Exception("Failed to resolve server information."); + + customServerInformation.Id = Guid.NewGuid(); + customServerInformation.AccountId = _createdAccount.Id; + + _createdAccount.Address = WizardContext.EmailAddress; + _createdAccount.SenderName = WizardContext.DisplayName; + _createdAccount.IsCalendarAccessGranted = customServerInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled; + _createdAccount.ServerInformation = customServerInformation; + + // Step: Test IMAP + SetStepInProgress(Translator.AccountSetup_Step_TestingMailAuth); + await ValidateImapConnectivityAsync(customServerInformation); + SetCurrentStepSucceeded(); + + // Step: CalDAV discovery and testing (if applicable) + if (customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + SetStepInProgress(Translator.AccountSetup_Step_DiscoveringCalDav); + SetCurrentStepSucceeded(); + + SetStepInProgress(Translator.AccountSetup_Step_TestingCalendarAuth); + await ValidateCalDavConnectivityAsync(customServerInformation); + SetCurrentStepSucceeded(); + } + + // Step: Save to DB + SetStepInProgress(Translator.AccountSetup_Step_SavingAccount); + await _accountService.CreateAccountAsync(_createdAccount, customServerInformation); + _dbWritten = true; + SetCurrentStepSucceeded(); + + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + + // Step: Calendar metadata (if not disabled) + if (_createdAccount.IsCalendarAccessGranted) + { + SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); + var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions + { + AccountId = _createdAccount.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }); + if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata); + SetCurrentStepSucceeded(); + } + + // Aliases for IMAP + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } + else // Generic IMAP + { + var setupResult = WizardContext.ImapCalDavSetupResult + ?? throw new Exception("IMAP setup was not completed."); + + customServerInformation = setupResult.ServerInformation + ?? throw new Exception("Server information is missing."); + + customServerInformation.Id = Guid.NewGuid(); + customServerInformation.AccountId = _createdAccount.Id; + + _createdAccount.Address = setupResult.EmailAddress; + _createdAccount.SenderName = setupResult.DisplayName; + _createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; + _createdAccount.ServerInformation = customServerInformation; + + // Step: Save to DB + SetStepInProgress(Translator.AccountSetup_Step_SavingAccount); + await _accountService.CreateAccountAsync(_createdAccount, customServerInformation); + _dbWritten = true; + SetCurrentStepSucceeded(); + + // Step: Folders + SetStepInProgress(Translator.AccountSetup_Step_SyncingFolders); + var folderResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(_createdAccount.Id); + if (folderResult == null || folderResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeFolders); + SetCurrentStepSucceeded(); + + // Step: CalDAV (if applicable) + if (setupResult.IsCalendarAccessGranted && + customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + SetStepInProgress(Translator.AccountSetup_Step_DiscoveringCalDav); + SetCurrentStepSucceeded(); + + SetStepInProgress(Translator.AccountSetup_Step_TestingCalendarAuth); + await ValidateCalDavConnectivityAsync(customServerInformation); + SetCurrentStepSucceeded(); + } + + // Step: Calendar metadata + if (setupResult.IsCalendarAccessGranted) + { + SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); + var calResult = await SynchronizationManager.Instance.SynchronizeCalendarAsync(new CalendarSynchronizationOptions + { + AccountId = _createdAccount.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }); + if (calResult == null || calResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeCalendarMetadata); + SetCurrentStepSucceeded(); + } + + // Aliases for IMAP + await _accountService.CreateRootAliasAsync(_createdAccount.Id, _createdAccount.Address); + } + + // Step: Finalizing + SetStepInProgress(Translator.AccountSetup_Step_Finalizing); + SetCurrentStepSucceeded(); + + IsSetupComplete = true; + + // Notify listeners — this triggers ShellWindow creation from App.xaml.cs + Messenger.Send(new AccountCreatedMessage(_createdAccount)); + } + catch (AccountSetupCanceledException) + { + // User canceled authentication — go back silently, no error UI + Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft)); + } + catch (Exception ex) when (ex.Message.Contains(nameof(AccountSetupCanceledException))) + { + // Wrapped cancellation — same silent behavior + Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft)); + } + catch (Exception ex) + { + Log.Error(ex, "Account setup failed."); + + SetCurrentStepFailed(ex.Message); + IsSetupFailed = true; + FailureMessage = Translator.AccountSetup_FailureMessage; + + // Rollback if DB write happened + if (_dbWritten && _createdAccount != null) + { + try + { + await _accountService.DeleteAccountAsync(_createdAccount); + } + catch (Exception deleteEx) + { + Log.Error(deleteEx, "Failed to rollback account creation."); + } + + _dbWritten = false; + } + } + } + + private async Task ValidateImapConnectivityAsync(CustomServerInformation serverInformation) + { + var connectivityResult = await SynchronizationManager.Instance + .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: false); + + if (connectivityResult.IsCertificateUIRequired) + { + var certificateMessage = + $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}\n\n" + + $"{Translator.IMAPSetupDialog_CertificateIssuer}: {connectivityResult.CertificateIssuer}\n" + + $"{Translator.IMAPSetupDialog_CertificateValidFrom}: {connectivityResult.CertificateValidFromDateString}\n" + + $"{Translator.IMAPSetupDialog_CertificateValidTo}: {connectivityResult.CertificateExpirationDateString}\n\n" + + $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}"; + + var allowCertificate = await _dialogService.ShowConfirmationDialogAsync( + certificateMessage, + Translator.GeneralTitle_Warning, + Translator.Buttons_Allow); + + if (!allowCertificate) + throw new InvalidOperationException(Translator.IMAPSetupDialog_CertificateDenied); + + connectivityResult = await SynchronizationManager.Instance + .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: true); + } + + if (!connectivityResult.IsSuccess) + throw new InvalidOperationException(connectivityResult.FailedReason ?? Translator.IMAPSetupDialog_ConnectionFailedMessage); + } + + private async Task ValidateCalDavConnectivityAsync(CustomServerInformation serverInformation) + { + if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired); + + var settings = new CalDavConnectionSettings + { + ServiceUri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute), + Username = serverInformation.CalDavUsername, + Password = serverInformation.CalDavPassword + }; + + await _calDavClient.DiscoverCalendarsAsync(settings); + } + + [RelayCommand] + private void GoBack() + { + Messenger.Send(new BackBreadcrumNavigationRequested(NavigationTransitionEffect.FromLeft)); + } + + [RelayCommand] + private async Task TryAgainAsync() + { + await RunSetupAsync(); + } +} diff --git a/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs index b5a1372d..8a1bce39 100644 --- a/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs +++ b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs @@ -9,7 +9,8 @@ namespace Wino.Mail.ViewModels.Data; public enum ImapCalDavSettingsPageMode { Create, - Edit + Edit, + Wizard } public sealed class ImapCalDavSettingsNavigationContext @@ -35,6 +36,16 @@ public sealed class ImapCalDavSettingsNavigationContext Mode = ImapCalDavSettingsPageMode.Edit, AccountId = accountId }; + + public static ImapCalDavSettingsNavigationContext CreateForWizardMode( + AccountCreationDialogResult accountCreationDialogResult) + => new() + { + Mode = ImapCalDavSettingsPageMode.Wizard, + AccountCreationDialogResult = accountCreationDialogResult + }; + + public bool IsWizardMode => Mode == ImapCalDavSettingsPageMode.Wizard; } public sealed class ImapCalDavSetupResult diff --git a/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs new file mode 100644 index 00000000..fe38bd23 --- /dev/null +++ b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs @@ -0,0 +1,79 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Mail.ViewModels.Data; + +public partial class WelcomeWizardContext : ObservableObject +{ + // Step 2 — Provider selection + [ObservableProperty] + public partial IProviderDetail SelectedProvider { get; set; } + + [ObservableProperty] + public partial string AccountName { get; set; } + + [ObservableProperty] + public partial string AccountColorHex { get; set; } + + // Special IMAP fields (iCloud/Yahoo) + [ObservableProperty] + public partial string DisplayName { get; set; } + + [ObservableProperty] + public partial string EmailAddress { get; set; } + + [ObservableProperty] + public partial string AppSpecificPassword { get; set; } + + [ObservableProperty] + public partial ImapCalendarSupportMode CalendarSupportMode { get; set; } = ImapCalendarSupportMode.Disabled; + + // Generic IMAP — populated by ImapCalDavSettingsPage + public ImapCalDavSetupResult ImapCalDavSetupResult { get; set; } + + // Computed helpers + public bool IsOAuthProvider => SelectedProvider?.Type is MailProviderType.Outlook or MailProviderType.Gmail; + + public bool IsSpecialImapProvider => + SelectedProvider?.SpecialImapProvider is SpecialImapProvider.iCloud or SpecialImapProvider.Yahoo; + + public bool IsGenericImap => + SelectedProvider?.Type == MailProviderType.IMAP4 + && SelectedProvider?.SpecialImapProvider == SpecialImapProvider.None; + + public SpecialImapProviderDetails BuildSpecialImapProviderDetails() + { + if (!IsSpecialImapProvider) return null; + + return new SpecialImapProviderDetails( + EmailAddress, + AppSpecificPassword, + DisplayName, + SelectedProvider.SpecialImapProvider, + CalendarSupportMode); + } + + public AccountCreationDialogResult BuildAccountCreationDialogResult() + { + return new AccountCreationDialogResult( + SelectedProvider.Type, + AccountName, + BuildSpecialImapProviderDetails(), + AccountColorHex); + } + + public void Reset() + { + SelectedProvider = null; + AccountName = null; + AccountColorHex = null; + DisplayName = null; + EmailAddress = null; + AppSpecificPassword = null; + CalendarSupportMode = ImapCalendarSupportMode.Disabled; + ImapCalDavSetupResult = null; + } +} diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs index 97d52026..f1409956 100644 --- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -25,6 +25,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private readonly ICalDavClient _calDavClient; private readonly IAccountService _accountService; private readonly IMailDialogService _mailDialogService; + private readonly WelcomeWizardContext _wizardContext; private ImapCalDavSettingsPageMode _pageMode; private Guid _editingAccountId; @@ -256,12 +257,14 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel public ImapCalDavSettingsPageViewModel(IAutoDiscoveryService autoDiscoveryService, ICalDavClient calDavClient, IAccountService accountService, - IMailDialogService mailDialogService) + IMailDialogService mailDialogService, + WelcomeWizardContext wizardContext) { _autoDiscoveryService = autoDiscoveryService; _calDavClient = calDavClient; _accountService = accountService; _mailDialogService = mailDialogService; + _wizardContext = wizardContext; } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -278,7 +281,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel _localOnlyInfoShown = false; SelectedSetupTabIndex = 0; - if (_pageMode == ImapCalDavSettingsPageMode.Create) + if (_pageMode == ImapCalDavSettingsPageMode.Create || _pageMode == ImapCalDavSettingsPageMode.Wizard) { PageTitle = Translator.ImapCalDavSettingsPage_TitleCreate; ApplyCreateContextDefaults(context.AccountCreationDialogResult); @@ -301,6 +304,8 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel base.OnNavigatedFrom(mode, parameters); } + public bool IsWizardMode => _pageMode == ImapCalDavSettingsPageMode.Wizard; + [RelayCommand] private async Task AutoDiscoverSettingsAsync() { @@ -407,6 +412,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel IsCalDavValidationSucceeded = false; } + if (_pageMode == ImapCalDavSettingsPageMode.Wizard) + { + CompleteWizardFlow(serverInformation); + return; + } + if (_pageMode == ImapCalDavSettingsPageMode.Create) { CompleteCreateFlow(serverInformation); @@ -436,6 +447,22 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel Messenger.Send(new BackBreadcrumNavigationRequested()); } + private void CompleteWizardFlow(CustomServerInformation serverInformation) + { + serverInformation.Id = Guid.NewGuid(); + serverInformation.AccountId = Guid.Empty; + + _wizardContext.ImapCalDavSetupResult = new ImapCalDavSetupResult + { + DisplayName = DisplayName.Trim(), + EmailAddress = EmailAddress.Trim(), + IsCalendarAccessGranted = serverInformation.CalendarSupportMode != ImapCalendarSupportMode.Disabled, + ServerInformation = serverInformation + }; + + Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step3Title, WinoPage.AccountSetupProgressPage)); + } + [RelayCommand] private Task ShowLocalCalendarExplanationAsync() => _mailDialogService.ShowMessageAsync( diff --git a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs new file mode 100644 index 00000000..c5480a68 --- /dev/null +++ b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.ViewModels.Data; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Navigation; + +namespace Wino.Mail.ViewModels; + +public partial class ProviderSelectionPageViewModel : MailBaseViewModel +{ + private readonly IProviderService _providerService; + private readonly INewThemeService _themeService; + + public WelcomeWizardContext WizardContext { get; } + + public List Providers { get; private set; } = []; + public List AvailableColors { get; private set; } = []; + + [ObservableProperty] + public partial IProviderDetail SelectedProvider { get; set; } + + [ObservableProperty] + public partial AppColorViewModel SelectedColor { get; set; } + + [ObservableProperty] + public partial string AccountName { get; set; } + + [ObservableProperty] + public partial bool CanProceed { get; set; } + + public bool IsColorSelected => SelectedColor != null; + + public ProviderSelectionPageViewModel( + IProviderService providerService, + INewThemeService themeService, + WelcomeWizardContext wizardContext) + { + _providerService = providerService; + _themeService = themeService; + WizardContext = wizardContext; + } + + public override void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + Providers = _providerService.GetAvailableProviders(); + AvailableColors = _themeService.GetAvailableAccountColors() + .Select(hex => new AppColorViewModel(hex)) + .ToList(); + + // Restore from wizard context if navigating back + if (WizardContext.SelectedProvider != null) + { + SelectedProvider = Providers.FirstOrDefault(p => + p.Type == WizardContext.SelectedProvider.Type && + p.SpecialImapProvider == WizardContext.SelectedProvider.SpecialImapProvider); + AccountName = WizardContext.AccountName; + + if (WizardContext.AccountColorHex != null) + SelectedColor = AvailableColors.FirstOrDefault(c => c.Hex == WizardContext.AccountColorHex); + } + + Validate(); + } + + partial void OnSelectedProviderChanged(IProviderDetail value) => Validate(); + partial void OnAccountNameChanged(string value) => Validate(); + partial void OnSelectedColorChanged(AppColorViewModel value) => OnPropertyChanged(nameof(IsColorSelected)); + + [RelayCommand] + private void ClearColor() => SelectedColor = null; + + private void Validate() + { + CanProceed = SelectedProvider != null && !string.IsNullOrWhiteSpace(AccountName); + } + + [RelayCommand] + private void Proceed() + { + if (!CanProceed) return; + + // Persist to wizard context + WizardContext.SelectedProvider = SelectedProvider; + WizardContext.AccountName = AccountName?.Trim(); + WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty; + + if (WizardContext.IsGenericImap) + { + // Navigate to ImapCalDavSettingsPage in wizard mode + var context = ImapCalDavSettingsNavigationContext.CreateForWizardMode( + WizardContext.BuildAccountCreationDialogResult()); + + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.ImapCalDavSettingsPage_TitleCreate, + WinoPage.ImapCalDavSettingsPage, + context)); + } + else if (SelectedProvider.SpecialImapProvider is SpecialImapProvider.iCloud or SpecialImapProvider.Yahoo) + { + // Navigate to credentials page for special IMAP providers + Messenger.Send(new BreadcrumbNavigationRequested( + SelectedProvider.Name, + WinoPage.SpecialImapCredentialsPage)); + } + else + { + // OAuth — go directly to progress page + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.WelcomeWizard_Step3Title, + WinoPage.AccountSetupProgressPage)); + } + } +} diff --git a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs new file mode 100644 index 00000000..9ba42445 --- /dev/null +++ b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Navigation; + +namespace Wino.Mail.ViewModels; + +public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel +{ + private static readonly Dictionary AppPasswordHelpLinks = new() + { + { SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" }, + { SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" }, + }; + + private readonly INativeAppService _nativeAppService; + + public WelcomeWizardContext WizardContext { get; } + + [ObservableProperty] + public partial string DisplayName { get; set; } + + [ObservableProperty] + public partial string EmailAddress { get; set; } + + [ObservableProperty] + public partial string AppSpecificPassword { get; set; } + + [ObservableProperty] + public partial int SelectedCalendarModeIndex { get; set; } + + [ObservableProperty] + public partial bool CanProceed { get; set; } + + public string AppPasswordHelpUrl + { + get + { + if (WizardContext.SelectedProvider == null) return null; + AppPasswordHelpLinks.TryGetValue(WizardContext.SelectedProvider.SpecialImapProvider, out var url); + return url; + } + } + + public string CalendarModeCalDavDescription + => WizardContext.SelectedProvider?.SpecialImapProvider == SpecialImapProvider.iCloud + ? Translator.ProviderSelection_CalendarMode_CalDavDescription_Apple + : Translator.ProviderSelection_CalendarMode_CalDavDescription_Yahoo; + + public SpecialImapCredentialsPageViewModel( + INativeAppService nativeAppService, + WelcomeWizardContext wizardContext) + { + _nativeAppService = nativeAppService; + WizardContext = wizardContext; + } + + public override void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + // Restore from context when navigating back + DisplayName = WizardContext.DisplayName; + EmailAddress = WizardContext.EmailAddress; + AppSpecificPassword = WizardContext.AppSpecificPassword; + + SelectedCalendarModeIndex = WizardContext.CalendarSupportMode switch + { + ImapCalendarSupportMode.CalDav => 1, + ImapCalendarSupportMode.LocalOnly => 2, + _ => 0 + }; + + OnPropertyChanged(nameof(AppPasswordHelpUrl)); + OnPropertyChanged(nameof(CalendarModeCalDavDescription)); + + Validate(); + } + + partial void OnDisplayNameChanged(string value) => Validate(); + partial void OnEmailAddressChanged(string value) => Validate(); + partial void OnAppSpecificPasswordChanged(string value) => Validate(); + + private void Validate() + { + CanProceed = !string.IsNullOrWhiteSpace(DisplayName) + && !string.IsNullOrWhiteSpace(EmailAddress) + && EmailValidation.EmailValidator.Validate(EmailAddress ?? string.Empty) + && !string.IsNullOrWhiteSpace(AppSpecificPassword); + } + + [RelayCommand] + private void Proceed() + { + if (!CanProceed) return; + + WizardContext.DisplayName = DisplayName?.Trim(); + WizardContext.EmailAddress = EmailAddress?.Trim(); + WizardContext.AppSpecificPassword = AppSpecificPassword?.Trim(); + WizardContext.CalendarSupportMode = SelectedCalendarModeIndex switch + { + 1 => ImapCalendarSupportMode.CalDav, + 2 => ImapCalendarSupportMode.LocalOnly, + _ => ImapCalendarSupportMode.Disabled + }; + + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.WelcomeWizard_Step3Title, + WinoPage.AccountSetupProgressPage)); + } + + [RelayCommand] + private async Task OpenAppPasswordHelp() + { + var url = AppPasswordHelpUrl; + if (url != null) + await _nativeAppService.LaunchUriAsync(new Uri(url)); + } +} diff --git a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs index 3e086a92..cb190e8b 100644 --- a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs +++ b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Updates; @@ -40,6 +42,8 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel [RelayCommand] private void GetStarted() { - Messenger.Send(new GetStartedFromWelcomeRequested()); + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.WelcomeWizard_Step2Title, + WinoPage.ProviderSelectionPage)); } } diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 526c0b13..1da0a846 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -23,9 +23,9 @@ using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; -using Wino.Core.Domain.Models.Updates; using Wino.Mail.Services; using Wino.Mail.ViewModels; +using Wino.Mail.ViewModels.Data; using Wino.Mail.WinUI.Activation; using Wino.Mail.WinUI.Interfaces; using Wino.Mail.WinUI.Models; @@ -42,6 +42,7 @@ public partial class App : WinoApplication, IRecipient, IRecipient, IRecipient, + IRecipient, IRecipient { private const int InboxSyncsPerFullSync = 20; @@ -146,6 +147,10 @@ public partial class App : WinoApplication, services.AddTransient(typeof(AccountManagementViewModel)); services.AddTransient(typeof(WelcomePageViewModel)); services.AddTransient(typeof(WelcomePageV2ViewModel)); + services.AddTransient(typeof(ProviderSelectionPageViewModel)); + services.AddTransient(typeof(AccountSetupProgressPageViewModel)); + services.AddTransient(typeof(SpecialImapCredentialsPageViewModel)); + services.AddSingleton(typeof(WelcomeWizardContext)); services.AddTransient(typeof(ComposePageViewModel)); services.AddTransient(typeof(IdlePageViewModel)); @@ -218,18 +223,10 @@ public partial class App : WinoApplication, return; } - // Check whether the new version requires a migration before starting sync. - var updateManager = Services.GetRequiredService(); - var updateNotes = await updateManager.GetLatestUpdateNotesAsync(); - bool hasPendingMigration = updateNotes.HasPendingMigrations && updateManager.HasPendingMigrations(); - _preferencesService.PreferenceChanged -= PreferencesServiceChanged; _preferencesService.PreferenceChanged += PreferencesServiceChanged; - // Hold off sync loop when a migration is required in startup-task (tray-only) mode. - // In foreground mode the sync loop starts normally; the ViewModel dialog handles migrations before sync kicks in. - if (!hasPendingMigration || !IsStartupTaskLaunch()) - RestartAutoSynchronizationLoop(); + RestartAutoSynchronizationLoop(); // Check if launched from toast notification. if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs)) @@ -253,16 +250,7 @@ public partial class App : WinoApplication, // Otherwise, activate the window normally. if (isStartupTaskLaunch) { - if (hasPendingMigration) - { - // Notify the user to open the app to complete the update before sync can resume. - Services.GetRequiredService().CreateMigrationRequiredNotification(); - LogActivation("Migration required for new version. Sync skipped. User notified via toast."); - } - else - { - LogActivation("Launched by startup task. Window created but hidden (system tray only)."); - } + LogActivation("Launched by startup task. Window created but hidden (system tray only)."); } else { @@ -305,13 +293,6 @@ public partial class App : WinoApplication, { var toastArguments = ToastArguments.Parse(toastArgs.Argument); - // Check migration notification activation first. - if (toastArguments.Contains(Constants.ToastMigrationRequiredKey)) - { - await HandleMigrationToastActivationAsync(); - return; - } - // Check calendar reminder toast activation first. if (toastArguments.TryGetValue(Constants.ToastCalendarActionKey, out string calendarAction) && calendarAction == Constants.ToastCalendarNavigateAction && @@ -341,29 +322,6 @@ public partial class App : WinoApplication, } } - /// - /// Handles activation from the migration-required toast notification. - /// Opens the app so the shell ViewModel can show the What's New dialog and run migrations. - /// - private async Task HandleMigrationToastActivationAsync() - { - LogActivation("Handling migration toast activation."); - - if (!IsAppRunning()) - { - await CreateAndActivateWindow(null!); - } - else - { - EnsureMainWindowVisibleAndForeground(); - } - - // The MailAppShellViewModel.OnNavigatedTo will detect ShouldShowUpdateNotes() == true - // and display the What's New dialog (including running migrations) once the XamlRoot is ready. - // Restart sync in case it was blocked. - RestartAutoSynchronizationLoop(); - } - private async Task HandleCalendarToastNavigationAsync(Guid calendarItemId) { var calendarService = Services.GetRequiredService(); @@ -593,7 +551,7 @@ public partial class App : WinoApplication, InitializeNavigationDispatcher(); Services.GetRequiredService() - .Navigate(WinoPage.WelcomePageV2, null, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); + .Navigate(WinoPage.WelcomeHostPage, null, NavigationReferenceFrame.ShellFrame, NavigationTransitionType.None); } private void InitializeNavigationDispatcher() @@ -627,6 +585,7 @@ public partial class App : WinoApplication, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); } @@ -699,6 +658,29 @@ public partial class App : WinoApplication, }); } + public void Receive(AccountRemovedMessage message) + { + var windowManager = Services.GetRequiredService(); + + // Only handle when ShellWindow is active (not during wizard rollback) + if (windowManager.GetWindow(WinoWindowKind.Shell) == null) + return; + + MainWindow?.DispatcherQueue?.TryEnqueue(async () => + { + var accounts = await _accountService!.GetAccountsAsync(); + if (accounts.Any()) return; + + // All accounts removed — go back to welcome wizard from step 1 + Services.GetRequiredService().Reset(); + StopAutoSynchronizationLoop(); + CreateWelcomeWindow(); + windowManager.HideWindow(WinoWindowKind.Shell); + await NewThemeService.ApplyThemeToActiveWindowAsync(); + MainWindow?.Activate(); + }); + } + public void Receive(GetStartedFromWelcomeRequested message) { var windowManager = Services.GetRequiredService(); diff --git a/Wino.Mail.WinUI/AppThemes/Clouds.xaml b/Wino.Mail.WinUI/AppThemes/Clouds.xaml index d0af4a96..ee46664a 100644 --- a/Wino.Mail.WinUI/AppThemes/Clouds.xaml +++ b/Wino.Mail.WinUI/AppThemes/Clouds.xaml @@ -4,7 +4,7 @@ xmlns:xaml="using:Microsoft.UI.Xaml"> Clouds - ms-appx:///Wino.Mail.WinUI/BackgroundImages/Clouds.jpg + ms-appx:///BackgroundImages/Clouds.jpg Transparent diff --git a/Wino.Mail.WinUI/AppThemes/Forest.xaml b/Wino.Mail.WinUI/AppThemes/Forest.xaml index 265a0843..8c8a26ba 100644 --- a/Wino.Mail.WinUI/AppThemes/Forest.xaml +++ b/Wino.Mail.WinUI/AppThemes/Forest.xaml @@ -4,7 +4,7 @@ xmlns:xaml="using:Microsoft.UI.Xaml"> Forest - ms-appx:///Wino.Mail.WinUI/BackgroundImages/Forest.jpg + ms-appx:///BackgroundImages/Forest.jpg Transparent diff --git a/Wino.Mail.WinUI/AppThemes/Garden.xaml b/Wino.Mail.WinUI/AppThemes/Garden.xaml index 830f43de..3dbe1256 100644 --- a/Wino.Mail.WinUI/AppThemes/Garden.xaml +++ b/Wino.Mail.WinUI/AppThemes/Garden.xaml @@ -4,7 +4,7 @@ xmlns:xaml="using:Microsoft.UI.Xaml"> Garden - ms-appx:///Wino.Mail.WinUI/BackgroundImages/Garden.jpg + ms-appx:///BackgroundImages/Garden.jpg Transparent diff --git a/Wino.Mail.WinUI/AppThemes/Nighty.xaml b/Wino.Mail.WinUI/AppThemes/Nighty.xaml index 382f0b04..b611d0c6 100644 --- a/Wino.Mail.WinUI/AppThemes/Nighty.xaml +++ b/Wino.Mail.WinUI/AppThemes/Nighty.xaml @@ -4,7 +4,7 @@ xmlns:xaml="using:Microsoft.UI.Xaml"> Nighty - ms-appx:///Wino.Mail.WinUI/BackgroundImages/Nighty.jpg + ms-appx:///BackgroundImages/Nighty.jpg Transparent diff --git a/Wino.Mail.WinUI/AppThemes/Snowflake.xaml b/Wino.Mail.WinUI/AppThemes/Snowflake.xaml index 9e857f3e..34137686 100644 --- a/Wino.Mail.WinUI/AppThemes/Snowflake.xaml +++ b/Wino.Mail.WinUI/AppThemes/Snowflake.xaml @@ -4,7 +4,7 @@ xmlns:xaml="using:Microsoft.UI.Xaml"> Snowflake - ms-appx:///Wino.Mail.WinUI/BackgroundImages/Snowflake.jpg + ms-appx:///BackgroundImages/Snowflake.jpg Transparent diff --git a/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json b/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json index a1509ff4..f0c08b09 100644 --- a/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json +++ b/Wino.Mail.WinUI/Assets/UpdateNotes/vnext.json @@ -1,9 +1,4 @@ { - "hasPendingMigrations": true, - "migration": { - "titleKey": "WhatIsNew_MigrationPreparing_Title", - "descriptionKey": "WhatIsNew_MigrationPreparing_Description" - }, "sections": [ { "title": "# Wino Calendar is here!", diff --git a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs index 12780859..bf729cab 100644 --- a/Wino.Mail.WinUI/CoreUWPContainerSetup.cs +++ b/Wino.Mail.WinUI/CoreUWPContainerSetup.cs @@ -49,6 +49,7 @@ public static class CoreUWPContainerSetup services.AddTransient(typeof(AboutPageViewModel)); services.AddTransient(typeof(SettingsPageViewModel)); services.AddTransient(typeof(ManageAccountsPagePageViewModel)); + services.AddTransient(typeof(WelcomeHostPageViewModel)); services.AddTransient(typeof(KeyboardShortcutsPageViewModel)); } } diff --git a/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml b/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml index c9dcfffe..56e857dd 100644 --- a/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml @@ -22,40 +22,16 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +