Handling of AI pack through mmicrosoft store.
This commit is contained in:
@@ -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.9" />
|
||||
<PackageVersion Include="Wino.Mail.Contracts" Version="1.0.13" />
|
||||
<PackageVersion Include="MimeKit" Version="4.15.1" />
|
||||
<PackageVersion Include="morelinq" Version="4.4.0" />
|
||||
<PackageVersion Include="Nito.AsyncEx" Version="5.1.2" />
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using System;
|
||||
using SQLite;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Shared;
|
||||
|
||||
public class WinoAccountAddOnCache
|
||||
{
|
||||
[PrimaryKey]
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public bool HasAiPack { get; set; }
|
||||
|
||||
public int? AiUsageCount { get; set; }
|
||||
|
||||
public int? AiUsageLimit { get; set; }
|
||||
|
||||
public DateTime? AiBillingPeriodStartUtc { get; set; }
|
||||
|
||||
public DateTime? AiBillingPeriodEndUtc { get; set; }
|
||||
|
||||
public bool HasUnlimitedAccounts { get; set; }
|
||||
|
||||
public DateTime LastUpdatedUtc { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
public interface IStoreManagementService
|
||||
@@ -13,4 +15,14 @@ public interface IStoreManagementService
|
||||
/// Attempts to purchase the given add-on.
|
||||
/// </summary>
|
||||
Task<StorePurchaseResult> PurchaseAsync(WinoAddOnProductType productType);
|
||||
|
||||
/// <summary>
|
||||
/// Requests a Microsoft Store collections ID key for the current customer.
|
||||
/// </summary>
|
||||
Task<string?> GetCustomerCollectionsIdAsync(string serviceTicket, string publisherUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Requests a Microsoft Store purchase ID key for the current customer.
|
||||
/// </summary>
|
||||
Task<string?> GetCustomerPurchaseIdAsync(string serviceTicket, string publisherUserId);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
@@ -24,8 +22,9 @@ public interface IWinoAccountApiClient
|
||||
Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreateCollectionsIdTicketAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default);
|
||||
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
@@ -22,17 +20,14 @@ public interface IWinoAccountProfileService
|
||||
Task<ApiEnvelope<EmailConfirmationResendResultDto>> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<JsonElement>> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default);
|
||||
Task<WinoAccount?> GetActiveAccountAsync();
|
||||
Task<WinoAccountAddOnSnapshot?> GetCachedAddOnSnapshotAsync();
|
||||
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> HasActiveAccountAsync();
|
||||
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiTextResultDto>> SummarizeAsync(string html, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiTextResultDto>> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default);
|
||||
Task SignOutAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
public interface IWinoAddOnService
|
||||
{
|
||||
Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
public sealed record WinoAccountAddOnSnapshot(
|
||||
bool HasAiPack,
|
||||
int? UsageCount = null,
|
||||
int? UsageLimit = null,
|
||||
DateTimeOffset? BillingPeriodStartUtc = null,
|
||||
DateTimeOffset? BillingPeriodEndUtc = null,
|
||||
bool HasUnlimitedAccounts = false,
|
||||
DateTimeOffset? LastUpdatedUtc = null);
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using Wino.Core.Domain.Enums;
|
||||
|
||||
namespace Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
public sealed record WinoAddOnInfo(
|
||||
WinoAddOnProductType ProductType,
|
||||
bool IsPurchased,
|
||||
int? UsageCount = null,
|
||||
int? UsageLimit = null,
|
||||
double UsagePercentage = 0,
|
||||
DateTimeOffset? RenewalDateUtc = null);
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
public sealed record WinoStoreCollectionsIdTicketInfo(
|
||||
string ServiceTicket,
|
||||
string PublisherUserId,
|
||||
DateTimeOffset ExpiresAtUtc);
|
||||
@@ -1240,14 +1240,17 @@
|
||||
"WinoAddOn_UNLIMITED_ACCOUNTS_Description": "Remove the account limit and add as many mail accounts as you need.",
|
||||
"WinoAddOn_UNLIMITED_ACCOUNTS_Keywords": "accounts, unlimited, premium, add-on",
|
||||
"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_PurchaseStartFailed": "Wino could not complete this Microsoft Store purchase.",
|
||||
"WinoAccount_Management_StoreSyncFailed": "Your purchase finished, but Wino could not refresh your account benefits yet. Please try again in a moment.",
|
||||
"WinoAccount_Management_AiPackSubscriptionActive": "Your subscription is active",
|
||||
"WinoAccount_Management_AiPackRenews": "Renews {0:d}",
|
||||
"WinoAccount_Management_AiPackRequestsUsed": "Requests used this month",
|
||||
"WinoAccount_Management_AiPackResets": "Resets {0:d}",
|
||||
"WinoAccount_Management_AiPackUsageLoadFailed": "We had issues loading your AI usage balance.",
|
||||
"WinoAccount_Management_AiPackFeatureTranslate": "Translate",
|
||||
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
|
||||
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
|
||||
"WinoAccount_Management_AddOnLoadFailed": "We had issues loading this add-on.",
|
||||
"WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences",
|
||||
"WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.",
|
||||
"WinoAccount_Management_SignOutTitle": "Sign out",
|
||||
|
||||
@@ -27,7 +27,6 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
{
|
||||
_databaseService = new InMemoryDatabaseService();
|
||||
await _databaseService.InitializeAsync();
|
||||
await _databaseService.Connection.CreateTableAsync<WinoAccountAddOnCache>();
|
||||
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
|
||||
}
|
||||
|
||||
@@ -96,18 +95,6 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
persisted.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasAddOnAsync_ShouldUseLegacyStoreForUnlimitedAccounts()
|
||||
{
|
||||
_storeManagementService
|
||||
.Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var hasAddOn = await _service.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
|
||||
|
||||
hasAddOn.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_ShouldPreserveEnvelopeErrorMessage()
|
||||
{
|
||||
@@ -190,8 +177,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
"Premium",
|
||||
authResult.User.HasPassword,
|
||||
authResult.User.HasGoogleLogin,
|
||||
authResult.User.HasFacebookLogin,
|
||||
authResult.User.HasUnlimitedAccounts)));
|
||||
authResult.User.HasFacebookLogin)));
|
||||
|
||||
await _service.LoginAsync("first@example.com", "pw");
|
||||
|
||||
@@ -237,7 +223,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummarizeAsync_ShouldPersistQuotaSnapshot()
|
||||
public async Task SummarizeAsync_ShouldReturnQuotaBackedResponse()
|
||||
{
|
||||
var authResult = CreateAuthResult("first@example.com");
|
||||
|
||||
@@ -265,18 +251,12 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
|
||||
|
||||
response.IsSuccess.Should().BeTrue();
|
||||
response.Result?.Html.Should().Be("<p>Summary</p>");
|
||||
|
||||
var cachedSnapshot = await _service.GetCachedAddOnSnapshotAsync();
|
||||
cachedSnapshot.Should().NotBeNull();
|
||||
cachedSnapshot!.HasAiPack.Should().BeTrue();
|
||||
cachedSnapshot.UsageCount.Should().Be(4);
|
||||
cachedSnapshot.UsageLimit.Should().Be(1000);
|
||||
}
|
||||
|
||||
private static AuthResultDto CreateAuthResult(string email)
|
||||
{
|
||||
return new AuthResultDto(
|
||||
new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false, false),
|
||||
new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false),
|
||||
"access-token",
|
||||
DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
"refresh-token",
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Google.Apis.Requests;
|
||||
using Moq;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Synchronizers.Mail;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public sealed class GmailSynchronizerRequestSuccessTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
changeProcessor
|
||||
.Setup(x => x.ChangeMailReadStatusAsync("mail-1", true))
|
||||
.Returns(Task.CompletedTask);
|
||||
changeProcessor
|
||||
.Setup(x => x.ChangeMailReadStatusAsync("mail-2", true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object);
|
||||
var request = new BatchMarkReadRequest(
|
||||
[
|
||||
new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true),
|
||||
new MarkReadRequest(CreateMailCopy("mail-2"), IsRead: true)
|
||||
]);
|
||||
var bundle = new HttpRequestBundle<IClientServiceRequest>(Mock.Of<IClientServiceRequest>(), request);
|
||||
using var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
|
||||
await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response);
|
||||
|
||||
changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-1", true), Times.Once);
|
||||
changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-2", true), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_BatchChangeFlagRequest_PersistsLocalFlagStateForEachMail()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
changeProcessor
|
||||
.Setup(x => x.ChangeFlagStatusAsync("mail-1", true))
|
||||
.Returns(Task.CompletedTask);
|
||||
changeProcessor
|
||||
.Setup(x => x.ChangeFlagStatusAsync("mail-2", true))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object);
|
||||
var request = new BatchChangeFlagRequest(
|
||||
[
|
||||
new ChangeFlagRequest(CreateMailCopy("mail-1"), IsFlagged: true),
|
||||
new ChangeFlagRequest(CreateMailCopy("mail-2"), IsFlagged: true)
|
||||
]);
|
||||
var bundle = new HttpRequestBundle<IClientServiceRequest>(Mock.Of<IClientServiceRequest>(), request);
|
||||
using var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
|
||||
await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response);
|
||||
|
||||
changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-1", true), Times.Once);
|
||||
changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-2", true), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_DoesNotPersistLocalReadState()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
var errorFactory = new Mock<IGmailSynchronizerErrorHandlerFactory>(MockBehavior.Strict);
|
||||
errorFactory
|
||||
.Setup(x => x.HandleErrorAsync(It.IsAny<SynchronizerErrorContext>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object);
|
||||
var request = new BatchMarkReadRequest(
|
||||
[
|
||||
new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true)
|
||||
]);
|
||||
var bundle = new HttpRequestBundle<IClientServiceRequest>(Mock.Of<IClientServiceRequest>(), request);
|
||||
using var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
var error = new RequestError
|
||||
{
|
||||
Code = 429,
|
||||
Message = "rate limit"
|
||||
};
|
||||
|
||||
await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error);
|
||||
|
||||
changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny<SynchronizerErrorContext>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_RevertsOptimisticReadState()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
var errorFactory = new Mock<IGmailSynchronizerErrorHandlerFactory>(MockBehavior.Strict);
|
||||
errorFactory
|
||||
.Setup(x => x.HandleErrorAsync(It.IsAny<SynchronizerErrorContext>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var mail = CreateMailCopy("mail-1");
|
||||
var request = new BatchMarkReadRequest(
|
||||
[
|
||||
new MarkReadRequest(mail, IsRead: true)
|
||||
]);
|
||||
request.ApplyUIChanges();
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object);
|
||||
var bundle = new HttpRequestBundle<IClientServiceRequest>(Mock.Of<IClientServiceRequest>(), request);
|
||||
using var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(string.Empty)
|
||||
};
|
||||
var error = new RequestError
|
||||
{
|
||||
Code = 429,
|
||||
Message = "rate limit"
|
||||
};
|
||||
|
||||
await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error);
|
||||
|
||||
mail.IsRead.Should().BeFalse();
|
||||
changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny<SynchronizerErrorContext>()), Times.Once);
|
||||
}
|
||||
|
||||
private static GmailSynchronizer CreateSynchronizer(
|
||||
IGmailChangeProcessor changeProcessor,
|
||||
IGmailSynchronizerErrorHandlerFactory? errorFactory = null)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Gmail",
|
||||
Address = "user@example.com"
|
||||
};
|
||||
|
||||
var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose);
|
||||
|
||||
return new GmailSynchronizer(account, authenticator.Object, changeProcessor, errorFactory ?? Mock.Of<IGmailSynchronizerErrorHandlerFactory>());
|
||||
}
|
||||
|
||||
private static MailCopy CreateMailCopy(string id) =>
|
||||
new()
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = id,
|
||||
FolderId = Guid.NewGuid(),
|
||||
IsRead = false,
|
||||
IsFlagged = false
|
||||
};
|
||||
|
||||
private static async Task InvokeProcessSingleNativeRequestResponseAsync(
|
||||
GmailSynchronizer synchronizer,
|
||||
HttpRequestBundle<IClientServiceRequest> bundle,
|
||||
HttpResponseMessage response,
|
||||
RequestError? error = null)
|
||||
{
|
||||
var method = typeof(GmailSynchronizer).GetMethod(
|
||||
"ProcessSingleNativeRequestResponseAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var task = method!.Invoke(synchronizer, [bundle, error, response, CancellationToken.None]) as Task;
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
}
|
||||
}
|
||||
@@ -27,15 +27,15 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))]
|
||||
private bool hasUnlimitedAccountProduct;
|
||||
public partial bool HasUnlimitedAccountProduct { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAccountCreationAlmostOnLimit))]
|
||||
[NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))]
|
||||
private bool isAccountCreationBlocked;
|
||||
public partial bool IsAccountCreationBlocked { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private IAccountProviderDetailViewModel _startupAccount;
|
||||
public partial IAccountProviderDetailViewModel StartupAccount { get; set; }
|
||||
|
||||
public int FREE_ACCOUNT_COUNT { get; } = 3;
|
||||
protected IDialogServiceBase DialogService { get; }
|
||||
@@ -94,7 +94,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
|
||||
|
||||
public async Task ManageStorePurchasesAsync()
|
||||
{
|
||||
var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
|
||||
var hasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
|
||||
@@ -21,30 +21,63 @@ public partial class WinoAddOnItemViewModel : ObservableObject
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowPurchaseState))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial bool IsPurchased { get; set; }
|
||||
|
||||
public ICommand? PurchaseCommand { get; set; }
|
||||
|
||||
public ICommand? ManageCommand { get; set; }
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowLoadingState))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowPurchaseState))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowErrorState))]
|
||||
public partial bool IsLoading { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsPurchaseInProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowErrorState))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial bool HasUsageData { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowErrorState))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial string ErrorText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int UsageCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial int UsageLimit { get; set; } = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial double UsagePercentage { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial string RenewalText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowUsageSummary))]
|
||||
public partial string UsageResetText { get; set; } = string.Empty;
|
||||
|
||||
public bool ShowLoadingState => IsLoading;
|
||||
|
||||
public bool ShowPurchaseState => !IsLoading && string.IsNullOrWhiteSpace(ErrorText);
|
||||
|
||||
public bool ShowUsageSummary => ProductType == WinoAddOnProductType.AI_PACK &&
|
||||
IsPurchased &&
|
||||
!IsLoading &&
|
||||
string.IsNullOrWhiteSpace(ErrorText) &&
|
||||
HasUsageData;
|
||||
|
||||
public bool ShowErrorState => !IsLoading && !string.IsNullOrWhiteSpace(ErrorText);
|
||||
|
||||
public WinoAddOnItemViewModel(WinoAddOnProductType productType)
|
||||
{
|
||||
ProductType = productType;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -10,7 +9,6 @@ using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Core.ViewModels.Data;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
@@ -24,9 +22,10 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
IRecipient<WinoAccountAddOnPurchasedMessage>
|
||||
{
|
||||
private readonly IWinoAccountProfileService _profileService;
|
||||
private readonly IWinoAddOnService _addOnService;
|
||||
private readonly IMailDialogService _dialogService;
|
||||
private readonly INativeAppService _nativeAppService;
|
||||
private readonly IStoreManagementService _storeManagementService;
|
||||
private readonly WinoAddOnItemViewModel _aiPackAddOn;
|
||||
private readonly WinoAddOnItemViewModel _unlimitedAccountsAddOn;
|
||||
|
||||
public ObservableCollection<WinoAddOnItemViewModel> AddOns { get; } = [];
|
||||
|
||||
@@ -50,14 +49,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
public bool IsSignedOut => !IsSignedIn;
|
||||
|
||||
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
|
||||
IWinoAddOnService addOnService,
|
||||
IMailDialogService dialogService,
|
||||
INativeAppService nativeAppService)
|
||||
IStoreManagementService storeManagementService)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_addOnService = addOnService;
|
||||
_dialogService = dialogService;
|
||||
_nativeAppService = nativeAppService;
|
||||
_storeManagementService = storeManagementService;
|
||||
|
||||
_aiPackAddOn = CreateAddOnItem(WinoAddOnProductType.AI_PACK);
|
||||
_unlimitedAccountsAddOn = CreateAddOnItem(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
|
||||
AddOns.Add(_aiPackAddOn);
|
||||
AddOns.Add(_unlimitedAccountsAddOn);
|
||||
}
|
||||
|
||||
public override void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||
@@ -166,15 +168,6 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
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(() =>
|
||||
{
|
||||
IsCheckoutInProgress = true;
|
||||
@@ -183,9 +176,9 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false);
|
||||
var purchaseResult = await _storeManagementService.PurchaseAsync(addOn.ProductType);
|
||||
|
||||
if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url))
|
||||
if (purchaseResult == StorePurchaseResult.NotPurchased)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_PurchaseStartFailed,
|
||||
@@ -193,13 +186,16 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
return;
|
||||
}
|
||||
|
||||
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false);
|
||||
if (!isLaunched)
|
||||
var syncResult = await _profileService.SyncStoreEntitlementsAsync().ConfigureAwait(false);
|
||||
if (!syncResult.IsSuccess && !string.Equals(syncResult.ErrorCode, "MissingAccessToken", StringComparison.Ordinal))
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_PurchaseStartFailed,
|
||||
TranslateStoreSyncError(syncResult.ErrorCode),
|
||||
InfoBarMessageType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
await HandleAddOnPurchasedAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -221,40 +217,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
}
|
||||
|
||||
private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn)
|
||||
=> addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ManageAiPackAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var portalSession = await _profileService.CreateCustomerPortalSessionAsync().ConfigureAwait(false);
|
||||
if (!portalSession.IsSuccess || string.IsNullOrWhiteSpace(portalSession.Result?.Url))
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_PurchaseStartFailed,
|
||||
InfoBarMessageType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(portalSession.Result.Url)).ConfigureAwait(false);
|
||||
if (!isLaunched)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_PurchaseStartFailed,
|
||||
InfoBarMessageType.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_PurchaseStartFailed,
|
||||
InfoBarMessageType.Error);
|
||||
}
|
||||
}
|
||||
=> addOn != null && !addOn.IsPurchased && !addOn.IsLoading && !IsCheckoutInProgress;
|
||||
|
||||
[RelayCommand]
|
||||
private Task ExportSettingsAsync() => Task.CompletedTask;
|
||||
@@ -296,43 +259,58 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
WinoAccount? cachedAccount = null;
|
||||
|
||||
try
|
||||
{
|
||||
var cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
|
||||
var cachedAddOns = await _addOnService.GetAvailableAddOnsAsync(true).ConfigureAwait(false);
|
||||
cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
|
||||
|
||||
if (cachedAccount != null)
|
||||
{
|
||||
await ApplyAccountStateAsync(cachedAccount).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (cachedAddOns.Count > 0)
|
||||
await ExecuteUIThread(() => IsBusy = true);
|
||||
await ResetAddOnStatesAsync().ConfigureAwait(false);
|
||||
var loadAiPackTask = LoadAiPackAddOnAsync();
|
||||
var loadUnlimitedAccountsTask = LoadUnlimitedAccountsAddOnAsync();
|
||||
|
||||
var resolvedAccount = cachedAccount;
|
||||
|
||||
if (cachedAccount == null || IsAccessTokenExpired(cachedAccount))
|
||||
{
|
||||
await UpdateAddOnsAsync(cachedAddOns).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
||||
if (account != null)
|
||||
{
|
||||
resolvedAccount = account;
|
||||
|
||||
var refreshedProfileResult = await _profileService.RefreshProfileAsync().ConfigureAwait(false);
|
||||
if (refreshedProfileResult.IsSuccess && refreshedProfileResult.Account != null)
|
||||
{
|
||||
resolvedAccount = refreshedProfileResult.Account;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
resolvedAccount ??= cachedAccount;
|
||||
}
|
||||
}
|
||||
|
||||
await ExecuteUIThread(() => IsBusy = cachedAccount == null && cachedAddOns.Count == 0);
|
||||
|
||||
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
|
||||
var refreshedProfileResult = account == null
|
||||
? null
|
||||
: await _profileService.RefreshProfileAsync().ConfigureAwait(false);
|
||||
var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
|
||||
|
||||
var resolvedAccount = refreshedProfileResult?.IsSuccess == true && refreshedProfileResult.Account != null
|
||||
? refreshedProfileResult.Account
|
||||
: account;
|
||||
|
||||
await ApplyAccountStateAsync(resolvedAccount).ConfigureAwait(false);
|
||||
|
||||
await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
|
||||
await Task.WhenAll(loadAiPackTask, loadUnlimitedAccountsTask).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_LoadFailed,
|
||||
InfoBarMessageType.Error);
|
||||
await ResetStateAsync().ConfigureAwait(false);
|
||||
if (cachedAccount == null)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
|
||||
Translator.WinoAccount_Management_LoadFailed,
|
||||
InfoBarMessageType.Error);
|
||||
await ResetStateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -369,46 +347,151 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
||||
AccountEmail = string.Empty;
|
||||
AccountStatusText = string.Empty;
|
||||
IsCheckoutInProgress = false;
|
||||
AddOns.Clear();
|
||||
PurchaseAddOnCommand.NotifyCanExecuteChanged();
|
||||
});
|
||||
|
||||
await ResetAddOnStatesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateAddOnsAsync(IReadOnlyList<WinoAddOnInfo> addOns)
|
||||
private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnProductType productType)
|
||||
{
|
||||
var items = addOns.Select(CreateAddOnItem).ToList();
|
||||
return new WinoAddOnItemViewModel(productType)
|
||||
{
|
||||
PurchaseCommand = PurchaseAddOnCommand,
|
||||
UsageLimit = 1
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ResetAddOnStatesAsync()
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
AddOns.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
AddOns.Add(item);
|
||||
}
|
||||
|
||||
ResetAddOnItem(_aiPackAddOn);
|
||||
ResetAddOnItem(_unlimitedAccountsAddOn);
|
||||
PurchaseAddOnCommand.NotifyCanExecuteChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn)
|
||||
private static void ResetAddOnItem(WinoAddOnItemViewModel addOn)
|
||||
{
|
||||
var item = new WinoAddOnItemViewModel(addOn.ProductType)
|
||||
addOn.IsLoading = true;
|
||||
addOn.IsPurchased = false;
|
||||
addOn.IsPurchaseInProgress = false;
|
||||
addOn.HasUsageData = false;
|
||||
addOn.ErrorText = string.Empty;
|
||||
addOn.UsageCount = 0;
|
||||
addOn.UsageLimit = 1;
|
||||
addOn.UsagePercentage = 0;
|
||||
addOn.RenewalText = string.Empty;
|
||||
addOn.UsageResetText = string.Empty;
|
||||
}
|
||||
|
||||
private static string TranslateStoreSyncError(string? errorCode)
|
||||
=> errorCode switch
|
||||
{
|
||||
IsPurchased = addOn.IsPurchased,
|
||||
PurchaseCommand = PurchaseAddOnCommand,
|
||||
ManageCommand = ManageAiPackCommand,
|
||||
UsageCount = addOn.UsageCount ?? 0,
|
||||
UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1,
|
||||
UsagePercentage = addOn.UsagePercentage
|
||||
_ => Translator.WinoAccount_Management_StoreSyncFailed
|
||||
};
|
||||
|
||||
if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc)
|
||||
{
|
||||
item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime);
|
||||
item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime);
|
||||
}
|
||||
private static bool IsAccessTokenExpired(WinoAccount account)
|
||||
=> string.IsNullOrWhiteSpace(account.AccessToken) || account.AccessTokenExpiresAtUtc <= DateTime.UtcNow;
|
||||
|
||||
return item;
|
||||
private async Task LoadUnlimitedAccountsAddOnAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hasUnlimitedAccounts = await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_unlimitedAccountsAddOn.IsPurchased = hasUnlimitedAccounts;
|
||||
_unlimitedAccountsAddOn.ErrorText = string.Empty;
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_unlimitedAccountsAddOn.ErrorText = Translator.WinoAccount_Management_AddOnLoadFailed;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_unlimitedAccountsAddOn.IsLoading = false;
|
||||
PurchaseAddOnCommand.NotifyCanExecuteChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAiPackAddOnAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hasAiPack = await _storeManagementService.HasProductAsync(WinoAddOnProductType.AI_PACK).ConfigureAwait(false);
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.IsPurchased = hasAiPack;
|
||||
_aiPackAddOn.ErrorText = string.Empty;
|
||||
});
|
||||
|
||||
if (!hasAiPack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
|
||||
if (!aiStatusResponse.IsSuccess || aiStatusResponse.Result == null)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.HasUsageData = false;
|
||||
_aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AiPackUsageLoadFailed;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var aiStatus = aiStatusResponse.Result;
|
||||
if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.HasUsageData = false;
|
||||
_aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AiPackUsageLoadFailed;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.HasUsageData = true;
|
||||
_aiPackAddOn.ErrorText = string.Empty;
|
||||
_aiPackAddOn.UsageCount = usageCount;
|
||||
_aiPackAddOn.UsageLimit = usageLimit;
|
||||
_aiPackAddOn.UsagePercentage = usageLimit > 0 ? (double)usageCount / usageLimit * 100 : 0;
|
||||
_aiPackAddOn.RenewalText = aiStatus.CurrentPeriodEndUtc is DateTimeOffset renewalDateUtc
|
||||
? string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime)
|
||||
: string.Empty;
|
||||
_aiPackAddOn.UsageResetText = aiStatus.CurrentPeriodEndUtc is DateTimeOffset resetDateUtc
|
||||
? string.Format(Translator.WinoAccount_Management_AiPackResets, resetDateUtc.LocalDateTime)
|
||||
: string.Empty;
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.HasUsageData = false;
|
||||
_aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AddOnLoadFailed;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
_aiPackAddOn.IsLoading = false;
|
||||
PurchaseAddOnCommand.NotifyCanExecuteChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1622,6 +1622,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Try to handle the error with registered handlers
|
||||
var handled = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext);
|
||||
|
||||
if (handled)
|
||||
{
|
||||
if (ShouldRevertOptimisticMailStateChange(bundle?.UIChangeRequest))
|
||||
{
|
||||
bundle?.UIChangeRequest?.RevertUIChanges();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If not handled by any specific handler, apply default error handling
|
||||
if (!handled)
|
||||
{
|
||||
@@ -1649,6 +1659,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRevertOptimisticMailStateChange(IUIChangeRequest request)
|
||||
=> request is BatchMarkReadRequest
|
||||
|| request is MarkReadRequest
|
||||
|| request is BatchChangeFlagRequest
|
||||
|| request is ChangeFlagRequest;
|
||||
|
||||
private bool ShouldUpdateSyncIdentifier(ulong? historyId)
|
||||
{
|
||||
if (historyId == null) return false;
|
||||
@@ -1672,7 +1688,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
HttpResponseMessage httpResponseMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ProcessGmailRequestErrorAsync(error, bundle);
|
||||
if (error != null)
|
||||
{
|
||||
await ProcessGmailRequestErrorAsync(error, bundle).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await PersistSuccessfulMailStateChangesAsync(bundle).ConfigureAwait(false);
|
||||
|
||||
if (bundle is HttpRequestBundle<IClientServiceRequest, Message> messageBundle)
|
||||
{
|
||||
@@ -1735,6 +1757,34 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistSuccessfulMailStateChangesAsync(IRequestBundle<IClientServiceRequest> bundle)
|
||||
{
|
||||
switch (bundle.UIChangeRequest)
|
||||
{
|
||||
case BatchMarkReadRequest batchMarkReadRequest:
|
||||
foreach (var request in batchMarkReadRequest)
|
||||
{
|
||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case MarkReadRequest markReadRequest:
|
||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case BatchChangeFlagRequest batchChangeFlagRequest:
|
||||
foreach (var request in batchChangeFlagRequest)
|
||||
{
|
||||
await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case ChangeFlagRequest changeFlagRequest:
|
||||
await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gmail Archive is a special folder that is not visible in the Gmail web interface.
|
||||
/// We need to handle it separately.
|
||||
|
||||
@@ -145,7 +145,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId);
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadWrite).ConfigureAwait(false);
|
||||
await remoteFolder.StoreAsync(GetUniqueId(item.Item.Id), new StoreFlagsRequest(item.Item.IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged) { Silent = true }).ConfigureAwait(false);
|
||||
await remoteFolder.StoreAsync(GetUniqueId(item.Item.Id), new StoreFlagsRequest(item.IsFlagged ? StoreAction.Add : StoreAction.Remove, MessageFlags.Flagged) { Silent = true }).ConfigureAwait(false);
|
||||
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
||||
}, requests);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<Identity
|
||||
Name="58272BurakKSE.WinoMailPreview"
|
||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
||||
Version="2.0.14.0" />
|
||||
Version="2.0.15.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Services.Store;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult;
|
||||
using WinRT.Interop;
|
||||
using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType;
|
||||
using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult;
|
||||
|
||||
namespace Wino.Mail.WinUI.Services;
|
||||
|
||||
@@ -14,11 +15,13 @@ public class StoreManagementService : IStoreManagementService
|
||||
|
||||
private readonly Dictionary<WinoAddOnProductType, string> productIds = new Dictionary<WinoAddOnProductType, string>()
|
||||
{
|
||||
{ WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" }
|
||||
{ WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" },
|
||||
{ WinoAddOnProductType.AI_PACK, "AI_PACK" },
|
||||
};
|
||||
|
||||
private readonly Dictionary<WinoAddOnProductType, string> skuIds = new Dictionary<WinoAddOnProductType, string>()
|
||||
{
|
||||
{ WinoAddOnProductType.AI_PACK, "9N2FH734RBVS" },
|
||||
{ WinoAddOnProductType.UNLIMITED_ACCOUNTS, "9P02MXZ42GSM" }
|
||||
};
|
||||
|
||||
@@ -60,6 +63,7 @@ public class StoreManagementService : IStoreManagementService
|
||||
return WinoStorePurchaseResult.AlreadyPurchased;
|
||||
else
|
||||
{
|
||||
InitializeStoreContextWithWindow();
|
||||
var result = await CurrentContext.RequestPurchaseAsync(productKey);
|
||||
|
||||
switch (result.Status)
|
||||
@@ -73,4 +77,39 @@ public class StoreManagementService : IStoreManagementService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetCustomerCollectionsIdAsync(string serviceTicket, string publisherUserId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serviceTicket) || string.IsNullOrWhiteSpace(publisherUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
InitializeStoreContextWithWindow();
|
||||
var collectionsId = await CurrentContext.GetCustomerCollectionsIdAsync(serviceTicket, publisherUserId);
|
||||
return string.IsNullOrWhiteSpace(collectionsId) ? null : collectionsId;
|
||||
}
|
||||
|
||||
public async Task<string?> GetCustomerPurchaseIdAsync(string serviceTicket, string publisherUserId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serviceTicket) || string.IsNullOrWhiteSpace(publisherUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
InitializeStoreContextWithWindow();
|
||||
var purchaseId = await CurrentContext.GetCustomerPurchaseIdAsync(serviceTicket, publisherUserId);
|
||||
return string.IsNullOrWhiteSpace(purchaseId) ? null : purchaseId;
|
||||
}
|
||||
|
||||
private void InitializeStoreContextWithWindow()
|
||||
{
|
||||
var mainWindow = WinoApplication.MainWindow;
|
||||
if (mainWindow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InitializeWithWindow.Initialize(CurrentContext, WindowNative.GetWindowHandle(mainWindow));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<DataTemplate x:Key="SettingsShellWinoAccountItemTemplate" x:DataType="menu:SettingsShellPageMenuItem">
|
||||
<coreControls:WinoNavigationViewItem Content="{x:Bind Title}" DataContext="{x:Bind}">
|
||||
<muxc:NavigationViewItem.Icon>
|
||||
<BitmapIcon UriSource="/Assets/Wino_Icon.ico" />
|
||||
<BitmapIcon ShowAsMonochrome="False" UriSource="/Assets/Wino_Icon.ico" />
|
||||
<!--<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="{x:Bind Glyph}" />-->
|
||||
</muxc:NavigationViewItem.Icon>
|
||||
</coreControls:WinoNavigationViewItem>
|
||||
|
||||
@@ -32,6 +32,26 @@
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey)}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Visibility="{x:Bind ShowLoadingState, Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="18"
|
||||
Height="18"
|
||||
IsActive="{x:Bind ShowLoadingState, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.Busy}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorCautionBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ErrorText, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ShowErrorState, Mode=OneWay}" />
|
||||
<StackPanel
|
||||
Margin="0,4,0,0"
|
||||
Orientation="Horizontal"
|
||||
@@ -40,7 +60,8 @@
|
||||
Command="{x:Bind PurchaseCommand}"
|
||||
CommandParameter="{x:Bind}"
|
||||
Content="{x:Bind domain:Translator.Buttons_Purchase}"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Visibility="{x:Bind ShowPurchaseState, Mode=OneWay}" />
|
||||
<ProgressRing
|
||||
Width="18"
|
||||
Height="18"
|
||||
@@ -88,41 +109,65 @@
|
||||
Text="{x:Bind RenewalText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</controls:SettingsExpander.Description>
|
||||
<HyperlinkButton Command="{x:Bind ManageCommand}" Content="{x:Bind domain:Translator.Buttons_Manage}" />
|
||||
<controls:SettingsExpander.Items>
|
||||
<controls:SettingsCard HorizontalContentAlignment="Stretch">
|
||||
<controls:SettingsCard.Header>
|
||||
<StackPanel MinWidth="400" Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Text="{x:Bind UsageCount, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,2"
|
||||
VerticalAlignment="Bottom"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}">
|
||||
<Run Text="/ " />
|
||||
<Run Text="{x:Bind UsageLimit, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel
|
||||
MinWidth="400"
|
||||
Spacing="8"
|
||||
Visibility="{x:Bind ShowUsageSummary, Mode=OneWay}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
Text="{x:Bind UsageCount, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,2"
|
||||
VerticalAlignment="Bottom"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}">
|
||||
<Run Text="/ " />
|
||||
<Run Text="{x:Bind UsageLimit, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<ProgressBar
|
||||
Height="8"
|
||||
Maximum="100"
|
||||
Value="{x:Bind UsagePercentage, Mode=OneWay}" />
|
||||
<Grid>
|
||||
<TextBlock
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackRequestsUsed}" />
|
||||
<TextBlock
|
||||
HorizontalAlignment="Right"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind UsageResetText, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<ProgressBar
|
||||
Height="8"
|
||||
Maximum="100"
|
||||
Value="{x:Bind UsagePercentage, Mode=OneWay}" />
|
||||
<Grid>
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Visibility="{x:Bind ShowLoadingState, Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="18"
|
||||
Height="18"
|
||||
IsActive="{x:Bind ShowLoadingState, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackRequestsUsed}" />
|
||||
<TextBlock
|
||||
HorizontalAlignment="Right"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind UsageResetText, Mode=OneWay}" />
|
||||
</Grid>
|
||||
Text="{x:Bind domain:Translator.Busy}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorCautionBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ErrorText, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ShowErrorState, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</controls:SettingsCard.Header>
|
||||
</controls:SettingsCard>
|
||||
@@ -148,11 +193,32 @@
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey)}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Visibility="{x:Bind ShowLoadingState, Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="18"
|
||||
Height="18"
|
||||
IsActive="{x:Bind ShowLoadingState, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.Busy}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorCautionBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ErrorText, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ShowErrorState, Mode=OneWay}" />
|
||||
<Border
|
||||
Padding="12,4"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="12">
|
||||
CornerRadius="12"
|
||||
Visibility="{x:Bind ShowPurchaseState, Mode=OneWay}">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
@@ -173,18 +239,6 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
|
||||
|
||||
<StackPanel
|
||||
x:Name="BusyPanel"
|
||||
HorizontalAlignment="Center"
|
||||
x:Load="{x:Bind ViewModel.IsBusy, Mode=OneWay}"
|
||||
Spacing="8">
|
||||
<ProgressRing
|
||||
Width="32"
|
||||
Height="32"
|
||||
IsActive="True" />
|
||||
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.Busy}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel
|
||||
x:Name="SignedOutPanel"
|
||||
HorizontalAlignment="Stretch"
|
||||
@@ -260,15 +314,52 @@
|
||||
VerticalAlignment="Center"
|
||||
Data="F1 M 3.75 5 L 3.75 4.902344 C 3.75 4.225262 3.885091 3.588867 4.155273 2.993164 C 4.425456 2.397461 4.790039 1.878256 5.249023 1.435547 C 5.708008 0.99284 6.238606 0.642904 6.84082 0.385742 C 7.443034 0.128582 8.079427 0 8.75 0 C 9.440104 0 10.089518 0.130209 10.698242 0.390625 C 11.306966 0.651043 11.837564 1.007488 12.290039 1.459961 C 12.742513 1.912436 13.098958 2.443035 13.359375 3.051758 C 13.619791 3.660482 13.75 4.309896 13.75 5 C 13.75 5.690104 13.619791 6.339519 13.359375 6.948242 C 13.098958 7.556967 12.742513 8.087565 12.290039 8.540039 C 11.837564 8.992514 11.306966 9.348959 10.698242 9.609375 C 10.089518 9.869792 9.440104 10 8.75 10 C 8.059896 10 7.410481 9.869792 6.801758 9.609375 C 6.193034 9.348959 5.662435 8.992514 5.209961 8.540039 C 4.757487 8.087565 4.401042 7.556967 4.140625 6.948242 C 3.880208 6.339519 3.75 5.690104 3.75 5 Z M 5 5 L 5 5.078125 C 5 5.585938 5.100911 6.062826 5.302734 6.508789 C 5.504557 6.954753 5.777995 7.34375 6.123047 7.675781 C 6.468099 8.007812 6.866862 8.269857 7.319336 8.461914 C 7.77181 8.653972 8.248697 8.75 8.75 8.75 C 9.270833 8.75 9.759114 8.652344 10.214844 8.457031 C 10.670572 8.261719 11.067708 7.994792 11.40625 7.65625 C 11.744791 7.317709 12.011719 6.920573 12.207031 6.464844 C 12.402344 6.009115 12.5 5.520834 12.5 5 C 12.5 4.479167 12.402344 3.990887 12.207031 3.535156 C 12.011719 3.079428 11.744791 2.682293 11.40625 2.34375 C 11.067708 2.005209 10.670572 1.738281 10.214844 1.542969 C 9.759114 1.347656 9.270833 1.25 8.75 1.25 C 8.229166 1.25 7.740885 1.347656 7.285156 1.542969 C 6.829427 1.738281 6.432292 2.005209 6.09375 2.34375 C 5.755208 2.682293 5.488281 3.079428 5.292969 3.535156 C 5.097656 3.990887 5 4.479167 5 5 Z M 20 14.375 L 20 18.125 C 20 18.385416 19.951172 18.629557 19.853516 18.857422 C 19.755859 19.085287 19.622395 19.283854 19.453125 19.453125 C 19.283854 19.622396 19.085285 19.755859 18.857422 19.853516 C 18.629557 19.951172 18.385416 20 18.125 20 L 11.875 20 C 11.614583 20 11.370442 19.951172 11.142578 19.853516 C 10.914713 19.755859 10.716146 19.622396 10.546875 19.453125 C 10.377604 19.283854 10.244141 19.085287 10.146484 18.857422 C 10.048828 18.629557 10 18.385416 10 18.125 L 10 14.375 C 10 14.114584 10.048828 13.870443 10.146484 13.642578 C 10.244141 13.414714 10.377604 13.216146 10.546875 13.046875 C 10.716146 12.877604 10.914713 12.744141 11.142578 12.646484 C 11.370442 12.548828 11.614583 12.5 11.875 12.5 L 12.5 12.5 L 12.5 11.25 C 12.5 11.080729 12.532552 10.921225 12.597656 10.771484 C 12.66276 10.621745 12.753906 10.488281 12.871094 10.371094 C 13.118488 10.123698 13.411457 10 13.75 10 L 16.25 10 C 16.41927 10 16.578775 10.032553 16.728516 10.097656 C 16.878254 10.162761 17.011719 10.253906 17.128906 10.371094 C 17.376301 10.61849 17.5 10.911459 17.5 11.25 L 17.5 12.5 L 18.125 12.5 C 18.385416 12.5 18.629557 12.548828 18.857422 12.646484 C 19.085285 12.744141 19.283854 12.877604 19.453125 13.046875 C 19.622395 13.216146 19.755859 13.414714 19.853516 13.642578 C 19.951172 13.870443 20 14.114584 20 14.375 Z M 11.25 11.25 C 10.800781 11.25 10.382486 11.360678 9.995117 11.582031 C 9.607747 11.803386 9.303385 12.109375 9.082031 12.5 L 2.5 12.5 C 2.324219 12.5 2.159831 12.532553 2.006836 12.597656 C 1.853841 12.662761 1.722005 12.750651 1.611328 12.861328 C 1.500651 12.972006 1.41276 13.103842 1.347656 13.256836 C 1.282552 13.409831 1.25 13.574219 1.25 13.75 C 1.25 14.420573 1.360677 15.008139 1.582031 15.512695 C 1.803385 16.017252 2.102865 16.455078 2.480469 16.826172 C 2.858073 17.197266 3.297526 17.50651 3.798828 17.753906 C 4.30013 18.001303 4.827474 18.198242 5.380859 18.344727 C 5.934244 18.491211 6.50065 18.595377 7.080078 18.657227 C 7.659505 18.719076 8.216146 18.75 8.75 18.75 C 8.75 19.205729 8.860677 19.622396 9.082031 20 L 8.75 20 C 8.190104 20 7.618814 19.973959 7.036133 19.921875 C 6.45345 19.869791 5.878906 19.775391 5.3125 19.638672 C 4.746094 19.501953 4.197591 19.319662 3.666992 19.091797 C 3.136393 18.863932 2.646484 18.574219 2.197266 18.222656 C 1.474609 17.66276 0.927734 17.005209 0.556641 16.25 C 0.185547 15.494792 0 14.661458 0 13.75 C 0 13.404948 0.065104 13.081055 0.195312 12.77832 C 0.325521 12.475586 0.504557 12.210287 0.732422 11.982422 C 0.960286 11.754558 1.225586 11.575521 1.52832 11.445312 C 1.831055 11.315104 2.154948 11.25 2.5 11.25 Z M 16.25 11.25 L 13.75 11.25 L 13.75 12.5 L 16.25 12.5 Z " />
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
<Border
|
||||
Padding="12,4"
|
||||
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="12">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.AccountStatusText, Mode=OneWay}" />
|
||||
</Border>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Border
|
||||
Padding="12,4"
|
||||
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="12">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.AccountStatusText, Mode=OneWay}" />
|
||||
</Border>
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
Visibility="{x:Bind ViewModel.IsBusy, Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="16"
|
||||
Height="16"
|
||||
VerticalAlignment="Center"
|
||||
IsActive="{x:Bind ViewModel.IsBusy, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.Busy}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard
|
||||
Command="{x:Bind ViewModel.ChangePasswordCommand}"
|
||||
Description="{x:Bind domain:Translator.WinoAccount_ChangePassword_Description}"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_ChangePassword_Title}"
|
||||
IsClickEnabled="True">
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard
|
||||
Command="{x:Bind ViewModel.SignOutCommand}"
|
||||
Description="{x:Bind domain:Translator.WinoAccount_Management_SignOutDescription}"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_Management_SignOutTitle}"
|
||||
IsClickEnabled="True">
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<TextBlock
|
||||
@@ -301,30 +392,6 @@
|
||||
</StackPanel>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<TextBlock
|
||||
Margin="0,12,0,4"
|
||||
Style="{StaticResource BodyStrongTextBlockStyle}"
|
||||
Text="{x:Bind domain:Translator.WinoAccount_Management_AccountActionsSectionHeader}" />
|
||||
|
||||
<controls:SettingsCard
|
||||
Command="{x:Bind ViewModel.SignOutCommand}"
|
||||
Description="{x:Bind domain:Translator.WinoAccount_Management_SignOutDescription}"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_Management_SignOutTitle}"
|
||||
IsClickEnabled="True">
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard
|
||||
Command="{x:Bind ViewModel.ChangePasswordCommand}"
|
||||
Description="{x:Bind domain:Translator.WinoAccount_ChangePassword_Description}"
|
||||
Header="{x:Bind domain:Translator.WinoAccount_ChangePassword_Title}"
|
||||
IsClickEnabled="True">
|
||||
<!--<controls:SettingsCard.HeaderIcon>
|
||||
<SymbolIcon Symbol="Permission" />
|
||||
</controls:SettingsCard.HeaderIcon>-->
|
||||
</controls:SettingsCard>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -68,8 +68,7 @@ public class DatabaseService : IDatabaseService
|
||||
Connection.CreateTableAsync<CalendarAttachment>(),
|
||||
Connection.CreateTableAsync<Reminder>(),
|
||||
Connection.CreateTableAsync<MailInvitationCalendarMapping>(),
|
||||
Connection.CreateTableAsync<WinoAccount>(),
|
||||
Connection.CreateTableAsync<WinoAccountAddOnCache>());
|
||||
Connection.CreateTableAsync<WinoAccount>());
|
||||
|
||||
await EnsureSchemaUpgradesAsync().ConfigureAwait(false);
|
||||
await EnsureIndexesAsync().ConfigureAwait(false);
|
||||
@@ -142,6 +141,8 @@ public class DatabaseService : IDatabaseService
|
||||
.ExecuteAsync($"ALTER TABLE {nameof(AccountContact)} ADD COLUMN {nameof(AccountContact.ContactPictureFileId)} TEXT NULL")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Connection.ExecuteAsync("DROP TABLE IF EXISTS WinoAccountAddOnCache").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureKeyboardShortcutSchemaAsync()
|
||||
@@ -208,7 +209,6 @@ SET {nameof(KeyboardShortcut.Action)} =
|
||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailAccountPreferences_AccountId ON MailAccountPreferences(AccountId)").ConfigureAwait(false);
|
||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_CustomServerInformation_AccountId ON CustomServerInformation(AccountId)").ConfigureAwait(false);
|
||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccount_Email ON WinoAccount(Email)").ConfigureAwait(false);
|
||||
await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccountAddOnCache_AccountId ON WinoAccountAddOnCache(AccountId)").ConfigureAwait(false);
|
||||
|
||||
// Calendar indexes
|
||||
await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false);
|
||||
|
||||
@@ -29,7 +29,6 @@ public static class ServicesContainerSetup
|
||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
|
||||
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
|
||||
|
||||
services.AddTransient<ICalDavClient, CalDavClient>();
|
||||
|
||||
@@ -11,12 +11,10 @@ using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
|
||||
namespace Wino.Services;
|
||||
@@ -137,25 +135,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var endpoint = productId switch
|
||||
{
|
||||
WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session",
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return string.IsNullOrWhiteSpace(endpoint)
|
||||
? Task.FromResult(ApiEnvelope<CheckoutSessionResultDto>.Failure("UnknownProduct"))
|
||||
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeCheckoutSessionResultDto, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
|
||||
public Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreateCollectionsIdTicketAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAuthorizedRequestAsync(
|
||||
HttpMethod.Post,
|
||||
"api/v1/billing/ai-pack/customer-portal-session",
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto,
|
||||
"api/v1/store/collections-id-ticket",
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeWinoStoreCollectionsIdTicketInfo,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<WinoStoreCollectionsIdTicketInfo>> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAuthorizedRequestAsync(
|
||||
HttpMethod.Post,
|
||||
"api/v1/store/purchase-id-ticket",
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeWinoStoreCollectionsIdTicketInfo,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default)
|
||||
=> SendAuthorizedRequestAsync(
|
||||
HttpMethod.Post,
|
||||
"api/v1/store/entitlements/sync",
|
||||
new SyncStoreEntitlementsRequest(storeIdKey, purchaseIdKey),
|
||||
WinoAccountApiJsonContext.Default.SyncStoreEntitlementsRequest,
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
|
||||
@@ -471,7 +471,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
|
||||
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -542,12 +542,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
[JsonSerializable(typeof(SummarizeRequest))]
|
||||
[JsonSerializable(typeof(TranslateRequest))]
|
||||
[JsonSerializable(typeof(RewriteRequest))]
|
||||
[JsonSerializable(typeof(SyncStoreEntitlementsRequest))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<EmailConfirmationResendResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AiTextResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<WinoStoreCollectionsIdTicketInfo>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
|
||||
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
|
||||
|
||||
internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -11,7 +10,6 @@ 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.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
@@ -79,7 +77,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
|
||||
if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow)
|
||||
{
|
||||
return WinoAccountOperationResult.Success(account);
|
||||
}
|
||||
@@ -123,14 +121,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
}
|
||||
|
||||
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
||||
var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts)
|
||||
? cachedHasUnlimitedAccounts
|
||||
: (bool?)null;
|
||||
|
||||
if (hasUnlimitedAccounts.HasValue)
|
||||
{
|
||||
await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (AreEquivalentProfiles(account, refreshedAccount))
|
||||
{
|
||||
@@ -149,18 +139,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return account;
|
||||
}
|
||||
|
||||
public async Task<WinoAccountAddOnSnapshot?> GetCachedAddOnSnapshotAsync()
|
||||
{
|
||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cache = await GetAddOnCacheAsync(account.Id).ConfigureAwait(false);
|
||||
return cache == null ? null : Map(cache);
|
||||
}
|
||||
|
||||
public async Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||
@@ -176,7 +154,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return null;
|
||||
}
|
||||
|
||||
if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1))
|
||||
if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow)
|
||||
{
|
||||
return account;
|
||||
}
|
||||
@@ -193,16 +171,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
public async Task<bool> HasActiveAccountAsync()
|
||||
=> await Connection.Table<WinoAccount>().CountAsync().ConfigureAwait(false) > 0;
|
||||
|
||||
public async Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return productId switch
|
||||
{
|
||||
WinoAddOnProductType.AI_PACK => await HasAiPackAsync(cancellationToken).ConfigureAwait(false),
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS => await HasUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -221,11 +189,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
if (response.Result != null)
|
||||
{
|
||||
var refreshedAccount = MergeAccountProfile(account, response.Result);
|
||||
var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts)
|
||||
? cachedHasUnlimitedAccounts
|
||||
: (bool?)null;
|
||||
|
||||
await PersistProfileDataAsync(account, refreshedAccount, hasUnlimitedAccounts).ConfigureAwait(false);
|
||||
await PersistProfileDataAsync(account, refreshedAccount).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -246,11 +210,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.Result != null)
|
||||
{
|
||||
await PersistAddOnCacheAsync(account.Id, response.Result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -263,36 +222,65 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
public async Task<ApiEnvelope<AiTextResultDto>> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default)
|
||||
=> await ExecuteAiOperationAsync(account => _apiClient.RewriteAsync(html, mode, cancellationToken), "rewrite", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
|
||||
public async Task<ApiEnvelope<JsonElement>> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||
if (account == null || string.IsNullOrWhiteSpace(account.AccessToken))
|
||||
{
|
||||
return ApiEnvelope<CheckoutSessionResultDto>.Failure("MissingAccessToken");
|
||||
return ApiEnvelope<JsonElement>.Failure("MissingAccessToken");
|
||||
}
|
||||
|
||||
var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
|
||||
string? storeIdKey = null;
|
||||
string? purchaseIdKey = null;
|
||||
|
||||
var collectionsTicketResponse = await _apiClient.CreateCollectionsIdTicketAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (collectionsTicketResponse.IsSuccess && collectionsTicketResponse.Result != null)
|
||||
{
|
||||
storeIdKey = await _storeManagementService.GetCustomerCollectionsIdAsync(
|
||||
collectionsTicketResponse.Result.ServiceTicket,
|
||||
collectionsTicketResponse.Result.PublisherUserId).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storeIdKey))
|
||||
{
|
||||
_logger.Warning("Failed to obtain Microsoft Store collections ID key for Wino account {Email}.", account.Email);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("Failed to create Microsoft Store collections ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, collectionsTicketResponse.ErrorCode);
|
||||
}
|
||||
|
||||
var purchaseTicketResponse = await _apiClient.CreatePurchaseIdTicketAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (purchaseTicketResponse.IsSuccess && purchaseTicketResponse.Result != null)
|
||||
{
|
||||
purchaseIdKey = await _storeManagementService.GetCustomerPurchaseIdAsync(
|
||||
purchaseTicketResponse.Result.ServiceTicket,
|
||||
purchaseTicketResponse.Result.PublisherUserId).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(purchaseIdKey))
|
||||
{
|
||||
_logger.Warning("Failed to obtain Microsoft Store purchase ID key for Wino account {Email}.", account.Email);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("Failed to create Microsoft Store purchase ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, purchaseTicketResponse.ErrorCode);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storeIdKey) && string.IsNullOrWhiteSpace(purchaseIdKey))
|
||||
{
|
||||
return ApiEnvelope<JsonElement>.Failure("StoreEntitlementKeysMissing");
|
||||
}
|
||||
|
||||
var response = await _apiClient.SyncStoreEntitlementsAsync(storeIdKey, purchaseIdKey, 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);
|
||||
_logger.Warning("Failed to sync Microsoft Store entitlements for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||
return response;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
return ApiEnvelope<CustomerPortalResultDto>.Failure("MissingAccessToken");
|
||||
}
|
||||
|
||||
var response = await _apiClient.CreateCustomerPortalSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccess)
|
||||
{
|
||||
_logger.Warning("Failed to create customer portal session for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||
}
|
||||
await RefreshProfileAsync(cancellationToken).ConfigureAwait(false);
|
||||
await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -324,7 +312,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
|
||||
var refreshResult = await RefreshProfileAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (refreshResult.IsSuccess && await HasAddOnAsync(targetProductType.Value, cancellationToken).ConfigureAwait(false))
|
||||
if (refreshResult.IsSuccess && await _storeManagementService.HasProductAsync(targetProductType.Value).ConfigureAwait(false))
|
||||
{
|
||||
ReportUIChange(new WinoAccountAddOnPurchasedMessage(targetProductType.Value));
|
||||
return true;
|
||||
@@ -365,8 +353,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
}
|
||||
|
||||
await Connection.DeleteAllAsync<WinoAccount>().ConfigureAwait(false);
|
||||
await Connection.DeleteAllAsync<WinoAccountAddOnCache>().ConfigureAwait(false);
|
||||
|
||||
if (account != null)
|
||||
{
|
||||
ReportUIChange(new WinoAccountProfileDeletedMessage(account));
|
||||
@@ -395,29 +381,18 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount, bool? hasUnlimitedAccounts)
|
||||
private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount)
|
||||
{
|
||||
if (!AreEquivalentProfiles(originalAccount, refreshedAccount))
|
||||
{
|
||||
await PersistAccountAsync(refreshedAccount).ConfigureAwait(false);
|
||||
PublishProfileUpdated(refreshedAccount);
|
||||
}
|
||||
|
||||
if (hasUnlimitedAccounts.HasValue)
|
||||
{
|
||||
await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void PublishProfileUpdated(WinoAccount account)
|
||||
=> ReportUIChange(new WinoAccountProfileUpdatedMessage(account));
|
||||
|
||||
private async Task<bool> HasAiPackAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccess && response.Result?.HasAiPack == true;
|
||||
}
|
||||
|
||||
private async Task<ApiEnvelope<AiTextResultDto>> ExecuteAiOperationAsync(Func<WinoAccount, Task<ApiEnvelope<AiTextResultDto>>> executeAsync,
|
||||
string operationName,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -434,94 +409,9 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
_logger.Warning("Failed to {Operation} HTML with AI for Wino account {Email}. Error code: {ErrorCode}", operationName, account.Email, response.ErrorCode);
|
||||
}
|
||||
|
||||
if (response.Quota != null)
|
||||
{
|
||||
await PersistAddOnCacheAsync(account.Id, Map(response.Quota)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||
if (cachedSnapshot?.HasUnlimitedAccounts == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> HasRemoteUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccess || response.Result == null)
|
||||
{
|
||||
return (await GetCachedAddOnSnapshotAsync().ConfigureAwait(false))?.HasUnlimitedAccounts == true;
|
||||
}
|
||||
|
||||
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
||||
}
|
||||
|
||||
private async Task<WinoAccountAddOnCache?> GetAddOnCacheAsync(Guid accountId)
|
||||
=> await Connection.Table<WinoAccountAddOnCache>()
|
||||
.Where(cache => cache.AccountId == accountId)
|
||||
.FirstOrDefaultAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
private async Task PersistAddOnCacheAsync(Guid accountId, AiStatusResultDto? aiStatus = null, bool? hasUnlimitedAccounts = null)
|
||||
{
|
||||
var cache = await GetAddOnCacheAsync(accountId).ConfigureAwait(false) ?? new WinoAccountAddOnCache
|
||||
{
|
||||
AccountId = accountId
|
||||
};
|
||||
|
||||
if (aiStatus != null)
|
||||
{
|
||||
cache.HasAiPack = aiStatus.HasAiPack;
|
||||
cache.AiUsageCount = aiStatus.HasAiPack ? aiStatus.Used : null;
|
||||
cache.AiUsageLimit = aiStatus.HasAiPack ? aiStatus.MonthlyLimit : null;
|
||||
cache.AiBillingPeriodStartUtc = aiStatus.CurrentPeriodStartUtc?.UtcDateTime;
|
||||
cache.AiBillingPeriodEndUtc = aiStatus.CurrentPeriodEndUtc?.UtcDateTime;
|
||||
}
|
||||
|
||||
if (hasUnlimitedAccounts.HasValue)
|
||||
{
|
||||
cache.HasUnlimitedAccounts = hasUnlimitedAccounts.Value;
|
||||
}
|
||||
|
||||
cache.LastUpdatedUtc = DateTime.UtcNow;
|
||||
|
||||
await Connection.InsertOrReplaceAsync(cache, typeof(WinoAccountAddOnCache)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static WinoAccountAddOnSnapshot Map(WinoAccountAddOnCache cache)
|
||||
=> new(
|
||||
cache.HasAiPack,
|
||||
cache.AiUsageCount,
|
||||
cache.AiUsageLimit,
|
||||
cache.AiBillingPeriodStartUtc is DateTime periodStartUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodStartUtc, DateTimeKind.Utc)) : null,
|
||||
cache.AiBillingPeriodEndUtc is DateTime periodEndUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodEndUtc, DateTimeKind.Utc)) : null,
|
||||
cache.HasUnlimitedAccounts,
|
||||
new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc)));
|
||||
|
||||
private static AiStatusResultDto Map(QuotaInfoDto quota)
|
||||
=> new(
|
||||
quota.HasAiPack,
|
||||
quota.EntitlementStatus,
|
||||
quota.CurrentPeriodStartUtc,
|
||||
quota.CurrentPeriodEndUtc,
|
||||
quota.MonthlyLimit,
|
||||
quota.Used,
|
||||
quota.Remaining,
|
||||
quota.Product);
|
||||
|
||||
private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right)
|
||||
=> left.Id == right.Id &&
|
||||
string.Equals(left.Email, right.Email, StringComparison.Ordinal) &&
|
||||
@@ -546,21 +436,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
LastAuthenticatedUtc = existingAccount.LastAuthenticatedUtc
|
||||
};
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
|
||||
private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
|
||||
{
|
||||
value = false;
|
||||
|
||||
var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
|
||||
if (property?.PropertyType != typeof(bool))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (bool)(property.GetValue(instance) ?? false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WinoAddOnProductType? ResolveProductType(Uri callbackUri)
|
||||
{
|
||||
var productCode = GetQueryParameter(callbackUri, "productCode");
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
namespace Wino.Services;
|
||||
|
||||
public sealed class WinoAddOnService : IWinoAddOnService
|
||||
{
|
||||
private readonly IWinoAccountProfileService _profileService;
|
||||
|
||||
public WinoAddOnService(IWinoAccountProfileService profileService)
|
||||
{
|
||||
_profileService = profileService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cachedSnapshot = await _profileService.GetCachedAddOnSnapshotAsync().ConfigureAwait(false);
|
||||
|
||||
if (useCachedDataOnly)
|
||||
{
|
||||
return BuildAddOnInfos(cachedSnapshot);
|
||||
}
|
||||
|
||||
var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
|
||||
var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
|
||||
|
||||
await Task.WhenAll(aiStatusTask, hasUnlimitedAccountsTask).ConfigureAwait(false);
|
||||
|
||||
var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
|
||||
var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
|
||||
var hasUnlimitedAccounts = await hasUnlimitedAccountsTask.ConfigureAwait(false);
|
||||
|
||||
if (aiStatus == null && cachedSnapshot != null)
|
||||
{
|
||||
return BuildAddOnInfos(cachedSnapshot with
|
||||
{
|
||||
HasUnlimitedAccounts = hasUnlimitedAccounts || cachedSnapshot.HasUnlimitedAccounts
|
||||
});
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.AI_PACK,
|
||||
aiStatus?.HasAiPack == true,
|
||||
aiStatus?.Used,
|
||||
aiStatus?.MonthlyLimit,
|
||||
aiStatus?.HasAiPack == true && aiStatus.MonthlyLimit is int limit && limit > 0 && aiStatus.Used is int used
|
||||
? (double)used / limit * 100
|
||||
: 0,
|
||||
aiStatus?.CurrentPeriodEndUtc),
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||
hasUnlimitedAccounts || cachedSnapshot?.HasUnlimitedAccounts == true)
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WinoAddOnInfo> BuildAddOnInfos(WinoAccountAddOnSnapshot? snapshot)
|
||||
{
|
||||
if (snapshot == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.AI_PACK,
|
||||
snapshot.HasAiPack,
|
||||
snapshot.UsageCount,
|
||||
snapshot.UsageLimit,
|
||||
snapshot.HasAiPack && snapshot.UsageLimit is int limit && limit > 0 && snapshot.UsageCount is int used
|
||||
? (double)used / limit * 100
|
||||
: 0,
|
||||
snapshot.BillingPeriodEndUtc),
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||
snapshot.HasUnlimitedAccounts)
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user