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 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
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum WinoPage
|
||||
ContactsPage,
|
||||
MailRenderingPage,
|
||||
AccountDetailsPage,
|
||||
FolderCustomizationPage,
|
||||
MergedAccountDetailsPage,
|
||||
ManageAccountsPage,
|
||||
AccountManagementPage,
|
||||
|
||||
@@ -22,6 +22,25 @@ public interface IFolderService
|
||||
Task<int> GetFolderNotificationBadgeAsync(Guid folderId);
|
||||
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 ChangeFolderSynchronizationStateAsync(Guid folderId, bool isSynchronizationEnabled);
|
||||
Task ChangeFolderShowUnreadCountStateAsync(Guid folderId, bool showUnreadCount);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(AccountDetailsPageViewModel));
|
||||
services.AddTransient(typeof(FolderCustomizationPageViewModel));
|
||||
services.AddTransient(typeof(SignatureManagementPageViewModel));
|
||||
services.AddTransient(typeof(MessageListPageViewModel));
|
||||
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)
|
||||
{
|
||||
return specialFolderType switch
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
{
|
||||
var folder = await GetFolderAsync(folderId);
|
||||
@@ -104,8 +158,10 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
if (!includeHiddenFolders)
|
||||
folderQuery = folderQuery.Where(a => !a.IsHidden);
|
||||
|
||||
// Load child folders for each folder.
|
||||
var allFolders = await folderQuery.OrderBy(a => a.SpecialFolderType).ToListAsync();
|
||||
// Load child folders for each folder, applying user-defined ordering with
|
||||
// alphabetic fallback for folders the user hasn't explicitly re-ordered.
|
||||
var rawFolders = await folderQuery.ToListAsync();
|
||||
var allFolders = ApplyFolderSort(rawFolders).ToList();
|
||||
|
||||
if (allFolders.Any())
|
||||
{
|
||||
@@ -235,7 +291,7 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
|
||||
var mailAccount = accountMenuItem.HoldingAccounts.First();
|
||||
|
||||
var listingFolders = folders.OrderBy(a => a.SpecialFolderType);
|
||||
var listingFolders = ApplyFolderSort(folders);
|
||||
|
||||
var moreFolder = MailItemFolder.CreateMoreFolder();
|
||||
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
|
||||
@@ -394,10 +450,12 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
if (folder == 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)
|
||||
.ToListAsync();
|
||||
|
||||
var childFolders = ApplyFolderSort(childFoldersRaw).ToList();
|
||||
|
||||
foreach (var childFolder in childFolders)
|
||||
{
|
||||
var subChild = await GetChildFolderItemsRecursiveAsync(childFolder.Id, accountId);
|
||||
@@ -416,16 +474,20 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
public Task<int> GetCurrentItemCountForFolder(Guid folderId)
|
||||
=> 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";
|
||||
return Connection.QueryAsync<MailItemFolder>(query, accountId);
|
||||
// Ordering is applied in managed code so that StringComparer.CurrentCultureIgnoreCase
|
||||
// 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";
|
||||
return Connection.QueryAsync<MailItemFolder>(query, accountId, 0);
|
||||
const string query = "SELECT * FROM MailItemFolder WHERE MailAccountId = ? AND IsHidden = ?";
|
||||
var rows = await Connection.QueryAsync<MailItemFolder>(query, accountId, 0).ConfigureAwait(false);
|
||||
return ApplyFolderSort(rows).ToList();
|
||||
}
|
||||
|
||||
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
|
||||
@@ -528,6 +590,8 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
|
||||
folder.TextColorHex = existingFolder.TextColorHex;
|
||||
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
|
||||
folder.Order = existingFolder.Order;
|
||||
folder.IsHidden = existingFolder.IsHidden;
|
||||
|
||||
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user