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