From b0ee5c997426a346c187a48689b063ff3cba220a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 19 Mar 2026 01:50:14 +0100 Subject: [PATCH] Handling of paddle purchases and add-ons. --- AGENTS.md | 1 + .../Enums/WinoAddOnProductType.cs | 7 + .../Interfaces/IStoreManagementService.cs | 6 +- .../Interfaces/IWinoAccountApiClient.cs | 12 +- .../Interfaces/IWinoAccountProfileService.cs | 6 +- .../Interfaces/IWinoAddOnService.cs | 12 + .../Models/Accounts/WinoAccountApiResult.cs | 26 + .../Accounts/WinoAccountOperationResult.cs | 6 +- .../Models/Accounts/WinoAddOnInfo.cs | 12 + .../Models/Store/StoreProductType.cs | 6 - .../Translations/en_US/resources.json | 17 +- .../WinoAccountProfileServiceTests.cs | 39 +- .../AccountManagementPageViewModelBase.cs | 18 +- .../Data/WinoAddOnItemViewModel.cs | 52 ++ .../WinoAccountManagementPageViewModel.cs | 252 ++++----- .../AccountManagementViewModel.cs | 3 +- .../Dialogs/WinoAccountLoginDialog.xaml.cs | 2 +- .../WinoAccountRegistrationDialog.xaml.cs | 2 +- .../Selectors/WinoAddOnTemplateSelector.cs | 30 ++ .../Services/StoreManagementService.cs | 23 +- .../WinoAccountAuthErrorTranslator.cs | 30 ++ .../Settings/WinoAccountManagementPage.xaml | 485 +++++++----------- Wino.Services/ServicesContainerSetup.cs | 1 + Wino.Services/WinoAccountApiClient.cs | 104 +++- Wino.Services/WinoAccountProfileService.cs | 88 +++- Wino.Services/WinoAddOnService.cs | 52 ++ 26 files changed, 779 insertions(+), 513 deletions(-) create mode 100644 Wino.Core.Domain/Enums/WinoAddOnProductType.cs create mode 100644 Wino.Core.Domain/Interfaces/IWinoAddOnService.cs create mode 100644 Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs create mode 100644 Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs delete mode 100644 Wino.Core.Domain/Models/Store/StoreProductType.cs create mode 100644 Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs create mode 100644 Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs create mode 100644 Wino.Services/WinoAddOnService.cs diff --git a/AGENTS.md b/AGENTS.md index 8bbc7c51..9c6e2139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,7 @@ private string searchQuery = string.Empty; - Wrap async operations in try-catch - Log errors via IWinoLogger - For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations. +- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command. - In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`). - ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`. - In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`. diff --git a/Wino.Core.Domain/Enums/WinoAddOnProductType.cs b/Wino.Core.Domain/Enums/WinoAddOnProductType.cs new file mode 100644 index 00000000..e4e08540 --- /dev/null +++ b/Wino.Core.Domain/Enums/WinoAddOnProductType.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Enums; + +public enum WinoAddOnProductType +{ + AI_PACK, + UNLIMITED_ACCOUNTS +} diff --git a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs index ea61f1b7..7f7550cb 100644 --- a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs +++ b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs @@ -1,7 +1,5 @@ using System.Threading.Tasks; using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Models.Store; - namespace Wino.Core.Domain.Interfaces; public interface IStoreManagementService @@ -9,10 +7,10 @@ public interface IStoreManagementService /// /// Checks whether user has the type of an add-on purchased. /// - Task HasProductAsync(StoreProductType productType); + Task HasProductAsync(WinoAddOnProductType productType); /// /// Attempts to purchase the given add-on. /// - Task PurchaseAsync(StoreProductType productType); + Task PurchaseAsync(WinoAddOnProductType productType); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index befc1dc4..07d53f42 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -2,21 +2,25 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Billing; using Wino.Mail.Api.Contracts.Common; namespace Wino.Core.Domain.Interfaces; public interface IWinoAccountApiClient { - Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default); - Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default); - Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); + Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default); + Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default); + Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task> GetAiStatusAsync(CancellationToken cancellationToken = default); - Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default); + Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); + Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task GetSettingsAsync(CancellationToken cancellationToken = default); Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index 6f3f7bee..b95f5f5d 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -2,9 +2,11 @@ using System.Threading; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Billing; using Wino.Mail.Api.Contracts.Common; namespace Wino.Core.Domain.Interfaces; @@ -17,8 +19,10 @@ public interface IWinoAccountProfileService Task GetActiveAccountAsync(); Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task HasActiveAccountAsync(); + Task HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task> GetAiStatusAsync(CancellationToken cancellationToken = default); - Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default); + Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); + Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs new file mode 100644 index 00000000..b2e0fc6c --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Core.Domain.Interfaces; + +public interface IWinoAddOnService +{ + Task> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default); +} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs new file mode 100644 index 00000000..2baafc87 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs @@ -0,0 +1,26 @@ +#nullable enable + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class WinoAccountApiResult +{ + public bool IsSuccess { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + public T? Result { get; init; } + + public static WinoAccountApiResult Success(T result) + => new() + { + IsSuccess = true, + Result = result + }; + + public static WinoAccountApiResult Failure(string? errorCode, string? errorMessage = null) + => new() + { + IsSuccess = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage + }; +} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs index 15763b40..47253403 100644 --- a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs @@ -7,6 +7,7 @@ public sealed class WinoAccountOperationResult { public bool IsSuccess { get; init; } public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } public WinoAccount? Account { get; init; } public static WinoAccountOperationResult Success(WinoAccount account) @@ -16,10 +17,11 @@ public sealed class WinoAccountOperationResult Account = account }; - public static WinoAccountOperationResult Failure(string? errorCode) + public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null) => new() { IsSuccess = false, - ErrorCode = errorCode + ErrorCode = errorCode, + ErrorMessage = errorMessage }; } diff --git a/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs b/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs new file mode 100644 index 00000000..fd553f8a --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs @@ -0,0 +1,12 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed record WinoAddOnInfo( + WinoAddOnProductType ProductType, + bool IsPurchased, + int? UsageCount = null, + int? UsageLimit = null, + double UsagePercentage = 0, + DateTimeOffset? RenewalDateUtc = null); diff --git a/Wino.Core.Domain/Models/Store/StoreProductType.cs b/Wino.Core.Domain/Models/Store/StoreProductType.cs deleted file mode 100644 index f8c620ed..00000000 --- a/Wino.Core.Domain/Models/Store/StoreProductType.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Wino.Core.Domain.Models.Store; - -public enum StoreProductType -{ - UnlimitedAccounts -} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index f7b98981..1265ffc3 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -84,6 +84,7 @@ "Buttons_SyncAliases": "Synchronize Aliases", "Buttons_TryAgain": "Try Again", "Buttons_Yes": "Yes", + "Purchased": "Purchased", "Sync_SynchronizingFolder": "Synchronizing {0} {1}%", "Sync_DownloadedMessages": "Downloaded {0} messages from {1}", "SyncAction_Archiving": "Archiving {0} mail(s)", @@ -860,7 +861,7 @@ "WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail", "WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.", "WinoAccount_Management_ProfileSectionHeader": "Profile", - "WinoAccount_Management_AiPackSectionHeader": "AI Pack", + "WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons", "WinoAccount_Management_DataSectionHeader": "Data", "WinoAccount_Management_AccountActionsSectionHeader": "Account actions", "WinoAccount_Management_AccountCardTitle": "Account", @@ -887,14 +888,10 @@ "WinoAccount_Management_AiPackFeatureTranslate": "Translate", "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", "WinoAccount_Management_AiPackFeatureSummarize": "Summarize", - "WinoAccount_Management_ImportSettingsTitle": "Import settings", - "WinoAccount_Management_ImportSettingsDescription": "Restore your preferences from cloud.", - "WinoAccount_Management_ExportSettingsTitle": "Export settings", - "WinoAccount_Management_ExportSettingsDescription": "Save your preferences to cloud.", + "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences", + "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.", "WinoAccount_Management_SignOutTitle": "Sign out", "WinoAccount_Management_SignOutDescription": "Sign out of your account on this device", - "WinoAccount_Management_SettingsCardTitle": "Settings sync", - "WinoAccount_Management_SettingsCardDescription": "Export your current settings to your Wino Account or import them back on this device.", "WinoAccount_Management_StatusLabel": "Status: {0}", "WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.", "WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.", @@ -905,6 +902,12 @@ "WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.", "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", + "WinoAddOn_AI_PACK_Name": "AI Pack", + "WinoAddOn_AI_PACK_Description": "Translate, rewrite, and summarize emails with Wino AI.", + "WinoAddOn_AI_PACK_Keywords": "Translate · Rewrite · Summarize", + "WinoAddOn_UNLIMITED_ACCOUNTS_Name": "Unlimited Accounts", + "WinoAddOn_UNLIMITED_ACCOUNTS_Description": "Remove the 3-account limit and add as many accounts as you need.", + "WinoAddOn_UNLIMITED_ACCOUNTS_Keywords": "More accounts · No limit · One-time purchase", "SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender", "SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading", "SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security", diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index 9ad47176..c32054b9 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -3,7 +3,9 @@ using System.Threading.Tasks; using FluentAssertions; using Moq; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; using Wino.Services; @@ -15,6 +17,7 @@ namespace Wino.Core.Tests.Services; public class WinoAccountProfileServiceTests : IAsyncLifetime { private readonly Mock _apiClient = new(); + private readonly Mock _storeManagementService = new(); private InMemoryDatabaseService _databaseService = null!; private WinoAccountProfileService _service = null!; @@ -22,7 +25,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime { _databaseService = new InMemoryDatabaseService(); await _databaseService.InitializeAsync(); - _service = new WinoAccountProfileService(_databaseService, _apiClient.Object); + _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object); } public async Task DisposeAsync() @@ -37,7 +40,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) - .ReturnsAsync(ApiEnvelope.Success(authResult)); + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); var result = await _service.LoginAsync("first@example.com", "pw"); @@ -56,11 +59,11 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime { _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) - .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("first@example.com"))); + .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("first@example.com"))); _apiClient .Setup(x => x.LoginAsync("second@example.com", "pw", default)) - .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("second@example.com"))); + .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("second@example.com"))); await _service.LoginAsync("first@example.com", "pw"); await _service.LoginAsync("second@example.com", "pw"); @@ -77,7 +80,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime _apiClient .Setup(x => x.LoginAsync("signout@example.com", "pw", default)) - .ReturnsAsync(ApiEnvelope.Success(authResult)); + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient .Setup(x => x.LogoutAsync(authResult.RefreshToken, default)) @@ -90,6 +93,32 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime persisted.Should().BeEmpty(); } + [Fact] + public async Task HasAddOnAsync_ShouldUseLegacyStoreForUnlimitedAccounts() + { + _storeManagementService + .Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS)) + .ReturnsAsync(true); + + var hasAddOn = await _service.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS); + + hasAddOn.Should().BeTrue(); + } + + [Fact] + public async Task LoginAsync_ShouldPreserveEnvelopeErrorMessage() + { + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Failure(ApiErrorCodes.InvalidCredentials, "Password does not match this account.")); + + var result = await _service.LoginAsync("first@example.com", "pw"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ApiErrorCodes.InvalidCredentials); + result.ErrorMessage.Should().Be("Password does not match this account."); + } + private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( diff --git a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs index 03184d43..ede1311c 100644 --- a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs +++ b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs @@ -9,7 +9,6 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; -using Wino.Core.Domain.Models.Store; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Navigation; @@ -44,6 +43,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM protected IAccountService AccountService { get; } protected IProviderService ProviderService { get; } protected IStoreManagementService StoreManagementService { get; } + protected IWinoAccountProfileService WinoAccountProfileService { get; } protected IAuthenticationProvider AuthenticationProvider { get; } protected IPreferencesService PreferencesService { get; } @@ -52,6 +52,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM IAccountService accountService, IProviderService providerService, IStoreManagementService storeManagementService, + IWinoAccountProfileService winoAccountProfileService, IAuthenticationProvider authenticationProvider, IPreferencesService preferencesService) { @@ -60,6 +61,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM AccountService = accountService; ProviderService = providerService; StoreManagementService = storeManagementService; + WinoAccountProfileService = winoAccountProfileService; AuthenticationProvider = authenticationProvider; PreferencesService = preferencesService; } @@ -75,7 +77,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM [RelayCommand] public async Task PurchaseUnlimitedAccountAsync() { - var purchaseResult = await StoreManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts); + var purchaseResult = await StoreManagementService.PurchaseAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS); if (purchaseResult == StorePurchaseResult.Succeeded) DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success); @@ -92,14 +94,12 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM public async Task ManageStorePurchasesAsync() { - await ExecuteUIThread(async () => - { - HasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts); + var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false); - if (!HasUnlimitedAccountProduct) - IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT; - else - IsAccountCreationBlocked = false; + await ExecuteUIThread(() => + { + HasUnlimitedAccountProduct = hasUnlimitedAccountProduct; + IsAccountCreationBlocked = !hasUnlimitedAccountProduct && Accounts.Count >= FREE_ACCOUNT_COUNT; }); } diff --git a/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs new file mode 100644 index 00000000..6accda61 --- /dev/null +++ b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs @@ -0,0 +1,52 @@ +#nullable enable +using CommunityToolkit.Mvvm.ComponentModel; +using System.Windows.Input; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.ViewModels.Data; + +public partial class WinoAddOnItemViewModel : ObservableObject +{ + public WinoAddOnProductType ProductType { get; } + + public string NameKey => $"WinoAddOn_{ProductType}_Name"; + public string DescriptionKey => $"WinoAddOn_{ProductType}_Description"; + public string KeywordsKey => $"WinoAddOn_{ProductType}_Keywords"; + + public string IconGlyph => ProductType switch + { + WinoAddOnProductType.AI_PACK => "\uE945", + WinoAddOnProductType.UNLIMITED_ACCOUNTS => "\uE716", + _ => "\uE10F" + }; + + [ObservableProperty] + public partial bool IsPurchased { get; set; } + + public ICommand? PurchaseCommand { get; set; } + + public ICommand? ManageCommand { get; set; } + + [ObservableProperty] + public partial bool IsPurchaseInProgress { get; set; } + + [ObservableProperty] + public partial int UsageCount { get; set; } + + [ObservableProperty] + public partial int UsageLimit { get; set; } = 1; + + [ObservableProperty] + public partial double UsagePercentage { get; set; } + + [ObservableProperty] + public partial string RenewalText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string UsageResetText { get; set; } = string.Empty; + + public WinoAddOnItemViewModel(WinoAddOnProductType productType) + { + ProductType = productType; + } +} diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 29dadf8e..4b0f0969 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -1,17 +1,19 @@ #nullable enable using System; -using System.Globalization; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; 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.Navigation; -using Wino.Mail.Api.Contracts.Ai; -using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Billing; +using Wino.Core.ViewModels.Data; using Wino.Messaging.UI; namespace Wino.Core.ViewModels; @@ -20,27 +22,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, IRecipient, IRecipient { - private const string AiPackProductId = "ai-pack-monthly"; - private const string ManageAiPackUrl = "https://example.com/wino-ai-pack/manage"; - private readonly IWinoAccountProfileService _profileService; + private readonly IWinoAddOnService _addOnService; private readonly IMailDialogService _dialogService; private readonly INativeAppService _nativeAppService; + public ObservableCollection AddOns { get; } = []; + [ObservableProperty] public partial bool IsBusy { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSignedOut))] - [NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))] public partial bool IsSignedIn { get; set; } - [ObservableProperty] - public partial string AccountDisplayName { get; set; } = string.Empty; - - [ObservableProperty] - public partial string AccountInitials { get; set; } = string.Empty; - [ObservableProperty] public partial string AccountEmail { get; set; } = string.Empty; @@ -48,48 +43,18 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, public partial string AccountStatusText { get; set; } = string.Empty; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(CanShowAiUsage))] - [NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))] - public partial bool HasAiPack { get; set; } - - [ObservableProperty] - public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive; - - [ObservableProperty] - public partial string AiUsageSummary { get; set; } = string.Empty; - - [ObservableProperty] - public partial string AiBillingPeriodSummary { get; set; } = string.Empty; - - [ObservableProperty] - public partial string AiPackRenewalText { get; set; } = string.Empty; - - [ObservableProperty] - public partial int AiUsageCount { get; set; } - - [ObservableProperty] - public partial int AiUsageLimit { get; set; } = 1; - - [ObservableProperty] - public partial double AiUsagePercentage { get; set; } - - [ObservableProperty] - public partial string AiUsageResetText { get; set; } = string.Empty; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(CanBuyAiPack))] - public partial bool IsAiPackCheckoutInProgress { get; set; } + [NotifyCanExecuteChangedFor(nameof(PurchaseAddOnCommand))] + public partial bool IsCheckoutInProgress { get; set; } public bool IsSignedOut => !IsSignedIn; - public bool CanShowAiUsage => HasAiPack; - public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack; - public bool CanBuyAiPack => !IsAiPackCheckoutInProgress; public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, + IWinoAddOnService addOnService, IMailDialogService dialogService, INativeAppService nativeAppService) { _profileService = profileService; + _addOnService = addOnService; _dialogService = dialogService; _nativeAppService = nativeAppService; } @@ -147,16 +112,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, InfoBarMessageType.Success); } - [RelayCommand] - private async Task BuyAiPackAsync() + [RelayCommand(CanExecute = nameof(CanPurchaseAddOn))] + private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn) { - if (IsAiPackCheckoutInProgress) + if (addOn == null) { return; } var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); - if (account == null) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, @@ -165,13 +129,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, return; } - await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true); + await ExecuteUIThread(() => + { + IsCheckoutInProgress = true; + addOn.IsPurchaseInProgress = true; + }); try { - var checkoutSession = await _profileService.CreateCheckoutSessionAsync(AiPackProductId).ConfigureAwait(false); + var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false); - if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result)) + if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url)) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_PurchaseStartFailed, @@ -179,8 +147,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, return; } - var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result)).ConfigureAwait(false); - + var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false); if (!isLaunched) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, @@ -199,12 +166,49 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, } finally { - await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false); + await ExecuteUIThread(() => + { + IsCheckoutInProgress = false; + addOn.IsPurchaseInProgress = false; + }); } } + private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn) + => addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress; + [RelayCommand] - private async Task ManageAiPackAsync() => await _nativeAppService.LaunchUriAsync(new Uri(ManageAiPackUrl)); + private async Task ManageAiPackAsync() + { + try + { + var portalSession = await _profileService.CreateCustomerPortalSessionAsync().ConfigureAwait(false); + if (!portalSession.IsSuccess || string.IsNullOrWhiteSpace(portalSession.Result?.Url)) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_PurchaseStartFailed, + InfoBarMessageType.Error); + return; + } + + var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(portalSession.Result.Url)).ConfigureAwait(false); + if (!isLaunched) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_PurchaseStartFailed, + InfoBarMessageType.Error); + } + } + catch (OperationCanceledException) + { + } + catch (Exception) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_PurchaseStartFailed, + InfoBarMessageType.Error); + } + } [RelayCommand] private Task ExportSettingsAsync() => Task.CompletedTask; @@ -232,7 +236,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, => _ = LoadAsync(); public void Receive(WinoAccountSignedOutMessage message) - => _ = ResetSignedOutStateAsync(); + => _ = LoadAsync(); private async Task LoadAsync() { @@ -241,44 +245,25 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, try { var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); - - if (account == null) - { - await ResetSignedOutStateAsync(); - return; - } - - var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false); - var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false); - - var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null - ? currentUserResponse.Result - : new AuthUserDto(account.Id, account.Email, account.AccountStatus, account.HasPassword, account.HasGoogleLogin, account.HasFacebookLogin); + var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false); await ExecuteUIThread(() => { - IsSignedIn = true; - AccountEmail = resolvedUser.Email; - AccountDisplayName = ExtractDisplayName(resolvedUser.Email); - AccountInitials = ExtractInitials(resolvedUser.Email); - AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus); + IsSignedIn = account != null; + AccountEmail = account?.Email ?? string.Empty; + AccountStatusText = account == null + ? string.Empty + : string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus); }); - UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null); - - if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_LoadFailed, - InfoBarMessageType.Warning); - } + await UpdateAddOnsAsync(addOns).ConfigureAwait(false); } catch (Exception) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_LoadFailed, InfoBarMessageType.Error); - await ResetSignedOutStateAsync(); + await ResetStateAsync().ConfigureAwait(false); } finally { @@ -286,89 +271,54 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, } } - private async Task ResetSignedOutStateAsync() + private async Task ResetStateAsync() { await ExecuteUIThread(() => { IsSignedIn = false; - AccountDisplayName = string.Empty; - AccountInitials = string.Empty; AccountEmail = string.Empty; AccountStatusText = string.Empty; - HasAiPack = false; - AiPackStateText = Translator.WinoAccount_Management_AiPackInactive; - AiUsageSummary = string.Empty; - AiBillingPeriodSummary = string.Empty; - AiPackRenewalText = string.Empty; - AiUsageCount = 0; - AiUsageLimit = 1; - AiUsagePercentage = 0; - AiUsageResetText = string.Empty; - IsAiPackCheckoutInProgress = false; + IsCheckoutInProgress = false; + AddOns.Clear(); + PurchaseAddOnCommand.NotifyCanExecuteChanged(); }); } - private void UpdateAiPackState(AiStatusResultDto? aiStatus) + private async Task UpdateAddOnsAsync(IReadOnlyList addOns) { - var hasAiPack = aiStatus?.HasAiPack == true; - var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage; - var billingText = string.Empty; - var renewalText = string.Empty; - var usageCount = 0; - var usageLimit = 1; - var usagePercentage = 0d; - var resetText = string.Empty; + var items = addOns.Select(CreateAddOnItem).ToList(); - if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining) + await ExecuteUIThread(() => { - usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining); - usageCount = used; - usageLimit = limit > 0 ? limit : 1; - usagePercentage = (double)used / usageLimit * 100; - } + AddOns.Clear(); - if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd) - { - billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime); - renewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, periodEnd.LocalDateTime); - resetText = string.Format(Translator.WinoAccount_Management_AiPackResets, periodEnd.LocalDateTime); - } + foreach (var item in items) + { + AddOns.Add(item); + } - _ = ExecuteUIThread(() => - { - HasAiPack = hasAiPack; - AiPackStateText = hasAiPack - ? Translator.WinoAccount_Management_AiPackActive - : Translator.WinoAccount_Management_AiPackInactive; - AiUsageSummary = hasAiPack ? usageText : string.Empty; - AiBillingPeriodSummary = hasAiPack ? billingText : string.Empty; - AiPackRenewalText = hasAiPack ? renewalText : string.Empty; - AiUsageCount = usageCount; - AiUsageLimit = usageLimit; - AiUsagePercentage = usagePercentage; - AiUsageResetText = hasAiPack ? resetText : string.Empty; + PurchaseAddOnCommand.NotifyCanExecuteChanged(); }); } - private static string ExtractDisplayName(string email) + private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn) { - if (string.IsNullOrWhiteSpace(email)) - return string.Empty; + var item = new WinoAddOnItemViewModel(addOn.ProductType) + { + IsPurchased = addOn.IsPurchased, + PurchaseCommand = PurchaseAddOnCommand, + ManageCommand = ManageAiPackCommand, + UsageCount = addOn.UsageCount ?? 0, + UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1, + UsagePercentage = addOn.UsagePercentage + }; - var atIndex = email.IndexOf('@'); - var localPart = atIndex > 0 ? email[..atIndex] : email; + if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc) + { + item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime); + item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime); + } - if (localPart.Length == 0) - return string.Empty; - - return char.ToUpper(localPart[0], CultureInfo.CurrentCulture) + localPart[1..]; - } - - private static string ExtractInitials(string email) - { - var displayName = ExtractDisplayName(email); - return displayName.Length > 0 - ? displayName[..1].ToUpper(CultureInfo.CurrentCulture) - : string.Empty; + return item; } } diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index 50c2d46f..2c531298 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -37,11 +37,12 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel IAccountService accountService, IProviderService providerService, IStoreManagementService storeManagementService, + IWinoAccountProfileService winoAccountProfileService, IWinoLogger winoLogger, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ICalDavClient calDavClient, IAuthenticationProvider authenticationProvider, - IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService) + IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService) { MailDialogService = dialogService; _winoLogger = winoLogger; diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs index bd88b239..efb001b9 100644 --- a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs @@ -62,7 +62,7 @@ public sealed partial class WinoAccountLoginDialog : ContentDialog if (!result.IsSuccess || result.Account == null) { - ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); + ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage)); return; } diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs index ab049102..5fbd6ad9 100644 --- a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs @@ -64,7 +64,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog if (!result.IsSuccess || result.Account == null) { - ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); + ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage)); return; } diff --git a/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs new file mode 100644 index 00000000..47401360 --- /dev/null +++ b/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain.Enums; +using Wino.Core.ViewModels.Data; + +namespace Wino.Selectors; + +public partial class WinoAddOnTemplateSelector : DataTemplateSelector +{ + public DataTemplate? NotPurchasedTemplate { get; set; } + public DataTemplate? AiPackPurchasedTemplate { get; set; } + public DataTemplate? UnlimitedAccountsPurchasedTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + if (item is not WinoAddOnItemViewModel addOn) + throw new ArgumentException(nameof(item)); + + if (!addOn.IsPurchased) + return NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate)); + + return addOn.ProductType switch + { + WinoAddOnProductType.AI_PACK => AiPackPurchasedTemplate ?? throw new ArgumentException(nameof(AiPackPurchasedTemplate)), + WinoAddOnProductType.UNLIMITED_ACCOUNTS => UnlimitedAccountsPurchasedTemplate ?? throw new ArgumentException(nameof(UnlimitedAccountsPurchasedTemplate)), + _ => NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate)) + }; + } +} diff --git a/Wino.Mail.WinUI/Services/StoreManagementService.cs b/Wino.Mail.WinUI/Services/StoreManagementService.cs index c39a0da3..a0e11121 100644 --- a/Wino.Mail.WinUI/Services/StoreManagementService.cs +++ b/Wino.Mail.WinUI/Services/StoreManagementService.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; using System.Threading.Tasks; using Windows.Services.Store; using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Store; using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult; +using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType; namespace Wino.Mail.WinUI.Services; @@ -12,14 +12,14 @@ public class StoreManagementService : IStoreManagementService { private StoreContext CurrentContext { get; } - private readonly Dictionary productIds = new Dictionary() + private readonly Dictionary productIds = new Dictionary() { - { StoreProductType.UnlimitedAccounts, "UnlimitedAccounts" } + { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" } }; - private readonly Dictionary skuIds = new Dictionary() + private readonly Dictionary skuIds = new Dictionary() { - { StoreProductType.UnlimitedAccounts, "9P02MXZ42GSM" } + { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "9P02MXZ42GSM" } }; public StoreManagementService() @@ -27,9 +27,11 @@ public class StoreManagementService : IStoreManagementService CurrentContext = StoreContext.GetDefault(); } - public async Task HasProductAsync(StoreProductType productType) + public async Task HasProductAsync(WinoAddOnProductType productType) { - var productKey = productIds[productType]; + if (!productIds.TryGetValue(productType, out var productKey)) + return false; + var appLicense = await CurrentContext.GetAppLicenseAsync(); if (appLicense == null) @@ -49,14 +51,15 @@ public class StoreManagementService : IStoreManagementService return false; } - public async Task PurchaseAsync(StoreProductType productType) + public async Task PurchaseAsync(WinoAddOnProductType productType) { + if (!skuIds.TryGetValue(productType, out var productKey)) + return WinoStorePurchaseResult.NotPurchased; + if (await HasProductAsync(productType)) return WinoStorePurchaseResult.AlreadyPurchased; else { - var productKey = skuIds[productType]; - var result = await CurrentContext.RequestPurchaseAsync(productKey); switch (result.Status) diff --git a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs index 68152833..22e7b1f0 100644 --- a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs +++ b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs @@ -30,4 +30,34 @@ public static class WinoAccountAuthErrorTranslator _ => errorCode }; } + + public static string Format(string? errorCode, string? errorMessage) + { + var translatedCode = Translate(errorCode); + var hasCode = !string.IsNullOrWhiteSpace(errorCode); + var hasMessage = !string.IsNullOrWhiteSpace(errorMessage); + + if (!hasCode && !hasMessage) + { + return Translator.GeneralTitle_Error; + } + + var formattedCode = translatedCode; + if (hasCode && !string.Equals(translatedCode, errorCode, System.StringComparison.Ordinal)) + { + formattedCode = $"{translatedCode} ({errorCode})"; + } + + if (!hasMessage || string.Equals(errorMessage, translatedCode, System.StringComparison.OrdinalIgnoreCase) || string.Equals(errorMessage, errorCode, System.StringComparison.OrdinalIgnoreCase)) + { + return formattedCode; + } + + if (string.IsNullOrWhiteSpace(formattedCode)) + { + return errorMessage!; + } + + return $"{formattedCode}{System.Environment.NewLine}{errorMessage}"; + } } diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml index 458768ba..92c86f0a 100644 --- a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml @@ -7,13 +7,172 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:domain="using:Wino.Core.Domain" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:selectors="using:Wino.Selectors" + xmlns:viewModelData="using:Wino.Core.ViewModels.Data" + x:Name="root" Title="{x:Bind domain:Translator.WinoAccount_SettingsSection_Title}" mc:Ignorable="d"> + + + + + + + + + + + + +