Wino accounts settings.

This commit is contained in:
Burak Kaan Köse
2026-03-18 09:00:26 +01:00
parent 0d6da30a29
commit aee32228c2
26 changed files with 1220 additions and 27 deletions
+1
View File
@@ -144,6 +144,7 @@ private string searchQuery = string.Empty;
- 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.
- If you use `x:Load` in XAML, always give that `UIElement` an `x:Name`.
@@ -91,6 +91,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
// For updating account calendars asynchronously.
private SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1);
private bool _runtimeSubscriptionsAttached;
private bool _hasRegisteredPersistentRecipients;
public CalendarAppShellViewModel(IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
@@ -151,7 +152,12 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (!_hasRegisteredPersistentRecipients)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
}
AttachRuntimeSubscriptions();
var activationContext = parameters as ShellModeActivationContext;
@@ -177,8 +183,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
DetachRuntimeSubscriptions();
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
_ = ExecuteUIThread(() =>
+1
View File
@@ -36,6 +36,7 @@ public enum WinoPage
EmailTemplatesPage,
CreateEmailTemplatePage,
StoragePage,
WinoAccountManagementPage,
WelcomePageV2,
WelcomeHostPage,
ProviderSelectionPage,
@@ -1,6 +1,8 @@
#nullable enable
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
@@ -12,4 +14,8 @@ public interface IWinoAccountApiClient
Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
}
@@ -36,6 +36,11 @@ public static class SettingsNavigationInfoProvider
manageAccountsDescription,
"\uE77B",
searchKeywords: Translator.SettingsSearch_ManageAccounts_Keywords),
new(WinoPage.WinoAccountManagementPage,
Translator.WinoAccount_SettingsSection_Title,
Translator.WinoAccount_SettingsSection_Description,
"\uE77B",
searchKeywords: Translator.SettingsSearch_WinoAccount_Keywords),
new(null, Translator.SettingsOptions_GeneralSection, string.Empty, "\uE713", isSeparator: true),
new(WinoPage.AppPreferencesPage,
Translator.SettingsAppPreferences_Title,
@@ -130,6 +135,7 @@ public static class SettingsNavigationInfoProvider
WinoPage.SettingOptionsPage => Translator.MenuSettings,
WinoPage.ManageAccountsPage => Translator.SettingsManageAccountSettings_Title,
WinoPage.AccountManagementPage => Translator.SettingsManageAccountSettings_Title,
WinoPage.WinoAccountManagementPage => Translator.WinoAccount_SettingsSection_Title,
WinoPage.PersonalizationPage => Translator.SettingsPersonalization_Title,
WinoPage.AboutPage => Translator.SettingsAbout_Title,
WinoPage.MessageListPage => Translator.SettingsMessageList_Title,
@@ -829,6 +829,12 @@
"SettingsHome_StorageCard_Description": "See how much local MIME content Wino keeps on this device and clean it up when needed.",
"SettingsHome_StorageEmptySummary": "No cached MIME content detected yet.",
"SettingsHome_StorageLoading": "Checking local MIME usage...",
"SettingsHome_WinoAccount_Title": "Wino Account",
"SettingsHome_WinoAccount_SignedOutDescription": "Sign in to sync settings across devices and access add-ons like Wino AI Pack.",
"SettingsHome_WinoAccount_ManageLink": "Manage Wino Account",
"SettingsHome_AiPack_Title": "Wino AI Pack",
"SettingsHome_SettingsSync_Title": "Settings sync",
"SettingsHome_SettingsSync_Description": "Export or import your preferences to keep them in sync across devices.",
"SettingsHome_Tips_Title": "Tips & tricks",
"SettingsHome_Tips_Description": "A few small changes can make Wino feel much more personal.",
"SettingsHome_Tip_Theme": "Want dark mode or accent changes? Open Personalization.",
@@ -849,6 +855,32 @@
"SettingsSearch_Personalization_Keywords": "theme;dark;light;appearance;accent;color;colour;mode;layout;density",
"SettingsSearch_About_Keywords": "about;version;website;privacy;github;donate;store;support",
"SettingsSearch_KeyboardShortcuts_Keywords": "shortcut;shortcuts;hotkey;hotkeys;keyboard;keys",
"SettingsSearch_WinoAccount_Keywords": "winoaccount;account;accounts;wino",
"WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized settings.",
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Account",
"WinoAccount_Management_SignedOutDescription": "Sign in or create a Wino Account to sync your settings and manage Wino add-ons.",
"WinoAccount_Management_AccountCardTitle": "Account",
"WinoAccount_Management_AccountCardDescription": "Your Wino Account email address and current account state.",
"WinoAccount_Management_AiPackCardTitle": "Wino AI Pack",
"WinoAccount_Management_AiPackCardDescription": "See whether Wino AI Pack is active and how much usage is left.",
"WinoAccount_Management_AiPackActive": "AI Pack is active",
"WinoAccount_Management_AiPackInactive": "AI Pack is not active",
"WinoAccount_Management_AiPackUsage": "{0} of {1} uses consumed. {2} remaining.",
"WinoAccount_Management_AiPackBillingPeriod": "Billing period: {0:d} - {1:d}",
"WinoAccount_Management_AiPackUnknownUsage": "Usage details are not available yet.",
"WinoAccount_Management_AiPackBuyDescription": "Buy Wino AI Pack to translate, rewrite or summarize emails with AI.",
"WinoAccount_Management_SettingsCardTitle": "Settings sync",
"WinoAccount_Management_SettingsCardDescription": "Export your current settings to your Wino Account or import them back on this device.",
"WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.",
"WinoAccount_Management_ImportSucceeded": "Imported {0} settings from your Wino Account.",
"WinoAccount_Management_ImportPartial": "Imported {0} settings. {1} settings could not be restored.",
"WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.",
"WinoAccount_Management_EmptyExport": "There are no preference values to export.",
"WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.",
"WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
"SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
@@ -16,13 +16,19 @@ using Wino.Core.Domain.Models.Settings;
using Wino.Core.Domain.Models.Translations;
using Wino.Core.Extensions;
using Wino.Core.ViewModels.Data;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.UI;
namespace Wino.Core.ViewModels;
public partial class SettingOptionsPageViewModel : CoreBaseViewModel
public partial class SettingOptionsPageViewModel : CoreBaseViewModel,
IRecipient<WinoAccountSignedInMessage>,
IRecipient<WinoAccountSignedOutMessage>
{
private const string BuyAiPackUrl = "https://example.com/wino-ai-pack";
private readonly INativeAppService _nativeAppService;
private readonly IAccountService _accountService;
private readonly IMimeStorageService _mimeStorageService;
@@ -31,6 +37,9 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
private readonly INewThemeService _newThemeService;
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;
@@ -82,6 +91,43 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
[ObservableProperty]
public partial bool UseAccentColor { get; set; }
// Wino Account hero card properties
[ObservableProperty]
public partial bool IsWinoAccountBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWinoAccountSignedOut))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsWinoAccountSignedIn { get; set; }
[ObservableProperty]
public partial string WinoAccountEmail { get; set; } = string.Empty;
[ObservableProperty]
public partial string WinoAccountStatusText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowAiUsage))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool HasAiPack { get; set; }
[ObservableProperty]
public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive;
[ObservableProperty]
public partial string AiUsageSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiBillingPeriodSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial double AiUsagePercent { get; set; }
public bool IsWinoAccountSignedOut => !IsWinoAccountSignedIn;
public bool CanShowAiUsage => HasAiPack;
public bool CanShowBuyAiPack => IsWinoAccountSignedIn && !HasAiPack;
public SettingOptionsPageViewModel(INativeAppService nativeAppService,
IAccountService accountService,
IMimeStorageService mimeStorageService,
@@ -89,7 +135,10 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
ITranslationService translationService,
INewThemeService newThemeService,
IPreferencesService preferencesService,
IProviderService providerService)
IProviderService providerService,
IWinoAccountProfileService profileService,
IWinoAccountApiClient apiClient,
IMailDialogService dialogService)
{
_nativeAppService = nativeAppService;
_accountService = accountService;
@@ -99,6 +148,9 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
_newThemeService = newThemeService;
_preferencesService = preferencesService;
_providerService = providerService;
_profileService = profileService;
_apiClient = apiClient;
_dialogService = dialogService;
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -112,6 +164,7 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
InitializeQuickSettings();
_ = LoadDashboardAsync();
_ = LoadWinoAccountAsync();
}
public void UpdateSearchSuggestions(string query)
@@ -365,6 +418,181 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
await _translationService.InitializeLanguageAsync(language.Language);
}
// Wino Account message recipients
protected override void RegisterRecipients()
{
base.RegisterRecipients();
Messenger.Register<WinoAccountSignedInMessage>(this);
Messenger.Register<WinoAccountSignedOutMessage>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<WinoAccountSignedInMessage>(this);
Messenger.Unregister<WinoAccountSignedOutMessage>(this);
}
public void Receive(WinoAccountSignedInMessage message)
=> _ = LoadWinoAccountAsync();
public void Receive(WinoAccountSignedOutMessage message)
=> _ = ResetWinoAccountStateAsync();
// Wino Account hero card commands and helpers
[RelayCommand]
private async Task WinoAccountRegisterAsync()
{
var account = await _dialogService.ShowWinoAccountRegistrationDialogAsync();
if (account == null) return;
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_RegisterSuccessMessage, account.Email),
InfoBarMessageType.Success);
await LoadWinoAccountAsync();
}
[RelayCommand]
private async Task WinoAccountSignInAsync()
{
var account = await _dialogService.ShowWinoAccountLoginDialogAsync();
if (account == null) return;
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_LoginSuccessMessage, account.Email),
InfoBarMessageType.Success);
await LoadWinoAccountAsync();
}
[RelayCommand]
private async Task WinoAccountSignOutAsync()
{
var account = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
if (account == null) return;
await _profileService.SignOutAsync().ConfigureAwait(false);
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_SignOut_SuccessMessage, account.Email),
InfoBarMessageType.Success);
await ResetWinoAccountStateAsync();
}
[RelayCommand]
private async Task OpenBuyAiPackPageAsync() => await _nativeAppService.LaunchUriAsync(new Uri(BuyAiPackUrl));
[RelayCommand]
private void NavigateToWinoAccountManagement()
=> NavigateSubDetail(WinoPage.WinoAccountManagementPage);
private async Task LoadWinoAccountAsync()
{
await ExecuteUIThread(() => IsWinoAccountBusy = true);
try
{
var account = await EnsureAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
await ResetWinoAccountStateAsync();
return;
}
var currentUserResponse = await _apiClient.GetCurrentUserAsync().ConfigureAwait(false);
var aiStatusResponse = await _apiClient.GetAiStatusAsync().ConfigureAwait(false);
var resolvedEmail = currentUserResponse.IsSuccess && currentUserResponse.Result != null
? currentUserResponse.Result.Email
: account.Email;
var resolvedStatus = currentUserResponse.IsSuccess && currentUserResponse.Result != null
? currentUserResponse.Result.AccountStatus
: account.AccountStatus;
await ExecuteUIThread(() =>
{
IsWinoAccountSignedIn = true;
WinoAccountEmail = resolvedEmail;
WinoAccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedStatus);
});
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
}
catch
{
await ResetWinoAccountStateAsync();
}
finally
{
await ExecuteUIThread(() => IsWinoAccountBusy = false);
}
}
private async Task ResetWinoAccountStateAsync()
{
await ExecuteUIThread(() =>
{
IsWinoAccountSignedIn = false;
WinoAccountEmail = string.Empty;
WinoAccountStatusText = string.Empty;
HasAiPack = false;
AiPackStateText = Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = string.Empty;
AiBillingPeriodSummary = string.Empty;
AiUsagePercent = 0;
});
}
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;
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
var billingText = string.Empty;
var usagePercent = 0d;
if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
{
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
usagePercent = limit > 0 ? (double)used / limit * 100 : 0;
}
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
{
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
}
_ = ExecuteUIThread(() =>
{
HasAiPack = hasAiPack;
AiPackStateText = hasAiPack
? Translator.WinoAccount_Management_AiPackActive
: Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = hasAiPack ? usageText : string.Empty;
AiBillingPeriodSummary = hasAiPack ? billingText : string.Empty;
AiUsagePercent = usagePercent;
});
}
[RelayCommand]
public void NavigateSubDetail(object type)
{
@@ -0,0 +1,554 @@
#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 Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
namespace Wino.Core.ViewModels;
public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel
{
private const string BuyAiPackUrl = "https://example.com/wino-ai-pack";
private readonly IWinoAccountProfileService _profileService;
private readonly IWinoAccountApiClient _apiClient;
private readonly IPreferencesService _preferencesService;
private readonly IMailDialogService _dialogService;
private readonly INativeAppService _nativeAppService;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSignedOut))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsSignedIn { get; set; }
[ObservableProperty]
public partial string AccountEmail { get; set; } = string.Empty;
[ObservableProperty]
public partial string AccountStatusText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowAiUsage))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool HasAiPack { get; set; }
[ObservableProperty]
public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive;
[ObservableProperty]
public partial string AiUsageSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiBillingPeriodSummary { get; set; } = string.Empty;
public bool IsSignedOut => !IsSignedIn;
public bool CanShowAiUsage => HasAiPack;
public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
IWinoAccountApiClient apiClient,
IPreferencesService preferencesService,
IMailDialogService dialogService,
INativeAppService nativeAppService)
{
_profileService = profileService;
_apiClient = apiClient;
_preferencesService = preferencesService;
_dialogService = dialogService;
_nativeAppService = nativeAppService;
}
public override void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
_ = LoadAsync();
}
[RelayCommand]
private async Task RegisterAsync()
{
var account = await _dialogService.ShowWinoAccountRegistrationDialogAsync();
if (account == null)
{
return;
}
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_RegisterSuccessMessage, account.Email),
InfoBarMessageType.Success);
await LoadAsync();
}
[RelayCommand]
private async Task SignInAsync()
{
var account = await _dialogService.ShowWinoAccountLoginDialogAsync();
if (account == null)
{
return;
}
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_LoginSuccessMessage, account.Email),
InfoBarMessageType.Success);
await LoadAsync();
}
[RelayCommand]
private async Task SignOutAsync()
{
var account = await _profileService.GetActiveAccountAsync().ConfigureAwait(false);
if (account == null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_SignOut_NoAccountMessage,
InfoBarMessageType.Warning);
return;
}
await _profileService.SignOutAsync().ConfigureAwait(false);
_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));
[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);
}
}
[RelayCommand]
private async Task ImportSettingsAsync()
{
try
{
if (await EnsureAuthenticatedAccountAsync().ConfigureAwait(false) == null)
{
return;
}
var settingsJson = await _apiClient.GetSettingsAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(settingsJson))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_NoRemoteSettings,
InfoBarMessageType.Warning);
return;
}
using var document = JsonDocument.Parse(settingsJson);
if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.EnumerateObject().Any())
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_ImportEmpty,
InfoBarMessageType.Warning);
return;
}
var (appliedCount, failedCount) = ApplyPreferencesSnapshot(document.RootElement);
if (appliedCount == 0)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_ImportEmpty,
InfoBarMessageType.Warning);
return;
}
if (failedCount > 0)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
string.Format(Translator.WinoAccount_Management_ImportPartial, appliedCount, failedCount),
InfoBarMessageType.Warning);
return;
}
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info,
string.Format(Translator.WinoAccount_Management_ImportSucceeded, appliedCount),
InfoBarMessageType.Success);
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_ActionFailed,
InfoBarMessageType.Error);
}
}
private async Task LoadAsync()
{
await ExecuteUIThread(() => IsBusy = true);
try
{
var account = await EnsureAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
await ResetSignedOutStateAsync();
return;
}
var currentUserResponse = await _apiClient.GetCurrentUserAsync().ConfigureAwait(false);
var aiStatusResponse = await _apiClient.GetAiStatusAsync().ConfigureAwait(false);
var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null
? currentUserResponse.Result
: new AuthUserDto(account.Id, account.Email, account.AccountStatus, account.HasPassword, account.HasGoogleLogin, account.HasFacebookLogin);
await ExecuteUIThread(() =>
{
IsSignedIn = true;
AccountEmail = resolvedUser.Email;
AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus);
});
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Warning);
}
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Error);
await ResetSignedOutStateAsync();
}
finally
{
await ExecuteUIThread(() => IsBusy = false);
}
}
private async Task ResetSignedOutStateAsync()
{
await ExecuteUIThread(() =>
{
IsSignedIn = false;
AccountEmail = string.Empty;
AccountStatusText = string.Empty;
HasAiPack = false;
AiPackStateText = Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = string.Empty;
AiBillingPeriodSummary = string.Empty;
});
}
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;
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
var billingText = string.Empty;
if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
{
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
}
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
{
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
}
_ = ExecuteUIThread(() =>
{
HasAiPack = hasAiPack;
AiPackStateText = hasAiPack
? Translator.WinoAccount_Management_AiPackActive
: Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = hasAiPack ? usageText : string.Empty;
AiBillingPeriodSummary = hasAiPack ? billingText : 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;
}
}
}
+12 -3
View File
@@ -30,6 +30,7 @@ namespace Wino.Mail.ViewModels;
public partial class MailAppShellViewModel : MailBaseViewModel,
IMailShellClient,
IRecipient<AccountCreatedMessage>,
IRecipient<MailtoProtocolMessageRequested>,
IRecipient<RefreshUnreadCountsMessage>,
IRecipient<AccountsMenuRefreshRequested>,
@@ -86,6 +87,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
private readonly INativeAppService _nativeAppService;
private readonly IMailService _mailService;
private bool _hasRegisteredPersistentRecipients;
private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1);
@@ -226,7 +228,11 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
if (!_hasRegisteredPersistentRecipients)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
}
var activationContext = parameters as ShellModeActivationContext;
var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true;
@@ -269,8 +275,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
base.OnNavigatedFrom(mode, parameters);
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
}
@@ -1155,6 +1159,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
Messenger.Register<AccountRemovedMessage>(this);
Messenger.Register<AccountUpdatedMessage>(this);
Messenger.Register<AccountCreatedMessage>(this);
Messenger.Register<MailtoProtocolMessageRequested>(this);
Messenger.Register<RefreshUnreadCountsMessage>(this);
Messenger.Register<AccountsMenuRefreshRequested>(this);
@@ -1172,6 +1177,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
Messenger.Unregister<AccountRemovedMessage>(this);
Messenger.Unregister<AccountUpdatedMessage>(this);
Messenger.Unregister<AccountCreatedMessage>(this);
Messenger.Unregister<MailtoProtocolMessageRequested>(this);
Messenger.Unregister<RefreshUnreadCountsMessage>(this);
Messenger.Unregister<AccountsMenuRefreshRequested>(this);
@@ -1195,6 +1201,9 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
await RestoreSelectedAccountAfterMenuRefreshAsync(false);
}
public async void Receive(AccountCreatedMessage message)
=> await HandleAccountCreatedAsync(message.Account);
public async Task HandleAccountCreatedAsync(MailAccount createdAccount)
{
latestSelectedAccountMenuItem = null;
+3 -1
View File
@@ -23,14 +23,15 @@ using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.ViewModels;
using Wino.Mail.Services;
using Wino.Mail.WinUI.ViewModels;
using Wino.Mail.ViewModels;
using Wino.Mail.ViewModels.Data;
using Wino.Mail.WinUI.Activation;
using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.WinUI.Models;
using Wino.Mail.WinUI.Services;
using Wino.Mail.WinUI.ViewModels;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
@@ -174,6 +175,7 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(LanguageTimePageViewModel));
services.AddTransient(typeof(AppPreferencesPageViewModel));
services.AddTransient(typeof(StoragePageViewModel));
services.AddTransient(typeof(WinoAccountManagementPageViewModel));
services.AddTransient(typeof(AliasManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel));
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
@@ -89,6 +89,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.EmailTemplatesPage,
WinoPage.CreateEmailTemplatePage,
WinoPage.StoragePage,
WinoPage.WinoAccountManagementPage,
WinoPage.CalendarSettingsPage,
WinoPage.CalendarAccountSettingsPage
];
@@ -155,6 +156,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.EmailTemplatesPage => typeof(EmailTemplatesPage),
WinoPage.CreateEmailTemplatePage => typeof(CreateEmailTemplatePage),
WinoPage.StoragePage => typeof(StoragePage),
WinoPage.WinoAccountManagementPage => typeof(WinoAccountManagementPage),
WinoPage.WelcomeHostPage => typeof(WelcomeHostPage),
WinoPage.ProviderSelectionPage => typeof(ProviderSelectionPage),
WinoPage.AccountSetupProgressPage => typeof(AccountSetupProgressPage),
@@ -35,7 +35,7 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
public bool IsBackButtonVisible =>
ApplicationMode == WinoApplicationMode.Mail
? (IsReadingMail && IsReaderNarrowed) || HasCurrentModeBackStack
: IsEventDetailsVisible || HasCurrentModeBackStack;
: HasCurrentModeBackStack;
private WinoApplicationMode applicationMode = WinoApplicationMode.Mail;
@@ -20,6 +20,8 @@ public partial class SettingsShellClient(INavigationService navigationService) :
IRecipient<ActiveSettingsPageChanged>,
IRecipient<LanguageChanged>
{
private bool _hasRegisteredPersistentRecipients;
public WinoApplicationMode Mode => WinoApplicationMode.Settings;
public MenuItemCollection? MenuItems { get; private set; }
@@ -37,18 +39,22 @@ public partial class SettingsShellClient(INavigationService navigationService) :
public void Activate(ShellModeActivationContext activationContext)
{
if (!_hasRegisteredPersistentRecipients)
{
RegisterRecipients();
_hasRegisteredPersistentRecipients = true;
}
RebuildMenuItems();
var targetPage = activationContext.Parameter as WinoPage? ?? WinoPage.SettingOptionsPage;
SetSelectedRootPage(SettingsNavigationInfoProvider.GetRootPage(targetPage));
OnNavigatedTo(NavigationMode.New, activationContext);
navigationService.Navigate(WinoPage.SettingsPage, targetPage, NavigationReferenceFrame.InnerShellFrame);
}
public void Deactivate()
{
OnNavigatedFrom(NavigationMode.New, null!);
}
public Task HandleNavigationItemInvokedAsync(IMenuItem? menuItem)
@@ -0,0 +1,7 @@
using Wino.Core.ViewModels;
namespace Wino.Views.Abstract;
public abstract class WinoAccountManagementPageAbstract : SettingsPageBase<WinoAccountManagementPageViewModel>
{
}
@@ -180,6 +180,8 @@
MenuItemsSource="{x:Bind ViewModel.MenuItems, Mode=OneWay}"
OpenPaneLength="{x:Bind ViewModel.StatePersistenceService.OpenPaneLength, Mode=TwoWay}"
PaneDisplayMode="Auto"
PaneOpened="NavigationPaneOpened"
PaneClosed="NavigationPaneClosed"
Style="{StaticResource CalendarShellNavigationViewStyle}">
<muxc:NavigationView.PaneCustomContent>
<Grid x:Name="PaneCustomContent" Padding="0,0,0,6">
@@ -79,6 +79,12 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract,
private void NavigationViewDisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
=> UpdateNavigationPaneLayout(args.DisplayMode);
private void NavigationPaneOpened(NavigationView sender, object args)
=> UpdateNavigationPaneLayout(sender.DisplayMode);
private void NavigationPaneClosed(NavigationView sender, object args)
=> UpdateNavigationPaneLayout(sender.DisplayMode);
private Task InvokeNewCalendarEventAsync()
=> ViewModel.HandleNavigationItemInvokedAsync(new NewCalendarEventMenuItem());
+103 -13
View File
@@ -80,22 +80,18 @@
</TransitionCollection>
</StackPanel.ChildrenTransitions>
<!-- About hero card. -->
<!-- App About hero card -->
<Border
Padding="24,20"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid ColumnSpacing="16" RowSpacing="16">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
VerticalAlignment="Center"
@@ -108,12 +104,8 @@
Source="ms-appx:///Assets/AppEntries/MailAssets/Square150x150Logo.scale-100.png"
Stretch="Uniform" />
<StackPanel VerticalAlignment="Center">
<TextBlock Style="{StaticResource TitleTextBlockStyle}" Text="Wino Mail" />
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource TitleTextBlockStyle}"
Text="Wino Mail" />
<TextBlock
VerticalAlignment="Top"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.VersionText, Mode=OneWay}" />
@@ -138,7 +130,6 @@
Stretch="Uniform" />
</Viewbox>
</Button>
<Button
Command="{x:Bind ViewModel.NavigateExternalCommand}"
CommandParameter="{x:Bind ViewModel.GitHubUrl, Mode=OneWay}"
@@ -151,7 +142,6 @@
Stretch="Uniform" />
</Viewbox>
</Button>
<Button
Command="{x:Bind ViewModel.NavigateExternalCommand}"
CommandParameter="Store"
@@ -167,6 +157,106 @@
</Grid>
</Border>
<!-- Loading indicator -->
<ProgressRing
x:Name="WinoAccountBusyRing"
Width="24"
Height="24"
HorizontalAlignment="Center"
x:Load="{x:Bind ViewModel.IsWinoAccountBusy, Mode=OneWay}"
IsActive="True" />
<!-- Wino Account: Signed-out state -->
<controls:SettingsCard
x:Name="SignedOutCard"
Description="{x:Bind domain:Translator.SettingsHome_WinoAccount_SignedOutDescription}"
Header="{x:Bind domain:Translator.SettingsHome_WinoAccount_Title}"
x:Load="{x:Bind ViewModel.IsWinoAccountSignedOut, Mode=OneWay}">
<controls:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE77B;" />
</controls:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
Command="{x:Bind ViewModel.WinoAccountSignInCommand}"
Content="{x:Bind domain:Translator.Buttons_SignIn}"
Style="{StaticResource AccentButtonStyle}" />
<Button Command="{x:Bind ViewModel.WinoAccountRegisterCommand}" Content="{x:Bind domain:Translator.Buttons_CreateAccount}" />
</StackPanel>
</controls:SettingsCard>
<!-- Wino Account: Signed-in state -->
<controls:SettingsExpander
x:Name="SignedInExpander"
Description="{x:Bind ViewModel.WinoAccountStatusText, Mode=OneWay}"
Header="{x:Bind ViewModel.WinoAccountEmail, Mode=OneWay}"
x:Load="{x:Bind ViewModel.IsWinoAccountSignedIn, Mode=OneWay}">
<controls:SettingsExpander.HeaderIcon>
<FontIcon Glyph="&#xE77B;" />
</controls:SettingsExpander.HeaderIcon>
<Button
Command="{x:Bind ViewModel.WinoAccountSignOutCommand}"
Content="{x:Bind domain:Translator.WinoAccount_SignOutButton_Action}" />
<controls:SettingsExpander.Items>
<!-- AI Pack active (with progress bar) -->
<controls:SettingsCard
x:Name="AiPackActiveCard"
Description="{x:Bind ViewModel.AiUsageSummary, Mode=OneWay}"
Header="{x:Bind domain:Translator.SettingsHome_AiPack_Title}"
x:Load="{x:Bind ViewModel.CanShowAiUsage, Mode=OneWay}">
<controls:SettingsCard.HeaderIcon>
<ImageIcon Source="ms-appx:///Assets/AIPackIcon.png" />
</controls:SettingsCard.HeaderIcon>
<StackPanel MinWidth="200" Spacing="4">
<ProgressBar
Height="8"
Maximum="100"
Value="{x:Bind ViewModel.AiUsagePercent, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AiBillingPeriodSummary, Mode=OneWay}" />
</StackPanel>
</controls:SettingsCard>
<!-- AI Pack not purchased -->
<controls:SettingsCard
x:Name="AiPackBuyCard"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackBuyDescription}"
Header="{x:Bind domain:Translator.SettingsHome_AiPack_Title}"
x:Load="{x:Bind ViewModel.CanShowBuyAiPack, Mode=OneWay}">
<controls:SettingsCard.HeaderIcon>
<ImageIcon Source="ms-appx:///Assets/AIPackIcon.png" />
</controls:SettingsCard.HeaderIcon>
<Button
Command="{x:Bind ViewModel.OpenBuyAiPackPageCommand}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
Style="{StaticResource AccentButtonStyle}" />
</controls:SettingsCard>
<!-- Settings sync -->
<controls:SettingsCard
Click="WinoAccountManagementClicked"
Description="{x:Bind domain:Translator.SettingsHome_SettingsSync_Description}"
Header="{x:Bind domain:Translator.SettingsHome_SettingsSync_Title}"
IsClickEnabled="True">
<controls:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE895;" />
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
<!-- Manage Wino Account -->
<controls:SettingsCard
Click="WinoAccountManagementClicked"
Header="{x:Bind domain:Translator.SettingsHome_WinoAccount_ManageLink}"
IsClickEnabled="True">
<controls:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE713;" />
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<Border
Padding="12,0,34,20"
@@ -51,6 +51,11 @@ public sealed partial class SettingOptionsPage : SettingOptionsPageAbstract
ViewModel.NavigateToAddAccount();
}
private void WinoAccountManagementClicked(object sender, RoutedEventArgs e)
{
ViewModel.NavigateToWinoAccountManagementCommand.Execute(null);
}
private void SettingsSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput || string.IsNullOrWhiteSpace(sender.Text))
@@ -0,0 +1,107 @@
<abstract:WinoAccountManagementPageAbstract
x:Class="Wino.Views.Settings.WinoAccountManagementPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="{x:Bind domain:Translator.WinoAccount_SettingsSection_Title}"
mc:Ignorable="d">
<ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<TextBlock
Margin="0,0,0,8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_Description}" />
<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"
x:Load="{x:Bind ViewModel.IsSignedOut, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_SignedOutDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_SignedOutTitle}">
<controls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Contact" />
</controls:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Command="{x:Bind ViewModel.RegisterCommand}" Content="{x:Bind domain:Translator.Buttons_CreateAccount}" />
<Button Command="{x:Bind ViewModel.SignInCommand}" Content="{x:Bind domain:Translator.Buttons_SignIn}" />
</StackPanel>
</controls:SettingsCard>
</StackPanel>
<StackPanel
x:Name="SignedInPanel"
x:Load="{x:Bind ViewModel.IsSignedIn, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_AccountCardDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_AccountCardTitle}">
<controls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Contact" />
</controls:SettingsCard.HeaderIcon>
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.AccountEmail, Mode=OneWay}" />
<TextBlock Text="{x:Bind ViewModel.AccountStatusText, Mode=OneWay}" />
<Button
HorizontalAlignment="Left"
Command="{x:Bind ViewModel.SignOutCommand}"
Content="{x:Bind domain:Translator.WinoAccount_SignOutButton_Action}" />
</StackPanel>
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackCardDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_AiPackCardTitle}">
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.AiPackStateText, Mode=OneWay}" />
<TextBlock
x:Name="CanShowAITextBlock"
x:Load="{x:Bind ViewModel.CanShowAiUsage, Mode=OneWay}"
Text="{x:Bind ViewModel.AiUsageSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
x:Name="BillingBlock"
x:Load="{x:Bind ViewModel.CanShowAiUsage, Mode=OneWay}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.AiBillingPeriodSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
x:Name="CanBuyAIPack"
x:Load="{x:Bind ViewModel.CanShowBuyAiPack, Mode=OneWay}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackBuyDescription}"
TextWrapping="WrapWholeWords" />
<Button
x:Name="OpenBuyPageButton"
HorizontalAlignment="Left"
x:Load="{x:Bind ViewModel.CanShowBuyAiPack, Mode=OneWay}"
Command="{x:Bind ViewModel.OpenBuyPageCommand}"
Content="{x:Bind domain:Translator.Buttons_Purchase}" />
</StackPanel>
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_SettingsCardDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_SettingsCardTitle}">
<controls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Sync" />
</controls:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Command="{x:Bind ViewModel.ExportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Export}" />
<Button Command="{x:Bind ViewModel.ImportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Import}" />
</StackPanel>
</controls:SettingsCard>
</StackPanel>
</StackPanel>
</ScrollViewer>
</abstract:WinoAccountManagementPageAbstract>
@@ -0,0 +1,11 @@
using Wino.Views.Abstract;
namespace Wino.Views.Settings;
public sealed partial class WinoAccountManagementPage : WinoAccountManagementPageAbstract
{
public WinoAccountManagementPage()
{
InitializeComponent();
}
}
@@ -61,6 +61,13 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
manageAccountsEntry.Title = Translator.SettingsManageAccountSettings_Title;
}
var winoAccountEntry = PageHistory.FirstOrDefault(a => a.Request.PageType == WinoPage.WinoAccountManagementPage);
if (winoAccountEntry != null)
{
winoAccountEntry.Title = Translator.WinoAccount_SettingsSection_Title;
}
_ = RefreshCurrentPageStateAsync();
UpdateWindowTitle();
}
+2
View File
@@ -525,6 +525,8 @@
OpenPaneLength="{x:Bind ViewModel.StatePersistenceService.OpenPaneLength, Mode=TwoWay}"
PaneDisplayMode="Auto"
PaneOpening="NavigationPaneOpening"
PaneOpened="NavigationPaneOpened"
PaneClosed="NavigationPaneClosed"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectionChanged="MenuSelectionChanged"
Style="{StaticResource CalendarShellNavigationViewStyle}">
@@ -471,6 +471,12 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract,
}
}
private void NavigationPaneOpened(NavigationView sender, object args)
=> UpdateNavigationPaneLayout(sender.DisplayMode);
private void NavigationPaneClosed(NavigationView sender, object args)
=> UpdateNavigationPaneLayout(sender.DisplayMode);
private void NavigationViewDisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
=> UpdateNavigationPaneLayout(args.DisplayMode);
+1
View File
@@ -98,6 +98,7 @@
<Content Remove="Assets\AppEntries\CalendarAssets\Square44x44Logo.altform-unplated_targetsize-24.png" />
</ItemGroup>
<ItemGroup>
<None Remove="Assets\AIPackIcon.png" />
<None Remove="Assets\CalendarWide310x150Logo.png" />
<None Remove="Assets\UpdateNotes\Images\Calendar.svg" />
<None Remove="Assets\UpdateNotes\Images\Security.svg" />
+99 -1
View File
@@ -1,14 +1,18 @@
#nullable enable
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
@@ -17,10 +21,13 @@ namespace Wino.Services;
public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly IDatabaseService _databaseService;
private readonly bool _ownsHttpClient;
public WinoAccountApiClient(HttpClient? httpClient = null)
public WinoAccountApiClient(IDatabaseService databaseService, HttpClient? httpClient = null)
{
_databaseService = databaseService;
if (httpClient != null)
{
_httpClient = httpClient;
@@ -71,6 +78,55 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
public Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/auth/me", WinoAccountApiJsonContext.Default.ApiEnvelopeAuthUserDto, cancellationToken);
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
{
using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings").ConfigureAwait(false);
if (request == null)
return null;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
return null;
if (!response.IsSuccessStatusCode)
return null;
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
public async Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default)
{
try
{
using var request = await CreateAuthorizedRequestAsync(HttpMethod.Put, "api/v1/users/me/settings").ConfigureAwait(false);
if (request == null)
return false;
request.Content = new StringContent(settingsJson, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
private async Task<ApiEnvelope<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
{
try
@@ -94,6 +150,46 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
private async Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
{
try
{
using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, endpoint).ConfigureAwait(false);
if (request == null)
return ApiEnvelope<TResponse>.Failure("MissingAccessToken");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var envelope = string.IsNullOrWhiteSpace(payload)
? null
: JsonSerializer.Deserialize(payload, typeInfo);
return envelope ?? ApiEnvelope<TResponse>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
}
catch (Exception ex)
{
return ApiEnvelope<TResponse>.Failure(ex.Message);
}
}
private async Task<HttpRequestMessage?> CreateAuthorizedRequestAsync(HttpMethod method, string endpoint)
{
var accessToken = await GetAccessTokenAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(accessToken))
return null;
var request = new HttpRequestMessage(method, endpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return request;
}
private async Task<string?> GetAccessTokenAsync()
{
var account = await _databaseService.Connection.Table<WinoAccount>().FirstOrDefaultAsync().ConfigureAwait(false);
return string.IsNullOrWhiteSpace(account?.AccessToken) ? null : account.AccessToken;
}
private static bool ValidateCertificate(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, System.Net.Security.SslPolicyErrors sslPolicyErrors)
{
if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true)
@@ -119,5 +215,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(RefreshRequest))]
[JsonSerializable(typeof(LogoutRequest))]
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;