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 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ public class MailItemFolder : IMailItemFolder
|
|||||||
public bool IsSynchronizationEnabled { get; set; }
|
public bool IsSynchronizationEnabled { get; set; }
|
||||||
public bool IsHidden { get; set; }
|
public bool IsHidden { get; set; }
|
||||||
public bool ShowUnreadCount { 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; }
|
public DateTime? LastSynchronizedDate { get; set; }
|
||||||
|
|
||||||
// For IMAP
|
// For IMAP
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public enum WinoPage
|
|||||||
ContactsPage,
|
ContactsPage,
|
||||||
MailRenderingPage,
|
MailRenderingPage,
|
||||||
AccountDetailsPage,
|
AccountDetailsPage,
|
||||||
|
FolderCustomizationPage,
|
||||||
MergedAccountDetailsPage,
|
MergedAccountDetailsPage,
|
||||||
ManageAccountsPage,
|
ManageAccountsPage,
|
||||||
AccountManagementPage,
|
AccountManagementPage,
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ public interface IFolderService
|
|||||||
Task<int> GetFolderNotificationBadgeAsync(Guid folderId);
|
Task<int> GetFolderNotificationBadgeAsync(Guid folderId);
|
||||||
Task ChangeStickyStatusAsync(Guid folderId, bool isSticky);
|
Task ChangeStickyStatusAsync(Guid folderId, bool isSticky);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles a folder's visibility in the navigation menu.
|
||||||
|
/// Hidden folders are still synchronized if sync is enabled.
|
||||||
|
/// </summary>
|
||||||
|
Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList<Guid> orderedFolderIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wipes every user folder customization for the account: clears custom Order,
|
||||||
|
/// un-hides folders, and restores IsSticky on system folders.
|
||||||
|
/// </summary>
|
||||||
|
Task ResetFolderCustomizationAsync(Guid accountId);
|
||||||
|
|
||||||
Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration);
|
Task<MailAccount> UpdateSystemFolderConfigurationAsync(Guid accountId, SystemFolderConfiguration configuration);
|
||||||
Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled);
|
Task ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled);
|
||||||
Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount);
|
Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount);
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ public static class SettingsNavigationInfoProvider
|
|||||||
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
|
WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
|
WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
|
WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage,
|
||||||
|
WinoPage.FolderCustomizationPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
|
WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
|
WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage,
|
||||||
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
|
WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage,
|
||||||
|
|||||||
@@ -885,6 +885,21 @@
|
|||||||
"SettingsManageAliases_Title": "Aliases",
|
"SettingsManageAliases_Title": "Aliases",
|
||||||
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
|
"SettingsMailCategories_Description": "Manage synchronized and local categories for this account.",
|
||||||
"SettingsMailCategories_Title": "Categories",
|
"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_Title": "Edit Account Details",
|
||||||
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
|
"SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.",
|
||||||
"SettingsAccountDetails_NavigationTitle": "{0} details",
|
"SettingsAccountDetails_NavigationTitle": "{0} details",
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel
|
|||||||
private void EditCategories()
|
private void EditCategories()
|
||||||
=> Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id));
|
=> 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]
|
[RelayCommand]
|
||||||
private void EditImapCalDavSettings()
|
private void EditImapCalDavSettings()
|
||||||
=> Messenger.Send(new BreadcrumbNavigationRequested(
|
=> Messenger.Send(new BreadcrumbNavigationRequested(
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
|
||||||
|
namespace Wino.Mail.ViewModels.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-folder row shown on the Folder Customization page. Wraps the underlying
|
||||||
|
/// <see cref="MailItemFolder"/> entity and exposes observable flags for binding.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backs the per-account Folder Customization page — lets the user reorder,
|
||||||
|
/// pin/unpin, and hide folders for a single real (non-merged) account.
|
||||||
|
/// </summary>
|
||||||
|
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<FolderCustomizationItemViewModel> PinnedFolders { get; } = [];
|
||||||
|
public ObservableCollection<FolderCustomizationItemViewModel> CategoryFolders { get; } = [];
|
||||||
|
public ObservableCollection<FolderCustomizationItemViewModel> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the view after a drag-reorder or pin/unpin change. Persists the
|
||||||
|
/// complete new layout and hidden state for this account.
|
||||||
|
/// </summary>
|
||||||
|
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<MailItemFolder>();
|
||||||
|
|
||||||
|
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<Guid>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -368,6 +368,7 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
services.AddTransient(typeof(ImapCalDavSettingsPageViewModel));
|
services.AddTransient(typeof(ImapCalDavSettingsPageViewModel));
|
||||||
services.AddTransient(typeof(AccountDetailsPageViewModel));
|
services.AddTransient(typeof(AccountDetailsPageViewModel));
|
||||||
|
services.AddTransient(typeof(FolderCustomizationPageViewModel));
|
||||||
services.AddTransient(typeof(SignatureManagementPageViewModel));
|
services.AddTransient(typeof(SignatureManagementPageViewModel));
|
||||||
services.AddTransient(typeof(MessageListPageViewModel));
|
services.AddTransient(typeof(MessageListPageViewModel));
|
||||||
services.AddTransient(typeof(MailNotificationSettingsPageViewModel));
|
services.AddTransient(typeof(MailNotificationSettingsPageViewModel));
|
||||||
|
|||||||
@@ -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)
|
public static WinoIconGlyph GetSpecialFolderPathIconGeometry(SpecialFolderType specialFolderType)
|
||||||
{
|
{
|
||||||
return specialFolderType switch
|
return specialFolderType switch
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
WinoPage.ManageAccountsPage,
|
WinoPage.ManageAccountsPage,
|
||||||
WinoPage.AccountManagementPage,
|
WinoPage.AccountManagementPage,
|
||||||
WinoPage.AccountDetailsPage,
|
WinoPage.AccountDetailsPage,
|
||||||
|
WinoPage.FolderCustomizationPage,
|
||||||
WinoPage.MergedAccountDetailsPage,
|
WinoPage.MergedAccountDetailsPage,
|
||||||
WinoPage.SignatureManagementPage,
|
WinoPage.SignatureManagementPage,
|
||||||
WinoPage.AboutPage,
|
WinoPage.AboutPage,
|
||||||
@@ -136,6 +137,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
WinoPage.None => null,
|
WinoPage.None => null,
|
||||||
WinoPage.IdlePage => typeof(IdlePage),
|
WinoPage.IdlePage => typeof(IdlePage),
|
||||||
WinoPage.AccountDetailsPage => typeof(AccountDetailsPage),
|
WinoPage.AccountDetailsPage => typeof(AccountDetailsPage),
|
||||||
|
WinoPage.FolderCustomizationPage => typeof(FolderCustomizationPage),
|
||||||
WinoPage.MergedAccountDetailsPage => typeof(MergedAccountDetailsPage),
|
WinoPage.MergedAccountDetailsPage => typeof(MergedAccountDetailsPage),
|
||||||
WinoPage.AccountManagementPage => typeof(AccountManagementPage),
|
WinoPage.AccountManagementPage => typeof(AccountManagementPage),
|
||||||
WinoPage.ManageAccountsPage => typeof(AccountManagementPage),
|
WinoPage.ManageAccountsPage => typeof(AccountManagementPage),
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Wino.Mail.WinUI;
|
||||||
|
using Wino.Mail.ViewModels;
|
||||||
|
|
||||||
|
namespace Wino.Views.Abstract;
|
||||||
|
|
||||||
|
public abstract class FolderCustomizationPageAbstract : BasePage<FolderCustomizationPageViewModel> { }
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
|
|||||||
|
<abstract:FolderCustomizationPageAbstract
|
||||||
|
x:Class="Wino.Views.FolderCustomizationPage"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:abstract="using:Wino.Views.Abstract"
|
||||||
|
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||||
|
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:data="using:Wino.Mail.ViewModels.Data"
|
||||||
|
xmlns:domain="using:Wino.Core.Domain"
|
||||||
|
xmlns:helpers="using:Wino.Helpers"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
|
||||||
|
<Page.Resources>
|
||||||
|
<DataTemplate x:Key="FolderRowTemplate" x:DataType="data:FolderCustomizationItemViewModel">
|
||||||
|
<Grid
|
||||||
|
Padding="8"
|
||||||
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Glyph="" />
|
||||||
|
|
||||||
|
<coreControls:WinoFontIcon
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="18"
|
||||||
|
Icon="{x:Bind helpers:XamlHelpers.GetSpecialFolderPathIconGeometry(SpecialFolderType)}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="2"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Bind FolderName}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
Grid.Column="3"
|
||||||
|
Click="HideToggle_Click"
|
||||||
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
|
ToolTipService.ToolTip="{x:Bind domain:Translator.FolderCustomization_Hide}">
|
||||||
|
<FontIcon FontSize="16" Glyph="{x:Bind helpers:XamlHelpers.GetHideGlyph(IsHidden), Mode=OneWay}" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
Grid.Column="4"
|
||||||
|
Click="PinToggle_Click"
|
||||||
|
Style="{StaticResource SubtleButtonStyle}"
|
||||||
|
ToolTipService.ToolTip="{x:Bind domain:Translator.FolderCustomization_Pin}">
|
||||||
|
<FontIcon FontSize="16" Glyph="" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</Page.Resources>
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<Grid Padding="16,12" RowSpacing="12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" ColumnSpacing="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{x:Bind domain:Translator.FolderCustomization_Title}" />
|
||||||
|
<TextBlock
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Text="{x:Bind domain:Translator.FolderCustomization_Description}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Command="{x:Bind ViewModel.ResetCommand}"
|
||||||
|
Content="{x:Bind domain:Translator.FolderCustomization_Reset}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Expander
|
||||||
|
Grid.Row="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
IsExpanded="True">
|
||||||
|
<Expander.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<FontIcon FontSize="16" Glyph="" />
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionPinned}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Expander.Header>
|
||||||
|
<ListView
|
||||||
|
AllowDrop="True"
|
||||||
|
CanReorderItems="True"
|
||||||
|
DropCompleted="ListView_DropCompleted"
|
||||||
|
ItemTemplate="{StaticResource FolderRowTemplate}"
|
||||||
|
ItemsSource="{x:Bind ViewModel.PinnedFolders}"
|
||||||
|
SelectionMode="None">
|
||||||
|
<ListView.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListViewItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
</Style>
|
||||||
|
</ListView.ItemContainerStyle>
|
||||||
|
</ListView>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
|
<Expander
|
||||||
|
Grid.Row="2"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
IsExpanded="True"
|
||||||
|
Visibility="{x:Bind ViewModel.IsGmailAccount, Mode=OneWay}">
|
||||||
|
<Expander.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<FontIcon FontSize="16" Glyph="" />
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionCategories}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Expander.Header>
|
||||||
|
<ListView
|
||||||
|
AllowDrop="True"
|
||||||
|
CanReorderItems="True"
|
||||||
|
DropCompleted="ListView_DropCompleted"
|
||||||
|
ItemTemplate="{StaticResource FolderRowTemplate}"
|
||||||
|
ItemsSource="{x:Bind ViewModel.CategoryFolders}"
|
||||||
|
SelectionMode="None">
|
||||||
|
<ListView.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListViewItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
</Style>
|
||||||
|
</ListView.ItemContainerStyle>
|
||||||
|
</ListView>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
|
<Expander
|
||||||
|
Grid.Row="3"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
IsExpanded="True">
|
||||||
|
<Expander.Header>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<FontIcon FontSize="16" Glyph="" />
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.FolderCustomization_SectionMore}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Expander.Header>
|
||||||
|
<ListView
|
||||||
|
AllowDrop="True"
|
||||||
|
CanReorderItems="True"
|
||||||
|
DropCompleted="ListView_DropCompleted"
|
||||||
|
ItemTemplate="{StaticResource FolderRowTemplate}"
|
||||||
|
ItemsSource="{x:Bind ViewModel.MoreFolders}"
|
||||||
|
SelectionMode="None">
|
||||||
|
<ListView.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListViewItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
</Style>
|
||||||
|
</ListView.ItemContainerStyle>
|
||||||
|
</ListView>
|
||||||
|
</Expander>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</abstract:FolderCustomizationPageAbstract>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Wino.Mail.ViewModels.Data;
|
||||||
|
using Wino.Views.Abstract;
|
||||||
|
|
||||||
|
namespace Wino.Views;
|
||||||
|
|
||||||
|
public sealed partial class FolderCustomizationPage : FolderCustomizationPageAbstract
|
||||||
|
{
|
||||||
|
public FolderCustomizationPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ListView_DropCompleted(UIElement sender, Microsoft.UI.Xaml.Controls.Primitives.DropCompletedEventArgs args)
|
||||||
|
{
|
||||||
|
// ListView.CanReorderItems automatically mutates the backing
|
||||||
|
// ObservableCollection; persist the new order here.
|
||||||
|
await ViewModel.PersistLayoutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PinToggle_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement element && element.DataContext is FolderCustomizationItemViewModel item)
|
||||||
|
{
|
||||||
|
await ViewModel.TogglePinAsync(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HideToggle_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement element && element.DataContext is FolderCustomizationItemViewModel item)
|
||||||
|
{
|
||||||
|
await ViewModel.ToggleHiddenAsync(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,13 @@ public class DatabaseService : IDatabaseService
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.Order)))
|
||||||
|
{
|
||||||
|
await Connection
|
||||||
|
.ExecuteAsync($"ALTER TABLE {nameof(MailItemFolder)} ADD COLUMN \"{nameof(MailItemFolder.Order)}\" INTEGER NOT NULL DEFAULT 0")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var customServerColumns = await Connection.GetTableInfoAsync(nameof(CustomServerInformation)).ConfigureAwait(false);
|
var customServerColumns = await Connection.GetTableInfoAsync(nameof(CustomServerInformation)).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavServiceUrl)))
|
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavServiceUrl)))
|
||||||
|
|||||||
@@ -45,6 +45,60 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
|
public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky)
|
||||||
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsSticky = ? WHERE Id = ?", isSticky, folderId);
|
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsSticky = ? WHERE Id = ?", isSticky, folderId);
|
||||||
|
|
||||||
|
public async Task ChangeFolderHiddenStatusAsync(Guid folderId, bool isHidden)
|
||||||
|
{
|
||||||
|
await Connection.ExecuteAsync("UPDATE MailItemFolder SET IsHidden = ? WHERE Id = ?", isHidden, folderId);
|
||||||
|
|
||||||
|
var folder = await GetFolderAsync(folderId).ConfigureAwait(false);
|
||||||
|
if (folder != null)
|
||||||
|
{
|
||||||
|
Messenger.Send(new AccountFolderConfigurationUpdated(folder.MailAccountId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateFolderOrdersAsync(Guid accountId, IReadOnlyList<Guid> orderedFolderIds)
|
||||||
|
{
|
||||||
|
if (orderedFolderIds == null || orderedFolderIds.Count == 0) return;
|
||||||
|
|
||||||
|
await Connection.RunInTransactionAsync(conn =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < orderedFolderIds.Count; i++)
|
||||||
|
{
|
||||||
|
conn.Execute("UPDATE MailItemFolder SET \"Order\" = ? WHERE Id = ? AND MailAccountId = ?",
|
||||||
|
i + 1, orderedFolderIds[i], accountId);
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Messenger.Send(new AccountFolderConfigurationUpdated(accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetFolderCustomizationAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
await Connection.RunInTransactionAsync(conn =>
|
||||||
|
{
|
||||||
|
conn.Execute("UPDATE MailItemFolder SET \"Order\" = 0, IsHidden = 0 WHERE MailAccountId = ?", accountId);
|
||||||
|
|
||||||
|
// Restore system folder stickiness. Category-type folders are virtual stickies too.
|
||||||
|
conn.Execute(
|
||||||
|
"UPDATE MailItemFolder SET IsSticky = 1 WHERE MailAccountId = ? AND (IsSystemFolder = 1 OR SpecialFolderType = ?)",
|
||||||
|
accountId, (int)SpecialFolderType.Category);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Messenger.Send(new AccountFolderConfigurationUpdated(accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders folders by user-set Order first (customized entries ahead of uncustomized ones),
|
||||||
|
/// then falls back to alphabetic folder name (culture-aware), then to SpecialFolderType
|
||||||
|
/// as a final canonical tiebreak.
|
||||||
|
/// </summary>
|
||||||
|
private static IOrderedEnumerable<MailItemFolder> ApplyFolderSort(IEnumerable<MailItemFolder> folders)
|
||||||
|
=> folders
|
||||||
|
.OrderBy(a => a.Order == 0 ? 1 : 0)
|
||||||
|
.ThenBy(a => a.Order)
|
||||||
|
.ThenBy(a => a.FolderName, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ThenBy(a => a.SpecialFolderType);
|
||||||
|
|
||||||
public async Task<int> GetFolderNotificationBadgeAsync(Guid folderId)
|
public async Task<int> GetFolderNotificationBadgeAsync(Guid folderId)
|
||||||
{
|
{
|
||||||
var folder = await GetFolderAsync(folderId);
|
var folder = await GetFolderAsync(folderId);
|
||||||
@@ -104,8 +158,10 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
if (!includeHiddenFolders)
|
if (!includeHiddenFolders)
|
||||||
folderQuery = folderQuery.Where(a => !a.IsHidden);
|
folderQuery = folderQuery.Where(a => !a.IsHidden);
|
||||||
|
|
||||||
// Load child folders for each folder.
|
// Load child folders for each folder, applying user-defined ordering with
|
||||||
var allFolders = await folderQuery.OrderBy(a => a.SpecialFolderType).ToListAsync();
|
// alphabetic fallback for folders the user hasn't explicitly re-ordered.
|
||||||
|
var rawFolders = await folderQuery.ToListAsync();
|
||||||
|
var allFolders = ApplyFolderSort(rawFolders).ToList();
|
||||||
|
|
||||||
if (allFolders.Any())
|
if (allFolders.Any())
|
||||||
{
|
{
|
||||||
@@ -235,7 +291,7 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
|
|
||||||
var mailAccount = accountMenuItem.HoldingAccounts.First();
|
var mailAccount = accountMenuItem.HoldingAccounts.First();
|
||||||
|
|
||||||
var listingFolders = folders.OrderBy(a => a.SpecialFolderType);
|
var listingFolders = ApplyFolderSort(folders);
|
||||||
|
|
||||||
var moreFolder = MailItemFolder.CreateMoreFolder();
|
var moreFolder = MailItemFolder.CreateMoreFolder();
|
||||||
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
|
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
|
||||||
@@ -394,10 +450,12 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
if (folder == null)
|
if (folder == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var childFolders = await Connection.Table<MailItemFolder>()
|
var childFoldersRaw = await Connection.Table<MailItemFolder>()
|
||||||
.Where(a => a.ParentRemoteFolderId == folder.RemoteFolderId && a.MailAccountId == folder.MailAccountId)
|
.Where(a => a.ParentRemoteFolderId == folder.RemoteFolderId && a.MailAccountId == folder.MailAccountId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var childFolders = ApplyFolderSort(childFoldersRaw).ToList();
|
||||||
|
|
||||||
foreach (var childFolder in childFolders)
|
foreach (var childFolder in childFolders)
|
||||||
{
|
{
|
||||||
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
|
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
|
||||||
@@ -416,16 +474,20 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
|
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
|
||||||
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
|
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
|
||||||
|
|
||||||
public Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
|
public async Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
|
||||||
{
|
{
|
||||||
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? ORDER BY SpecialFolderType";
|
// Ordering is applied in managed code so that StringComparer.CurrentCultureIgnoreCase
|
||||||
return Connection.QueryAsync<MailItemFolder>(query, accountId);
|
// is honored. SQLite's default ORDER BY is not culture-aware.
|
||||||
|
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ?";
|
||||||
|
var rows = await Connection.QueryAsync<MailItemFolder>(query, accountId).ConfigureAwait(false);
|
||||||
|
return ApplyFolderSort(rows).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
|
public async Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
|
||||||
{
|
{
|
||||||
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? AND IsHidden = ? ORDER BY SpecialFolderType";
|
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? AND IsHidden = ?";
|
||||||
return Connection.QueryAsync<MailItemFolder>(query, accountId, 0);
|
var rows = await Connection.QueryAsync<MailItemFolder>(query, accountId, 0).ConfigureAwait(false);
|
||||||
|
return ApplyFolderSort(rows).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
|
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
|
||||||
@@ -528,6 +590,8 @@ public class FolderService : BaseDatabaseService, IFolderService
|
|||||||
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
|
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
|
||||||
folder.TextColorHex = existingFolder.TextColorHex;
|
folder.TextColorHex = existingFolder.TextColorHex;
|
||||||
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
|
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
|
||||||
|
folder.Order = existingFolder.Order;
|
||||||
|
folder.IsHidden = existingFolder.IsHidden;
|
||||||
|
|
||||||
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
|
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user