diff --git a/AGENTS.md b/AGENTS.md index fab8fafb..8bbc7c51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ private string searchQuery = string.Empty; - Editing UWP project files instead of WinUI equivalents - Hardcoding strings instead of using Translator - Forgetting to unregister Messenger recipients (memory leaks) +- Putting authentication validation, token refresh, account API calls, settings serialization/deserialization, or preference-application logic into ViewModels instead of the corresponding service ## Code Style @@ -141,6 +142,7 @@ private string searchQuery = string.Empty; - Log errors via IWinoLogger - For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations. - 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(...)`. - Never create pure C# controls or controls that heavily manipulate UI structure from `.cs` files. Define controls in XAML and keep UI composition in XAML. - Never subscribe to framework events like `Loaded`, `Unloaded`, or input events from constructors in `.xaml.cs` for XAML-backed controls and pages; wire them directly in XAML instead. diff --git a/Directory.Packages.props b/Directory.Packages.props index 6cd46441..dffe25a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + @@ -76,4 +76,4 @@ - + \ No newline at end of file diff --git a/Wino.Core.Domain/Interfaces/IPreferencesService.cs b/Wino.Core.Domain/Interfaces/IPreferencesService.cs index 6fc0d80a..72276eea 100644 --- a/Wino.Core.Domain/Interfaces/IPreferencesService.cs +++ b/Wino.Core.Domain/Interfaces/IPreferencesService.cs @@ -67,6 +67,17 @@ public interface IPreferencesService : INotifyPropertyChanged /// bool IsWinoAccountButtonHidden { get; set; } + /// + /// Serializes the current syncable preferences snapshot. + /// + string ExportPreferences(); + + /// + /// Deserializes and applies a preferences snapshot. + /// Returns the applied and failed property counts. + /// + (int appliedCount, int failedCount) ImportPreferences(string settingsJson); + #endregion #region Mail diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index 9148d999..befc1dc4 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -16,6 +16,7 @@ public interface IWinoAccountApiClient 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 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 85870ddf..6f3f7bee 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -3,6 +3,9 @@ using System.Threading; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Models.Accounts; +using Wino.Mail.Api.Contracts.Ai; +using Wino.Mail.Api.Contracts.Auth; +using Wino.Mail.Api.Contracts.Common; namespace Wino.Core.Domain.Interfaces; @@ -12,6 +15,10 @@ public interface IWinoAccountProfileService Task LoginAsync(string email, string password, CancellationToken cancellationToken = default); Task RefreshAsync(CancellationToken cancellationToken = default); Task GetActiveAccountAsync(); + Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task HasActiveAccountAsync(); + Task> GetCurrentUserAsync(CancellationToken cancellationToken = default); + Task> GetAiStatusAsync(CancellationToken cancellationToken = default); + Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 8953daa6..f7b98981 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -878,6 +878,8 @@ "WinoAccount_Management_AiPackPromoPrice": "$4.99 / mo", "WinoAccount_Management_AiPackPromoRequests": "1,000 requests", "WinoAccount_Management_AiPackGetButton": "Get AI Pack", + "WinoAccount_Management_PurchaseRequiresSignIn": "Sign in with your Wino Account to complete this purchase.", + "WinoAccount_Management_PurchaseStartFailed": "Wino could not start the checkout session for this add-on.", "WinoAccount_Management_AiPackSubscriptionActive": "Your subscription is active", "WinoAccount_Management_AiPackRenews": "Renews {0:d}", "WinoAccount_Management_AiPackRequestsUsed": "Requests used this month", @@ -1245,6 +1247,7 @@ "WinoAccount_Error_AccountLocked": "This account is temporarily locked.", "WinoAccount_Error_AccountBanned": "This account has been banned.", "WinoAccount_Error_AccountSuspended": "This account has been suspended.", + "WinoAccount_Error_EmailNotConfirmed": "Please confirm your email address before signing in.", "WinoAccount_Error_RefreshTokenInvalid": "Your session is no longer valid. Please sign in again.", "WinoAccount_Error_EmailAlreadyRegistered": "This email address is already registered.", "WinoAccount_Error_ExternalLoginEmailRequired": "An email address is required to complete external sign-in.", diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index bbc86ce8..1f0ee6b6 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -38,7 +38,6 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, private readonly IPreferencesService _preferencesService; private readonly IProviderService _providerService; private readonly IWinoAccountProfileService _profileService; - private readonly IWinoAccountApiClient _apiClient; private readonly IMailDialogService _dialogService; private bool _isInitializingSettings; private bool _isAppearanceSelectionPaused; @@ -136,7 +135,6 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, IPreferencesService preferencesService, IProviderService providerService, IWinoAccountProfileService profileService, - IWinoAccountApiClient apiClient, IMailDialogService dialogService) { _nativeAppService = nativeAppService; @@ -148,7 +146,6 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, _preferencesService = preferencesService; _providerService = providerService; _profileService = profileService; - _apiClient = apiClient; _dialogService = dialogService; } @@ -494,15 +491,15 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, try { - var account = await EnsureAuthenticatedAccountAsync().ConfigureAwait(false); + var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); if (account == null) { await ResetWinoAccountStateAsync(); return; } - var currentUserResponse = await _apiClient.GetCurrentUserAsync().ConfigureAwait(false); - var aiStatusResponse = await _apiClient.GetAiStatusAsync().ConfigureAwait(false); + var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false); + var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false); var resolvedEmail = currentUserResponse.IsSuccess && currentUserResponse.Result != null ? currentUserResponse.Result.Email @@ -546,22 +543,6 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, }); } - private async Task EnsureAuthenticatedAccountAsync() - { - var account = await _profileService.GetActiveAccountAsync().ConfigureAwait(false); - if (account == null) return null; - - if (account.AccessTokenExpiresAtUtc <= DateTime.UtcNow.AddMinutes(1)) - { - var refreshResult = await _profileService.RefreshAsync().ConfigureAwait(false); - if (!refreshResult.IsSuccess) return null; - - account = refreshResult.Account ?? await _profileService.GetActiveAccountAsync().ConfigureAwait(false); - } - - return account != null && !string.IsNullOrWhiteSpace(account.AccessToken) ? account : null; - } - private void UpdateAiPackState(AiStatusResultDto aiStatus) { var hasAiPack = aiStatus?.HasAiPack == true; diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 7cadb3aa..29dadf8e 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -1,15 +1,10 @@ #nullable enable using System; -using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; 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; @@ -17,17 +12,18 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; +using Wino.Messaging.UI; namespace Wino.Core.ViewModels; -public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel +public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, + IRecipient, + IRecipient { - private const string BuyAiPackUrl = "https://example.com/wino-ai-pack"; + private const string AiPackProductId = "ai-pack-monthly"; private const string ManageAiPackUrl = "https://example.com/wino-ai-pack/manage"; private readonly IWinoAccountProfileService _profileService; - private readonly IWinoAccountApiClient _apiClient; - private readonly IPreferencesService _preferencesService; private readonly IMailDialogService _dialogService; private readonly INativeAppService _nativeAppService; @@ -80,19 +76,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel [ObservableProperty] public partial string AiUsageResetText { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanBuyAiPack))] + public partial bool IsAiPackCheckoutInProgress { get; set; } + public bool IsSignedOut => !IsSignedIn; public bool CanShowAiUsage => HasAiPack; public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack; + public bool CanBuyAiPack => !IsAiPackCheckoutInProgress; public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, - IWinoAccountApiClient apiClient, - IPreferencesService preferencesService, IMailDialogService dialogService, INativeAppService nativeAppService) { _profileService = profileService; - _apiClient = apiClient; - _preferencesService = preferencesService; _dialogService = dialogService; _nativeAppService = nativeAppService; } @@ -115,8 +112,6 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.WinoAccount_RegisterSuccessMessage, account.Email), InfoBarMessageType.Success); - - await LoadAsync(); } [RelayCommand] @@ -131,8 +126,6 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.WinoAccount_LoginSuccessMessage, account.Email), InfoBarMessageType.Success); - - await LoadAsync(); } [RelayCommand] @@ -152,150 +145,102 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, string.Format(Translator.WinoAccount_SignOut_SuccessMessage, account.Email), InfoBarMessageType.Success); - - await ResetSignedOutStateAsync(); } [RelayCommand] - private async Task OpenBuyPageAsync() => await _nativeAppService.LaunchUriAsync(new Uri(BuyAiPackUrl)); + private async Task BuyAiPackAsync() + { + if (IsAiPackCheckoutInProgress) + { + return; + } + + var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); + + if (account == null) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, + Translator.WinoAccount_Management_PurchaseRequiresSignIn, + InfoBarMessageType.Warning); + return; + } + + await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true); + + try + { + var checkoutSession = await _profileService.CreateCheckoutSessionAsync(AiPackProductId).ConfigureAwait(false); + + if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result)) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_PurchaseStartFailed, + InfoBarMessageType.Error); + return; + } + + var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result)).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); + } + finally + { + await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false); + } + } [RelayCommand] private async Task ManageAiPackAsync() => await _nativeAppService.LaunchUriAsync(new Uri(ManageAiPackUrl)); [RelayCommand] - private async Task ExportSettingsAsync() - { - string settingsJson; - - try - { - if (await EnsureAuthenticatedAccountAsync().ConfigureAwait(false) == null) - { - return; - } - - var settings = CollectPreferencesSnapshot(); - if (settings.Count == 0) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_EmptyExport, - InfoBarMessageType.Warning); - return; - } - - settingsJson = SerializePreferencesSnapshot(settings); - - if (string.IsNullOrWhiteSpace(settingsJson) || settingsJson == "{}") - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_EmptyExport, - InfoBarMessageType.Warning); - return; - } - } - catch (Exception) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_SerializeFailed, - InfoBarMessageType.Error); - return; - } - - try - { - if (await EnsureAuthenticatedAccountAsync().ConfigureAwait(false) == null) - { - return; - } - - var isSaved = await _apiClient.SaveSettingsAsync(settingsJson).ConfigureAwait(false); - - if (!isSaved) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_ActionFailed, - InfoBarMessageType.Error); - return; - } - - _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, - Translator.WinoAccount_Management_ExportSucceeded, - InfoBarMessageType.Success); - } - catch (Exception) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_ActionFailed, - InfoBarMessageType.Error); - } - } + private Task ExportSettingsAsync() => Task.CompletedTask; [RelayCommand] - private async Task ImportSettingsAsync() + private Task ImportSettingsAsync() => Task.CompletedTask; + + protected override void RegisterRecipients() { - try - { - if (await EnsureAuthenticatedAccountAsync().ConfigureAwait(false) == null) - { - return; - } + base.RegisterRecipients(); - var settingsJson = await _apiClient.GetSettingsAsync().ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(settingsJson)) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_NoRemoteSettings, - InfoBarMessageType.Warning); - return; - } - - using var document = JsonDocument.Parse(settingsJson); - if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.EnumerateObject().Any()) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_ImportEmpty, - InfoBarMessageType.Warning); - return; - } - - var (appliedCount, failedCount) = ApplyPreferencesSnapshot(document.RootElement); - - if (appliedCount == 0) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_ImportEmpty, - InfoBarMessageType.Warning); - return; - } - - if (failedCount > 0) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - string.Format(Translator.WinoAccount_Management_ImportPartial, appliedCount, failedCount), - InfoBarMessageType.Warning); - return; - } - - _dialogService.InfoBarMessage(Translator.GeneralTitle_Info, - string.Format(Translator.WinoAccount_Management_ImportSucceeded, appliedCount), - InfoBarMessageType.Success); - } - catch (Exception) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_ActionFailed, - InfoBarMessageType.Error); - } + Messenger.Register(this); + Messenger.Register(this); } + protected override void UnregisterRecipients() + { + base.UnregisterRecipients(); + + Messenger.Unregister(this); + Messenger.Unregister(this); + } + + public void Receive(WinoAccountSignedInMessage message) + => _ = LoadAsync(); + + public void Receive(WinoAccountSignedOutMessage message) + => _ = ResetSignedOutStateAsync(); + private async Task LoadAsync() { await ExecuteUIThread(() => IsBusy = true); try { - var account = await EnsureAuthenticatedAccountAsync().ConfigureAwait(false); + var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); if (account == null) { @@ -303,8 +248,8 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel return; } - var currentUserResponse = await _apiClient.GetCurrentUserAsync().ConfigureAwait(false); - var aiStatusResponse = await _apiClient.GetAiStatusAsync().ConfigureAwait(false); + var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false); + var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false); var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null ? currentUserResponse.Result @@ -359,34 +304,10 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel AiUsageLimit = 1; AiUsagePercentage = 0; AiUsageResetText = string.Empty; + IsAiPackCheckoutInProgress = false; }); } - private async Task EnsureAuthenticatedAccountAsync() - { - var account = await _profileService.GetActiveAccountAsync().ConfigureAwait(false); - if (account == null) - { - return null; - } - - if (account.AccessTokenExpiresAtUtc <= DateTime.UtcNow.AddMinutes(1)) - { - var refreshResult = await _profileService.RefreshAsync().ConfigureAwait(false); - - if (!refreshResult.IsSuccess) - { - return null; - } - - account = refreshResult.Account ?? await _profileService.GetActiveAccountAsync().ConfigureAwait(false); - } - - return account != null && !string.IsNullOrWhiteSpace(account.AccessToken) - ? account - : null; - } - private void UpdateAiPackState(AiStatusResultDto? aiStatus) { var hasAiPack = aiStatus?.HasAiPack == true; @@ -450,176 +371,4 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel ? displayName[..1].ToUpper(CultureInfo.CurrentCulture) : string.Empty; } - - private Dictionary CollectPreferencesSnapshot() - { - var settings = new Dictionary(StringComparer.Ordinal); - - foreach (var property in GetSyncablePreferenceProperties()) - { - settings[property.Name] = property.GetValue(_preferencesService); - } - - return settings; - } - - private static string SerializePreferencesSnapshot(Dictionary settings) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) - { - writer.WriteStartObject(); - - foreach (var setting in settings) - { - WritePreferenceValue(writer, setting.Key, setting.Value); - } - - writer.WriteEndObject(); - } - - return Encoding.UTF8.GetString(stream.ToArray()); - } - - private (int appliedCount, int failedCount) ApplyPreferencesSnapshot(JsonElement rootElement) - { - var appliedCount = 0; - var failedCount = 0; - - foreach (var property in GetSyncablePreferenceProperties()) - { - if (!rootElement.TryGetProperty(property.Name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - continue; - } - - try - { - property.SetValue(_preferencesService, ReadPreferenceValue(property.PropertyType, value)); - appliedCount++; - } - catch (Exception) - { - failedCount++; - } - } - - return (appliedCount, failedCount); - } - - private static void WritePreferenceValue(Utf8JsonWriter writer, string propertyName, object? value) - { - if (value == null) - { - writer.WriteNull(propertyName); - return; - } - - switch (value) - { - case string stringValue: - writer.WriteString(propertyName, stringValue); - return; - case bool boolValue: - writer.WriteBoolean(propertyName, boolValue); - return; - case int intValue: - writer.WriteNumber(propertyName, intValue); - return; - case long longValue: - writer.WriteNumber(propertyName, longValue); - return; - case double doubleValue: - writer.WriteNumber(propertyName, doubleValue); - return; - case float floatValue: - writer.WriteNumber(propertyName, floatValue); - return; - case Guid guidValue: - writer.WriteString(propertyName, guidValue); - return; - case TimeSpan timeSpanValue: - writer.WriteString(propertyName, timeSpanValue.ToString("c", CultureInfo.InvariantCulture)); - return; - } - - var valueType = Nullable.GetUnderlyingType(value.GetType()) ?? value.GetType(); - if (valueType.IsEnum) - { - writer.WriteString(propertyName, value.ToString()); - return; - } - - writer.WriteString(propertyName, Convert.ToString(value, CultureInfo.InvariantCulture)); - } - - private static object? ReadPreferenceValue(Type propertyType, JsonElement value) - { - var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; - - if (value.ValueKind == JsonValueKind.Null) - { - return null; - } - - if (targetType == typeof(string)) - { - return value.GetString() ?? string.Empty; - } - - if (targetType == typeof(bool)) - { - return value.GetBoolean(); - } - - if (targetType == typeof(int)) - { - return value.GetInt32(); - } - - if (targetType == typeof(long)) - { - return value.GetInt64(); - } - - if (targetType == typeof(double)) - { - return value.GetDouble(); - } - - if (targetType == typeof(float)) - { - return value.GetSingle(); - } - - if (targetType == typeof(Guid)) - { - return Guid.Parse(value.GetString() ?? string.Empty); - } - - if (targetType == typeof(TimeSpan)) - { - return TimeSpan.Parse(value.GetString() ?? string.Empty, CultureInfo.InvariantCulture); - } - - if (targetType.IsEnum) - { - return Enum.Parse(targetType, value.GetString() ?? string.Empty, true); - } - - return Convert.ChangeType(value.GetString(), targetType, CultureInfo.InvariantCulture); - } - - private static IEnumerable GetSyncablePreferenceProperties() - { - foreach (var property in typeof(IPreferencesService).GetProperties(BindingFlags.Instance | BindingFlags.Public)) - { - if (!property.CanRead || !property.CanWrite || property.GetIndexParameters().Length > 0) - { - continue; - } - - yield return property; - } - } } diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml index 3242fbb4..a36ba89c 100644 --- a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml @@ -6,6 +6,7 @@ xmlns:domain="using:Wino.Core.Domain" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" Title="{x:Bind domain:Translator.WinoAccount_RegisterDialog_Title}" + FullSizeDesired="True" PrimaryButtonClick="RegisterClicked" PrimaryButtonStyle="{ThemeResource AccentButtonStyle}" PrimaryButtonText="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrimaryButton}" @@ -16,15 +17,14 @@ 560 560 + 900 - + @@ -99,9 +99,7 @@ Height="28" Fill="White" /> - + + + - + + Content="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrivacyCheckbox}" + Unchecked="InputChanged" /> @@ -293,12 +295,6 @@ HorizontalAlignment="Left" IsActive="False" Visibility="Collapsed" /> - - diff --git a/Wino.Mail.WinUI/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs index 0a4e18fd..d4dc1d8c 100644 --- a/Wino.Mail.WinUI/Services/PreferencesService.cs +++ b/Wino.Mail.WinUI/Services/PreferencesService.cs @@ -3,6 +3,10 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; using CommunityToolkit.Mvvm.ComponentModel; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; @@ -44,6 +48,59 @@ public class PreferencesService(IConfigurationService configurationService) : Ob RenderPlaintextLinks = RenderPlaintextLinks }; + public string ExportPreferences() + { + var settings = new Dictionary(StringComparer.Ordinal); + + foreach (var property in GetSyncablePreferenceProperties()) + { + settings[property.Name] = property.GetValue(this); + } + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + + foreach (var setting in settings) + { + WritePreferenceValue(writer, setting.Key, setting.Value); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + public (int appliedCount, int failedCount) ImportPreferences(string settingsJson) + { + using var document = JsonDocument.Parse(settingsJson); + var rootElement = document.RootElement; + var appliedCount = 0; + var failedCount = 0; + + foreach (var property in GetSyncablePreferenceProperties()) + { + if (!rootElement.TryGetProperty(property.Name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + continue; + } + + try + { + property.SetValue(this, ReadPreferenceValue(property.PropertyType, value)); + appliedCount++; + } + catch (Exception) + { + failedCount++; + } + } + + return (appliedCount, failedCount); + } + public MailListDisplayMode MailItemDisplayMode { get => _configurationService.Get(nameof(MailItemDisplayMode), MailListDisplayMode.Spacious); @@ -368,6 +425,122 @@ public class PreferencesService(IConfigurationService configurationService) : Ob return daysOfWeek; } + + private static void WritePreferenceValue(Utf8JsonWriter writer, string propertyName, object? value) + { + if (value == null) + { + writer.WriteNull(propertyName); + return; + } + + switch (value) + { + case string stringValue: + writer.WriteString(propertyName, stringValue); + return; + case bool boolValue: + writer.WriteBoolean(propertyName, boolValue); + return; + case int intValue: + writer.WriteNumber(propertyName, intValue); + return; + case long longValue: + writer.WriteNumber(propertyName, longValue); + return; + case double doubleValue: + writer.WriteNumber(propertyName, doubleValue); + return; + case float floatValue: + writer.WriteNumber(propertyName, floatValue); + return; + case Guid guidValue: + writer.WriteString(propertyName, guidValue); + return; + case TimeSpan timeSpanValue: + writer.WriteString(propertyName, timeSpanValue.ToString("c", CultureInfo.InvariantCulture)); + return; + } + + var valueType = Nullable.GetUnderlyingType(value.GetType()) ?? value.GetType(); + if (valueType.IsEnum) + { + writer.WriteString(propertyName, value.ToString()); + return; + } + + writer.WriteString(propertyName, Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private static object? ReadPreferenceValue(Type propertyType, JsonElement value) + { + var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + if (value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (targetType == typeof(string)) + { + return value.GetString() ?? string.Empty; + } + + if (targetType == typeof(bool)) + { + return value.GetBoolean(); + } + + if (targetType == typeof(int)) + { + return value.GetInt32(); + } + + if (targetType == typeof(long)) + { + return value.GetInt64(); + } + + if (targetType == typeof(double)) + { + return value.GetDouble(); + } + + if (targetType == typeof(float)) + { + return value.GetSingle(); + } + + if (targetType == typeof(Guid)) + { + return Guid.Parse(value.GetString() ?? string.Empty); + } + + if (targetType == typeof(TimeSpan)) + { + return TimeSpan.Parse(value.GetString() ?? string.Empty, CultureInfo.InvariantCulture); + } + + if (targetType.IsEnum) + { + return Enum.Parse(targetType, value.GetString() ?? string.Empty, true); + } + + return Convert.ChangeType(value.GetString(), targetType, CultureInfo.InvariantCulture); + } + + private static IEnumerable GetSyncablePreferenceProperties() + { + foreach (var property in typeof(IPreferencesService).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead || !property.CanWrite || property.GetIndexParameters().Length > 0) + { + continue; + } + + yield return property; + } + } } diff --git a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs index 61ba82c4..68152833 100644 --- a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs +++ b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs @@ -18,6 +18,7 @@ public static class WinoAccountAuthErrorTranslator ApiErrorCodes.AccountLocked => Translator.WinoAccount_Error_AccountLocked, ApiErrorCodes.AccountBanned => Translator.WinoAccount_Error_AccountBanned, ApiErrorCodes.AccountSuspended => Translator.WinoAccount_Error_AccountSuspended, + ApiErrorCodes.EmailNotConfirmed => Translator.WinoAccount_Error_EmailNotConfirmed, ApiErrorCodes.RefreshTokenInvalid => Translator.WinoAccount_Error_RefreshTokenInvalid, ApiErrorCodes.EmailAlreadyRegistered => Translator.WinoAccount_Error_EmailAlreadyRegistered, ApiErrorCodes.ExternalLoginEmailRequired => Translator.WinoAccount_Error_ExternalLoginEmailRequired, diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml index cfeedc0f..458768ba 100644 --- a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml @@ -74,6 +74,99 @@ Style="{StaticResource AccentButtonStyle}" />