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" /> - -