diff --git a/AGENTS.md b/AGENTS.md
index fe235e7a..fab8fafb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 17bcfc91..6cd46441 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -76,4 +76,4 @@
-
\ No newline at end of file
+
diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
index c9593a8a..c23ab658 100644
--- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
@@ -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(() =>
diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs
index 15c3dedf..10fbb392 100644
--- a/Wino.Core.Domain/Enums/WinoPage.cs
+++ b/Wino.Core.Domain/Enums/WinoPage.cs
@@ -36,6 +36,7 @@ public enum WinoPage
EmailTemplatesPage,
CreateEmailTemplatePage,
StoragePage,
+ WinoAccountManagementPage,
WelcomePageV2,
WelcomeHostPage,
ProviderSelectionPage,
diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
index ce018924..9148d999 100644
--- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
+++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
@@ -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> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
+ Task> GetCurrentUserAsync(CancellationToken cancellationToken = default);
+ Task> GetAiStatusAsync(CancellationToken cancellationToken = default);
+ Task GetSettingsAsync(CancellationToken cancellationToken = default);
+ Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
}
diff --git a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
index ba890092..e77b7fdf 100644
--- a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
+++ b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs
@@ -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,
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index aa9843e4..39815cac 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -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",
diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs
index e49f98fe..66df4d7a 100644
--- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs
+++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs
@@ -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,
+ IRecipient
{
+ 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(this);
+ Messenger.Register(this);
+ }
+
+ protected override void UnregisterRecipients()
+ {
+ base.UnregisterRecipients();
+
+ Messenger.Unregister(this);
+ Messenger.Unregister(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 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)
{
diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs
new file mode 100644
index 00000000..9964e48d
--- /dev/null
+++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs
@@ -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 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 CollectPreferencesSnapshot()
+ {
+ var settings = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var property in GetSyncablePreferenceProperties())
+ {
+ settings[property.Name] = property.GetValue(_preferencesService);
+ }
+
+ return settings;
+ }
+
+ private static string SerializePreferencesSnapshot(Dictionary settings)
+ {
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ writer.WriteStartObject();
+
+ foreach (var setting in settings)
+ {
+ WritePreferenceValue(writer, setting.Key, setting.Value);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ private (int appliedCount, int failedCount) ApplyPreferencesSnapshot(JsonElement rootElement)
+ {
+ var appliedCount = 0;
+ var failedCount = 0;
+
+ foreach (var property in GetSyncablePreferenceProperties())
+ {
+ if (!rootElement.TryGetProperty(property.Name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ continue;
+ }
+
+ try
+ {
+ property.SetValue(_preferencesService, ReadPreferenceValue(property.PropertyType, value));
+ appliedCount++;
+ }
+ catch (Exception)
+ {
+ failedCount++;
+ }
+ }
+
+ return (appliedCount, failedCount);
+ }
+
+ private static void WritePreferenceValue(Utf8JsonWriter writer, string propertyName, object? value)
+ {
+ if (value == null)
+ {
+ writer.WriteNull(propertyName);
+ return;
+ }
+
+ switch (value)
+ {
+ case string stringValue:
+ writer.WriteString(propertyName, stringValue);
+ return;
+ case bool boolValue:
+ writer.WriteBoolean(propertyName, boolValue);
+ return;
+ case int intValue:
+ writer.WriteNumber(propertyName, intValue);
+ return;
+ case long longValue:
+ writer.WriteNumber(propertyName, longValue);
+ return;
+ case double doubleValue:
+ writer.WriteNumber(propertyName, doubleValue);
+ return;
+ case float floatValue:
+ writer.WriteNumber(propertyName, floatValue);
+ return;
+ case Guid guidValue:
+ writer.WriteString(propertyName, guidValue);
+ return;
+ case TimeSpan timeSpanValue:
+ writer.WriteString(propertyName, timeSpanValue.ToString("c", CultureInfo.InvariantCulture));
+ return;
+ }
+
+ var valueType = Nullable.GetUnderlyingType(value.GetType()) ?? value.GetType();
+ if (valueType.IsEnum)
+ {
+ writer.WriteString(propertyName, value.ToString());
+ return;
+ }
+
+ writer.WriteString(propertyName, Convert.ToString(value, CultureInfo.InvariantCulture));
+ }
+
+ private static object? ReadPreferenceValue(Type propertyType, JsonElement value)
+ {
+ var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
+
+ if (value.ValueKind == JsonValueKind.Null)
+ {
+ return null;
+ }
+
+ if (targetType == typeof(string))
+ {
+ return value.GetString() ?? string.Empty;
+ }
+
+ if (targetType == typeof(bool))
+ {
+ return value.GetBoolean();
+ }
+
+ if (targetType == typeof(int))
+ {
+ return value.GetInt32();
+ }
+
+ if (targetType == typeof(long))
+ {
+ return value.GetInt64();
+ }
+
+ if (targetType == typeof(double))
+ {
+ return value.GetDouble();
+ }
+
+ if (targetType == typeof(float))
+ {
+ return value.GetSingle();
+ }
+
+ if (targetType == typeof(Guid))
+ {
+ return Guid.Parse(value.GetString() ?? string.Empty);
+ }
+
+ if (targetType == typeof(TimeSpan))
+ {
+ return TimeSpan.Parse(value.GetString() ?? string.Empty, CultureInfo.InvariantCulture);
+ }
+
+ if (targetType.IsEnum)
+ {
+ return Enum.Parse(targetType, value.GetString() ?? string.Empty, true);
+ }
+
+ return Convert.ChangeType(value.GetString(), targetType, CultureInfo.InvariantCulture);
+ }
+
+ private static IEnumerable GetSyncablePreferenceProperties()
+ {
+ foreach (var property in typeof(IPreferencesService).GetProperties(BindingFlags.Instance | BindingFlags.Public))
+ {
+ if (!property.CanRead || !property.CanWrite || property.GetIndexParameters().Length > 0)
+ {
+ continue;
+ }
+
+ yield return property;
+ }
+ }
+}
diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs
index d2b15651..1f7f6555 100644
--- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs
+++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs
@@ -30,6 +30,7 @@ namespace Wino.Mail.ViewModels;
public partial class MailAppShellViewModel : MailBaseViewModel,
IMailShellClient,
+ IRecipient,
IRecipient,
IRecipient,
IRecipient,
@@ -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(this);
Messenger.Register(this);
+ Messenger.Register(this);
Messenger.Register(this);
Messenger.Register(this);
Messenger.Register(this);
@@ -1172,6 +1177,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
Messenger.Unregister(this);
Messenger.Unregister(this);
+ Messenger.Unregister(this);
Messenger.Unregister(this);
Messenger.Unregister(this);
Messenger.Unregister(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;
diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs
index 831dcddc..2dccf37f 100644
--- a/Wino.Mail.WinUI/App.xaml.cs
+++ b/Wino.Mail.WinUI/App.xaml.cs
@@ -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));
diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs
index 2f533165..d886fc4e 100644
--- a/Wino.Mail.WinUI/Services/NavigationService.cs
+++ b/Wino.Mail.WinUI/Services/NavigationService.cs
@@ -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),
diff --git a/Wino.Mail.WinUI/Services/StatePersistenceService.cs b/Wino.Mail.WinUI/Services/StatePersistenceService.cs
index 5a3088d6..b1edfab7 100644
--- a/Wino.Mail.WinUI/Services/StatePersistenceService.cs
+++ b/Wino.Mail.WinUI/Services/StatePersistenceService.cs
@@ -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;
diff --git a/Wino.Mail.WinUI/ViewModels/SettingsShellClient.cs b/Wino.Mail.WinUI/ViewModels/SettingsShellClient.cs
index 5b7dbc84..d1fb65b8 100644
--- a/Wino.Mail.WinUI/ViewModels/SettingsShellClient.cs
+++ b/Wino.Mail.WinUI/ViewModels/SettingsShellClient.cs
@@ -20,6 +20,8 @@ public partial class SettingsShellClient(INavigationService navigationService) :
IRecipient,
IRecipient
{
+ 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)
diff --git a/Wino.Mail.WinUI/Views/Abstract/WinoAccountManagementPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/WinoAccountManagementPageAbstract.cs
new file mode 100644
index 00000000..ee436e61
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Abstract/WinoAccountManagementPageAbstract.cs
@@ -0,0 +1,7 @@
+using Wino.Core.ViewModels;
+
+namespace Wino.Views.Abstract;
+
+public abstract class WinoAccountManagementPageAbstract : SettingsPageBase
+{
+}
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
index 2ab6d85d..06105cb0 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
@@ -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}">
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs
index 367ed9c0..da19dd2a 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs
@@ -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());
diff --git a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml
index 26b09479..de5709dd 100644
--- a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml
+++ b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml
@@ -80,22 +80,18 @@
-
+
-
+
-
-
-
-
+
-
@@ -138,7 +130,6 @@
Stretch="Uniform" />
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml.cs b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml.cs
new file mode 100644
index 00000000..f2c28075
--- /dev/null
+++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml.cs
@@ -0,0 +1,11 @@
+using Wino.Views.Abstract;
+
+namespace Wino.Views.Settings;
+
+public sealed partial class WinoAccountManagementPage : WinoAccountManagementPageAbstract
+{
+ public WinoAccountManagementPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs b/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs
index e43a2b32..7249fff7 100644
--- a/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/SettingsPage.xaml.cs
@@ -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();
}
diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml b/Wino.Mail.WinUI/Views/WinoAppShell.xaml
index b173aa20..99d6051b 100644
--- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml
@@ -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}">
diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs b/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs
index ac2bb687..9d23f512 100644
--- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs
+++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs
@@ -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);
diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
index 0c9f5af4..1b10618c 100644
--- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
+++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj
@@ -98,6 +98,7 @@
+
diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs
index a7c888e2..0d9f6220 100644
--- a/Wino.Services/WinoAccountApiClient.cs
+++ b/Wino.Services/WinoAccountApiClient.cs
@@ -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> GetCurrentUserAsync(CancellationToken cancellationToken = default)
+ => SendAuthorizedRequestAsync("api/v1/auth/me", WinoAccountApiJsonContext.Default.ApiEnvelopeAuthUserDto, cancellationToken);
+
+ public Task> GetAiStatusAsync(CancellationToken cancellationToken = default)
+ => SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
+
+ public async Task 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 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> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken)
{
try
@@ -94,6 +150,46 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
+ private async Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var request = await CreateAuthorizedRequestAsync(HttpMethod.Get, endpoint).ConfigureAwait(false);
+ if (request == null)
+ return ApiEnvelope.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.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
+ }
+ catch (Exception ex)
+ {
+ return ApiEnvelope.Failure(ex.Message);
+ }
+ }
+
+ private async Task 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 GetAccessTokenAsync()
+ {
+ var account = await _databaseService.Connection.Table().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))]
+[JsonSerializable(typeof(ApiEnvelope))]
+[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;