More updates on wino acc.

This commit is contained in:
Burak Kaan Köse
2026-03-18 17:43:56 +01:00
parent a3b43fd079
commit f306f6eb1c
14 changed files with 519 additions and 387 deletions
+2
View File
@@ -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.
+1 -1
View File
@@ -33,7 +33,7 @@
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.82.1" />
<PackageVersion Include="Microsoft.NETCore.UniversalWindowsPlatform" Version="6.2.14" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="3.0.1" />
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.3" />
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.5" />
<PackageVersion Include="MimeKit" Version="4.15.1" />
<PackageVersion Include="morelinq" Version="4.4.0" />
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
@@ -67,6 +67,17 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary>
bool IsWinoAccountButtonHidden { get; set; }
/// <summary>
/// Serializes the current syncable preferences snapshot.
/// </summary>
string ExportPreferences();
/// <summary>
/// Deserializes and applies a preferences snapshot.
/// Returns the applied and failed property counts.
/// </summary>
(int appliedCount, int failedCount) ImportPreferences(string settingsJson);
#endregion
#region Mail
@@ -16,6 +16,7 @@ public interface IWinoAccountApiClient
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
}
@@ -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<WinoAccountOperationResult> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<WinoAccountOperationResult> RefreshAsync(CancellationToken cancellationToken = default);
Task<WinoAccount?> GetActiveAccountAsync();
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
Task<bool> HasActiveAccountAsync();
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default);
}
@@ -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.",
@@ -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<WinoAccount> 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;
@@ -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<WinoAccountSignedInMessage>,
IRecipient<WinoAccountSignedOutMessage>
{
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,142 +145,94 @@ 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();
Messenger.Register<WinoAccountSignedInMessage>(this);
Messenger.Register<WinoAccountSignedOutMessage>(this);
}
var settingsJson = await _apiClient.GetSettingsAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(settingsJson))
protected override void UnregisterRecipients()
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_NoRemoteSettings,
InfoBarMessageType.Warning);
return;
base.UnregisterRecipients();
Messenger.Unregister<WinoAccountSignedInMessage>(this);
Messenger.Unregister<WinoAccountSignedOutMessage>(this);
}
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;
}
public void Receive(WinoAccountSignedInMessage message)
=> _ = LoadAsync();
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);
}
}
public void Receive(WinoAccountSignedOutMessage message)
=> _ = ResetSignedOutStateAsync();
private async Task LoadAsync()
{
@@ -295,7 +240,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel
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<WinoAccount?> 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<string, object?> CollectPreferencesSnapshot()
{
var settings = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in GetSyncablePreferenceProperties())
{
settings[property.Name] = property.GetValue(_preferencesService);
}
return settings;
}
private static string SerializePreferencesSnapshot(Dictionary<string, object?> 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<PropertyInfo> 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;
}
}
}
@@ -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 @@
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMinWidth">560</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">560</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">900</x:Double>
</ContentDialog.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="20">
<!-- Hero illustration area -->
<Border
Height="140"
CornerRadius="12">
<Border Height="140" CornerRadius="12">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#1A818CF8" />
@@ -99,9 +99,7 @@
Height="28"
Fill="White" />
<!-- Person body -->
<Path
Data="M28 68 A20 16 0 0 1 68 68"
Fill="White" />
<Path Data="M28 68 A20 16 0 0 1 68 68" Fill="White" />
<!-- Plus badge -->
<Ellipse
@@ -239,6 +237,12 @@
</Border>
</StackPanel>
<TextBlock
x:Name="ErrorTextBlock"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="WrapWholeWords"
Visibility="Collapsed" />
<!-- Input fields -->
<StackPanel Spacing="12">
<TextBox
@@ -266,9 +270,7 @@
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="12">
<StackPanel Spacing="10">
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrivacyTitle}" />
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrivacyTitle}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
@@ -281,8 +283,8 @@
<CheckBox
x:Name="PrivacyPolicyCheckBox"
Checked="InputChanged"
Unchecked="InputChanged"
Content="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrivacyCheckbox}" />
Content="{x:Bind domain:Translator.WinoAccount_RegisterDialog_PrivacyCheckbox}"
Unchecked="InputChanged" />
</StackPanel>
</Border>
@@ -293,12 +295,6 @@
HorizontalAlignment="Left"
IsActive="False"
Visibility="Collapsed" />
<TextBlock
x:Name="ErrorTextBlock"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="WrapWholeWords"
Visibility="Collapsed" />
</StackPanel>
</ScrollViewer>
</ContentDialog>
@@ -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<string, object?>(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<PropertyInfo> 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;
}
}
}
@@ -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,
@@ -74,6 +74,99 @@
Style="{StaticResource AccentButtonStyle}" />
<Button Command="{x:Bind ViewModel.RegisterCommand}" Content="{x:Bind domain:Translator.Buttons_CreateAccount}" />
</StackPanel>
<TextBlock
Margin="0,16,0,4"
HorizontalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSectionHeader}" />
<controls:SettingsCard
MaxWidth="520"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.Header>
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoTitle}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="4">
<TextBlock
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="PRO" />
</Border>
</StackPanel>
<TextBlock
MaxWidth="400"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}"
TextWrapping="WrapWholeWords" />
<StackPanel Orientation="Horizontal" Spacing="16">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8C1;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureTranslate}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE70F;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureRewrite}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8FD;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureSummarize}" />
</StackPanel>
</StackPanel>
<Border
Padding="12,8"
Background="{ThemeResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="8">
<TextBlock
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_PurchaseRequiresSignIn}"
TextWrapping="WrapWholeWords" />
</Border>
<StackPanel
Margin="0,4,0,0"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.BuyAiPackCommand}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
IsEnabled="{x:Bind ViewModel.CanBuyAiPack, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
<ProgressRing
Width="18"
Height="18"
IsActive="True"
Visibility="{x:Bind ViewModel.IsAiPackCheckoutInProgress, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoPrice}" />
<Run Text=" · " />
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoRequests}" />
</TextBlock>
</StackPanel>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</StackPanel>
</StackPanel>
@@ -181,9 +274,15 @@
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.OpenBuyPageCommand}"
Content="{x:Bind domain:Translator.WinoAccount_Management_AiPackGetButton}"
Command="{x:Bind ViewModel.BuyAiPackCommand}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
IsEnabled="{x:Bind ViewModel.CanBuyAiPack, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
<ProgressRing
Width="18"
Height="18"
IsActive="True"
Visibility="{x:Bind ViewModel.IsAiPackCheckoutInProgress, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+20 -2
View File
@@ -84,6 +84,20 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
public Task<ApiEnvelope<string>> 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<string>.Failure("UnknownProduct"))
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken);
}
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
@@ -150,11 +164,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
private async Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
private async Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(HttpMethod method, string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> 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<TResponse>.Failure("MissingAccessToken");
@@ -217,5 +234,6 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<string>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
+92 -1
View File
@@ -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<WinoAccount?> GetActiveAccountAsync()
@@ -66,9 +76,89 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return account;
}
public async Task<WinoAccount?> 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<bool> HasActiveAccountAsync()
=> await Connection.Table<WinoAccount>().CountAsync().ConfigureAwait(false) > 0;
public async Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<AuthUserDto>.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<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<AiStatusResultDto>.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<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<string>.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);
}