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}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -181,9 +274,15 @@
Orientation="Horizontal"
Spacing="12">
+
> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
+ public Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
+ {
+ var endpoint = productId switch
+ {
+ "ai-pack-monthly" => "api/v1/billing/ai-pack/checkout-session",
+ "unlimited-accounts" => "api/v1/billing/unlimited-accounts/checkout-session",
+ _ => string.Empty
+ };
+
+ return string.IsNullOrWhiteSpace(endpoint)
+ ? Task.FromResult(ApiEnvelope.Failure("UnknownProduct"))
+ : SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken);
+ }
+
public async Task GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
@@ -150,11 +164,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
- private async Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken)
+ private Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken)
+ => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
+
+ private async Task> SendAuthorizedRequestAsync(HttpMethod method, string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken)
{
try
{
- using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, endpoint).ConfigureAwait(false);
+ using var request = await CreateAuthorizedRequestAsync(method, endpoint).ConfigureAwait(false);
if (request == null)
return ApiEnvelope.Failure("MissingAccessToken");
@@ -217,5 +234,6 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
+[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs
index 92d551f5..d7dbc4a5 100644
--- a/Wino.Services/WinoAccountProfileService.cs
+++ b/Wino.Services/WinoAccountProfileService.cs
@@ -6,6 +6,7 @@ using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
+using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
using Wino.Messaging.UI;
@@ -53,11 +54,20 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
var account = await GetActiveAccountAsync().ConfigureAwait(false);
if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken))
{
+ _logger.Warning("Wino account token refresh skipped because there is no active account or refresh token.");
return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid);
}
+ _logger.Information("Refreshing Wino account token for {Email}", account.Email);
var response = await _apiClient.RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false);
- return await PersistResponseAsync(response).ConfigureAwait(false);
+ var result = await PersistResponseAsync(response).ConfigureAwait(false);
+
+ if (!result.IsSuccess)
+ {
+ _logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode);
+ }
+
+ return result;
}
public async Task GetActiveAccountAsync()
@@ -66,9 +76,89 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return account;
}
+ public async Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default)
+ {
+ var account = await GetActiveAccountAsync().ConfigureAwait(false);
+
+ if (account == null)
+ {
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(account.AccessToken))
+ {
+ _logger.Warning("Wino account {Email} is missing an access token.", account.Email);
+ return null;
+ }
+
+ if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
+ {
+ return account;
+ }
+
+ var refreshResult = await RefreshAsync(cancellationToken).ConfigureAwait(false);
+ if (!refreshResult.IsSuccess)
+ {
+ return null;
+ }
+
+ return refreshResult.Account ?? await GetActiveAccountAsync().ConfigureAwait(false);
+ }
+
public async Task HasActiveAccountAsync()
=> await Connection.Table().CountAsync().ConfigureAwait(false) > 0;
+ public async Task> GetCurrentUserAsync(CancellationToken cancellationToken = default)
+ {
+ var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
+ if (account == null)
+ {
+ return ApiEnvelope.Failure("MissingAccessToken");
+ }
+
+ var response = await _apiClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ _logger.Warning("Failed to load Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
+ }
+
+ return response;
+ }
+
+ public async Task> GetAiStatusAsync(CancellationToken cancellationToken = default)
+ {
+ var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
+ if (account == null)
+ {
+ return ApiEnvelope.Failure("MissingAccessToken");
+ }
+
+ var response = await _apiClient.GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ _logger.Warning("Failed to load AI status for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
+ }
+
+ return response;
+ }
+
+ public async Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
+ {
+ var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
+ if (account == null)
+ {
+ return ApiEnvelope.Failure("MissingAccessToken");
+ }
+
+ var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ _logger.Warning("Failed to create checkout session for product {ProductId} and Wino account {Email}. Error code: {ErrorCode}", productId, account.Email, response.ErrorCode);
+ }
+
+ return response;
+ }
+
public async Task SignOutAsync(CancellationToken cancellationToken = default)
{
var account = await GetActiveAccountAsync().ConfigureAwait(false);
@@ -101,6 +191,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
{
if (!response.IsSuccess || response.Result == null)
{
+ _logger.Warning("Wino account operation failed. Error code: {ErrorCode}", response.ErrorCode);
return WinoAccountOperationResult.Failure(response.ErrorCode);
}