Removed migrations. New onboarding screen and wizard like steps.

This commit is contained in:
Burak Kaan Köse
2026-03-06 03:42:08 +01:00
parent db5ecd60e4
commit aaa6e8a2c9
56 changed files with 1843 additions and 554 deletions
@@ -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<ImapCalDavSetupResult>();
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();
@@ -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<AccountSetupStepModel> 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();
}
}
@@ -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
@@ -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;
}
}
@@ -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(
@@ -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<IProviderDetail> Providers { get; private set; } = [];
public List<AppColorViewModel> 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));
}
}
}
@@ -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<SpecialImapProvider, string> 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));
}
}
@@ -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));
}
}