From 98eed39fe611f7cb840c28b0c5e87192e3a2771c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 16 Apr 2026 14:07:17 +0200 Subject: [PATCH] Add per-account folder customization page (#855) Introduce a dedicated settings page that lets users reorder, hide, and pin/unpin folders per account. Folders are organized into Pinned, Categories (Gmail only), and More sections with drag-to-reorder via ListView. New Order column on MailItemFolder persists the custom layout; the default sort falls back to alphabetic when no custom order is set. A reset action wipes all customization in a single transaction and restores system-folder stickiness. Co-authored-by: Claude --- .../Entities/Mail/MailItemFolder.cs | 5 + Wino.Core.Domain/Enums/WinoPage.cs | 1 + Wino.Core.Domain/Interfaces/IFolderService.cs | 19 ++ .../Settings/SettingsNavigationItemInfo.cs | 1 + .../Translations/en_US/resources.json | 15 ++ .../AccountDetailsPageViewModel.cs | 4 + .../Data/FolderCustomizationItemViewModel.cs | 26 +++ .../FolderCustomizationPageViewModel.cs | 198 ++++++++++++++++++ Wino.Mail.WinUI/App.xaml.cs | 1 + Wino.Mail.WinUI/Helpers/XamlHelpers.cs | 4 + Wino.Mail.WinUI/Services/NavigationService.cs | 2 + .../FolderCustomizationPageAbstract.cs | 6 + .../Views/Account/AccountDetailsPage.xaml | 10 + .../Account/FolderCustomizationPage.xaml | 188 +++++++++++++++++ .../Account/FolderCustomizationPage.xaml.cs | 38 ++++ Wino.Services/DatabaseService.cs | 7 + Wino.Services/FolderService.cs | 84 +++++++- 17 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 Wino.Mail.ViewModels/Data/FolderCustomizationItemViewModel.cs create mode 100644 Wino.Mail.ViewModels/FolderCustomizationPageViewModel.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/FolderCustomizationPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/Account/FolderCustomizationPage.xaml create mode 100644 Wino.Mail.WinUI/Views/Account/FolderCustomizationPage.xaml.cs diff --git a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs index 4555799e..909829f6 100644 --- a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs +++ b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs @@ -24,6 +24,11 @@ public class MailItemFolder : IMailItemFolder public bool IsSynchronizationEnabled { get; set; } public bool IsHidden { get; set; } public bool ShowUnreadCount { get; set; } + + // User-defined ordering within its navigation section (Pinned / Categories / More). + // 0 means "no custom order set" — the folder falls back to the default sort + // (alphabetic for More, canonical SpecialFolderType order as a tiebreak for Pinned). + public int Order { get; set; } public DateTime? LastSynchronizedDate { get; set; } // For IMAP diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 8f8e79bc..fd3bd7d4 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -12,6 +12,7 @@ public enum WinoPage ContactsPage, MailRenderingPage, AccountDetailsPage, + FolderCustomizationPage, MergedAccountDetailsPage, ManageAccountsPage, AccountManagementPage, diff --git a/Wino.Core.Domain/Interfaces/IFolderService.cs b/Wino.Core.Domain/Interfaces/IFolderService.cs index 754a67e2..9caa70c0 100644 --- a/Wino.Core.Domain/Interfaces/IFolderService.cs +++ b/Wino.Core.Domain/Interfaces/IFolderService.cs @@ -22,6 +22,25 @@ public interface IFolderService Task GetFolderNotificationBadgeAsync(Guid folderId); Task ChangeStickyStatusAsync(Guid folderId, bool isSticky); + /// + /// Toggles a folder's visibility in the navigation menu. + /// Hidden folders are still synchronized if sync is enabled. + /// + Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden); + + /// + /// Persists a new custom ordering for the given folders. + /// The first id becomes Order=1, second Order=2, etc. + /// Caller is responsible for notifying the shell to refresh. + /// + Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList orderedFolderIds); + + /// + /// Wipes every user folder customization for the account: clears custom Order, + /// un-hides folders, and restores IsSticky on system folders. + /// + Task ResetFolderCustomizationAsync(Guid accountId); + Task UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration); Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled); Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount); diff --git a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs index 274ce538..f8f2beab 100644 --- a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs +++ b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs @@ -176,6 +176,7 @@ public static class SettingsNavigationInfoProvider WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage, + WinoPage.FolderCustomizationPage => WinoPage.ManageAccountsPage, WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage, WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage, WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage, diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 8a23af75..cf17e781 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -885,6 +885,21 @@ "SettingsManageAliases_Title": "Aliases", "SettingsMailCategories_Description": "Manage synchronized and local categories for this account.", "SettingsMailCategories_Title": "Categories", + "FolderCustomization_Title": "Customize folder list", + "FolderCustomization_Description": "Reorder, hide, or pin folders for this account.", + "FolderCustomization_EntryCardTitle": "Customize folder list", + "FolderCustomization_EntryCardDescription": "Arrange pinned folders, hide folders you don't use, and reorder the More section.", + "FolderCustomization_SectionPinned": "Pinned", + "FolderCustomization_SectionCategories": "Categories", + "FolderCustomization_SectionMore": "More", + "FolderCustomization_Pin": "Pin to top", + "FolderCustomization_Unpin": "Move to More", + "FolderCustomization_Show": "Show in navigation", + "FolderCustomization_Hide": "Hide from navigation", + "FolderCustomization_Reset": "Reset to defaults", + "FolderCustomization_ResetConfirmTitle": "Reset folder layout", + "FolderCustomization_ResetConfirmMessage": "This will clear any custom folder order and restore the default alphabetic layout for this account. Hidden folders will become visible again. Continue?", + "FolderCustomization_EmptySection": "Drag folders here to add them to this section.", "SettingsEditAccountDetails_Title": "Edit Account Details", "SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.", "SettingsAccountDetails_NavigationTitle": "{0} details", diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index 1f659a7a..21e865a1 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -173,6 +173,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel private void EditCategories() => Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id)); + [RelayCommand] + private void CustomizeFolderList() + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.FolderCustomization_Title, WinoPage.FolderCustomizationPage, Account.Id)); + [RelayCommand] private void EditImapCalDavSettings() => Messenger.Send(new BreadcrumbNavigationRequested( diff --git a/Wino.Mail.ViewModels/Data/FolderCustomizationItemViewModel.cs b/Wino.Mail.ViewModels/Data/FolderCustomizationItemViewModel.cs new file mode 100644 index 00000000..b6dd9880 --- /dev/null +++ b/Wino.Mail.ViewModels/Data/FolderCustomizationItemViewModel.cs @@ -0,0 +1,26 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Mail; + +namespace Wino.Mail.ViewModels.Data; + +/// +/// Per-folder row shown on the Folder Customization page. Wraps the underlying +/// entity and exposes observable flags for binding. +/// +public partial class FolderCustomizationItemViewModel : ObservableObject +{ + public MailItemFolder Folder { get; } + + [ObservableProperty] + public partial bool IsHidden { get; set; } + + public string FolderName => Folder.FolderName; + public bool IsSystemFolder => Folder.IsSystemFolder; + public Core.Domain.Enums.SpecialFolderType SpecialFolderType => Folder.SpecialFolderType; + + public FolderCustomizationItemViewModel(MailItemFolder folder) + { + Folder = folder; + IsHidden = folder.IsHidden; + } +} diff --git a/Wino.Mail.ViewModels/FolderCustomizationPageViewModel.cs b/Wino.Mail.ViewModels/FolderCustomizationPageViewModel.cs new file mode 100644 index 00000000..c2fe10c1 --- /dev/null +++ b/Wino.Mail.ViewModels/FolderCustomizationPageViewModel.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models.Navigation; +using Wino.Mail.ViewModels.Data; + +namespace Wino.Mail.ViewModels; + +/// +/// Backs the per-account Folder Customization page — lets the user reorder, +/// pin/unpin, and hide folders for a single real (non-merged) account. +/// +public partial class FolderCustomizationPageViewModel : MailBaseViewModel +{ + private readonly IMailDialogService _dialogService; + private readonly IFolderService _folderService; + private readonly IAccountService _accountService; + + private static readonly SpecialFolderType[] GmailCategorySubTypes = + [ + SpecialFolderType.Promotions, + SpecialFolderType.Social, + SpecialFolderType.Updates, + SpecialFolderType.Forums, + SpecialFolderType.Personal + ]; + + private Guid _accountId; + private bool _isLoaded; + + [ObservableProperty] + public partial string AccountName { get; set; } + + [ObservableProperty] + public partial bool IsGmailAccount { get; set; } + + public ObservableCollection PinnedFolders { get; } = []; + public ObservableCollection CategoryFolders { get; } = []; + public ObservableCollection MoreFolders { get; } = []; + + public FolderCustomizationPageViewModel(IMailDialogService dialogService, + IFolderService folderService, + IAccountService accountService) + { + _dialogService = dialogService; + _folderService = folderService; + _accountService = accountService; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + if (parameters is not Guid accountId) + return; + + _accountId = accountId; + + var account = await _accountService.GetAccountAsync(accountId); + if (account == null) return; + + AccountName = account.Name; + IsGmailAccount = account.ProviderType == MailProviderType.Gmail; + + await LoadFoldersAsync(); + _isLoaded = true; + } + + private async Task LoadFoldersAsync() + { + PinnedFolders.Clear(); + CategoryFolders.Clear(); + MoreFolders.Clear(); + + var allFolders = await _folderService.GetFoldersAsync(_accountId); + + // Skip the Gmail "Categories" virtual bucket entity — Categories are rendered + // as an inline section, not as a regular folder row. + foreach (var folder in allFolders.Where(f => f.SpecialFolderType != SpecialFolderType.Category)) + { + var item = new FolderCustomizationItemViewModel(folder); + + if (IsGmailAccount && GmailCategorySubTypes.Contains(folder.SpecialFolderType)) + { + CategoryFolders.Add(item); + } + else if (folder.IsSticky) + { + PinnedFolders.Add(item); + } + else + { + MoreFolders.Add(item); + } + } + } + + [RelayCommand] + private async Task ResetAsync() + { + var confirmed = await _dialogService.ShowConfirmationDialogAsync( + Translator.FolderCustomization_ResetConfirmMessage, + Translator.FolderCustomization_ResetConfirmTitle, + Translator.FolderCustomization_Reset); + + if (!confirmed) return; + + await _folderService.ResetFolderCustomizationAsync(_accountId); + await LoadFoldersAsync(); + } + + /// + /// Called by the view after a drag-reorder or pin/unpin change. Persists the + /// complete new layout and hidden state for this account. + /// + public async Task PersistLayoutAsync() + { + if (!_isLoaded) return; + + // Reconcile IsSticky: Pinned rows become sticky, everything in More loses sticky. + // Categories (Gmail virtual group children) keep their own rules. + var touchedFolders = new List(); + + foreach (var item in PinnedFolders) + { + if (!item.Folder.IsSticky) + { + item.Folder.IsSticky = true; + touchedFolders.Add(item.Folder); + } + } + + foreach (var item in MoreFolders) + { + if (item.Folder.IsSticky) + { + item.Folder.IsSticky = false; + touchedFolders.Add(item.Folder); + } + } + + foreach (var folder in touchedFolders) + { + await _folderService.ChangeStickyStatusAsync(folder.Id, folder.IsSticky); + } + + // Persist the new order: Pinned first, then Categories, then More. The + // concrete number assigned is only meaningful relative to others in the + // same account; we still number them globally for simplicity. + var orderedIds = new List(); + orderedIds.AddRange(PinnedFolders.Select(a => a.Folder.Id)); + orderedIds.AddRange(CategoryFolders.Select(a => a.Folder.Id)); + orderedIds.AddRange(MoreFolders.Select(a => a.Folder.Id)); + + await _folderService.UpdateFolderOrdersAsync(_accountId, orderedIds); + } + + public async Task ToggleHiddenAsync(FolderCustomizationItemViewModel item) + { + if (item == null) return; + + item.IsHidden = !item.IsHidden; + item.Folder.IsHidden = item.IsHidden; + + await _folderService.ChangeFolderHiddenStatusAsync(item.Folder.Id, item.IsHidden); + } + + public async Task TogglePinAsync(FolderCustomizationItemViewModel item) + { + if (item == null) return; + + // Categories sub-items cannot be pinned individually; they always travel + // with the virtual Categories group. + if (CategoryFolders.Contains(item)) return; + + if (PinnedFolders.Contains(item)) + { + PinnedFolders.Remove(item); + MoreFolders.Insert(0, item); + } + else if (MoreFolders.Contains(item)) + { + MoreFolders.Remove(item); + PinnedFolders.Add(item); + } + + await PersistLayoutAsync(); + } +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 9cf6ec5b..60e17e66 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -368,6 +368,7 @@ public partial class App : WinoApplication, services.AddTransient(typeof(ImapCalDavSettingsPageViewModel)); services.AddTransient(typeof(AccountDetailsPageViewModel)); + services.AddTransient(typeof(FolderCustomizationPageViewModel)); services.AddTransient(typeof(SignatureManagementPageViewModel)); services.AddTransient(typeof(MessageListPageViewModel)); services.AddTransient(typeof(MailNotificationSettingsPageViewModel)); diff --git a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs index b7a9a1e4..695cea3a 100644 --- a/Wino.Mail.WinUI/Helpers/XamlHelpers.cs +++ b/Wino.Mail.WinUI/Helpers/XamlHelpers.cs @@ -274,6 +274,10 @@ public static class XamlHelpers }; } + // Segoe Fluent icon glyphs for the show/hide toggle on the folder + // customization page. E7B3 = "Hide" (eye with slash), E7B2 = "RedEye". + public static string GetHideGlyph(bool isHidden) => isHidden ? "\uE7B3" : "\uE7B2"; + public static WinoIconGlyph GetSpecialFolderPathIconGeometry(SpecialFolderType specialFolderType) { return specialFolderType switch diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 724babac..f2e21a9a 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -74,6 +74,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.ManageAccountsPage, WinoPage.AccountManagementPage, WinoPage.AccountDetailsPage, + WinoPage.FolderCustomizationPage, WinoPage.MergedAccountDetailsPage, WinoPage.SignatureManagementPage, WinoPage.AboutPage, @@ -136,6 +137,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.None => null, WinoPage.IdlePage => typeof(IdlePage), WinoPage.AccountDetailsPage => typeof(AccountDetailsPage), + WinoPage.FolderCustomizationPage => typeof(FolderCustomizationPage), WinoPage.MergedAccountDetailsPage => typeof(MergedAccountDetailsPage), WinoPage.AccountManagementPage => typeof(AccountManagementPage), WinoPage.ManageAccountsPage => typeof(AccountManagementPage), diff --git a/Wino.Mail.WinUI/Views/Abstract/FolderCustomizationPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/FolderCustomizationPageAbstract.cs new file mode 100644 index 00000000..2897d1a8 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/FolderCustomizationPageAbstract.cs @@ -0,0 +1,6 @@ +using Wino.Mail.WinUI; +using Wino.Mail.ViewModels; + +namespace Wino.Views.Abstract; + +public abstract class FolderCustomizationPageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index 2a0ec6e8..e3119aa3 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -248,6 +248,16 @@ + + + + + + diff --git a/Wino.Mail.WinUI/Views/Account/FolderCustomizationPage.xaml b/Wino.Mail.WinUI/Views/Account/FolderCustomizationPage.xaml new file mode 100644 index 00000000..e8816726 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Account/FolderCustomizationPage.xaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +