feat: add tag manager, search filtering and export
This commit is contained in:
1 parent
830f1153fd
commit
0fdd94d109
28 files changed
+1022
-24
No files matched your search
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Search;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.Tests.TestUtilities;
|
||||
|
||||
@@ -257,6 +258,17 @@ public class WorkspaceRepositoryTests
|
||||
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetCardIdsMatchingRunsTheCompiledSearch()
|
||||
{
|
||||
using var fixture = new RepositoryFixture();
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("T"));
|
||||
|
||||
IReadOnlyCollection<long> ids = fixture.Repository.GetCardIdsMatching(compiled);
|
||||
|
||||
CollectionAssert.AreEquivalent(new[] { fixture.CardId }, ids.ToList());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetTagsWithUsageCountsAssignments()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<Application.Styles>
|
||||
<!-- Semi.Avalonia provides the light and dark palettes; icons need their own theme. -->
|
||||
<semi:SemiTheme/>
|
||||
<semi:ColorPickerSemiTheme/>
|
||||
<StyleInclude Source="avares://IconPacks.Avalonia.Lucide/Lucide.axaml"/>
|
||||
</Application.Styles>
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
<data name="Common_Delete" xml:space="preserve">
|
||||
<value>Delete</value>
|
||||
</data>
|
||||
<data name="Common_Edit" xml:space="preserve">
|
||||
<value>Edit</value>
|
||||
</data>
|
||||
<data name="Main_Export" xml:space="preserve">
|
||||
<value>Export</value>
|
||||
</data>
|
||||
@@ -70,6 +73,18 @@
|
||||
<data name="Board_NoColumns" xml:space="preserve">
|
||||
<value>No columns yet.</value>
|
||||
</data>
|
||||
<data name="Board_FilterActive" xml:space="preserve">
|
||||
<value>Board is filtered</value>
|
||||
</data>
|
||||
<data name="Board_FilterInactive" xml:space="preserve">
|
||||
<value>Board is not filtered</value>
|
||||
</data>
|
||||
<data name="Search_Error_Title" xml:space="preserve">
|
||||
<value>Invalid search</value>
|
||||
</data>
|
||||
<data name="Search_Error_Message" xml:space="preserve">
|
||||
<value>The query expression is invalid. Details: {0}</value>
|
||||
</data>
|
||||
<data name="Sort_IdAsc" xml:space="preserve">
|
||||
<value>ID Ascending</value>
|
||||
</data>
|
||||
@@ -166,6 +181,39 @@
|
||||
<data name="TagDialog_NoTargets" xml:space="preserve">
|
||||
<value>There are no more tags to add.</value>
|
||||
</data>
|
||||
<data name="Tags_AddTag" xml:space="preserve">
|
||||
<value>Add Tag</value>
|
||||
</data>
|
||||
<data name="Tags_Empty" xml:space="preserve">
|
||||
<value>No tags yet.</value>
|
||||
</data>
|
||||
<data name="Tags_Usage" xml:space="preserve">
|
||||
<value>{0} cards</value>
|
||||
</data>
|
||||
<data name="TagEditor_AddTitle" xml:space="preserve">
|
||||
<value>New Tag</value>
|
||||
</data>
|
||||
<data name="TagEditor_EditTitle" xml:space="preserve">
|
||||
<value>Edit Tag</value>
|
||||
</data>
|
||||
<data name="TagEditor_Name" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="TagEditor_Color" xml:space="preserve">
|
||||
<value>Color</value>
|
||||
</data>
|
||||
<data name="TagEditor_Description" xml:space="preserve">
|
||||
<value>Description</value>
|
||||
</data>
|
||||
<data name="TagEditor_InvalidName" xml:space="preserve">
|
||||
<value>The name is empty or already used.</value>
|
||||
</data>
|
||||
<data name="DeleteTag_Confirm_Title" xml:space="preserve">
|
||||
<value>Delete Tag</value>
|
||||
</data>
|
||||
<data name="DeleteTag_Confirm_Message" xml:space="preserve">
|
||||
<value>Delete tag "{0}"? It will be removed from {1} card(s).</value>
|
||||
</data>
|
||||
<data name="MoveDialog_Title" xml:space="preserve">
|
||||
<value>Move Card</value>
|
||||
</data>
|
||||
@@ -391,4 +439,10 @@
|
||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||
<value>| Name | Color | Description | Usage |</value>
|
||||
</data>
|
||||
<data name="Export_Done_Title" xml:space="preserve">
|
||||
<value>Export Complete</value>
|
||||
</data>
|
||||
<data name="Export_Done_Message" xml:space="preserve">
|
||||
<value>Exported to: {0}</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -28,6 +28,9 @@
|
||||
<data name="Common_Delete" xml:space="preserve">
|
||||
<value>删除</value>
|
||||
</data>
|
||||
<data name="Common_Edit" xml:space="preserve">
|
||||
<value>编辑</value>
|
||||
</data>
|
||||
<data name="Main_Export" xml:space="preserve">
|
||||
<value>导出</value>
|
||||
</data>
|
||||
@@ -70,6 +73,18 @@
|
||||
<data name="Board_NoColumns" xml:space="preserve">
|
||||
<value>还没有列。</value>
|
||||
</data>
|
||||
<data name="Board_FilterActive" xml:space="preserve">
|
||||
<value>看板处于过滤状态</value>
|
||||
</data>
|
||||
<data name="Board_FilterInactive" xml:space="preserve">
|
||||
<value>看板未处于过滤状态</value>
|
||||
</data>
|
||||
<data name="Search_Error_Title" xml:space="preserve">
|
||||
<value>搜索表达式有误</value>
|
||||
</data>
|
||||
<data name="Search_Error_Message" xml:space="preserve">
|
||||
<value>查询表达式有误,详情:{0}</value>
|
||||
</data>
|
||||
<data name="Sort_IdAsc" xml:space="preserve">
|
||||
<value>ID 正序</value>
|
||||
</data>
|
||||
@@ -166,6 +181,39 @@
|
||||
<data name="TagDialog_NoTargets" xml:space="preserve">
|
||||
<value>没有可添加的标签。</value>
|
||||
</data>
|
||||
<data name="Tags_AddTag" xml:space="preserve">
|
||||
<value>添加标签</value>
|
||||
</data>
|
||||
<data name="Tags_Empty" xml:space="preserve">
|
||||
<value>还没有标签。</value>
|
||||
</data>
|
||||
<data name="Tags_Usage" xml:space="preserve">
|
||||
<value>{0} 张卡片</value>
|
||||
</data>
|
||||
<data name="TagEditor_AddTitle" xml:space="preserve">
|
||||
<value>新建标签</value>
|
||||
</data>
|
||||
<data name="TagEditor_EditTitle" xml:space="preserve">
|
||||
<value>编辑标签</value>
|
||||
</data>
|
||||
<data name="TagEditor_Name" xml:space="preserve">
|
||||
<value>名称</value>
|
||||
</data>
|
||||
<data name="TagEditor_Color" xml:space="preserve">
|
||||
<value>颜色</value>
|
||||
</data>
|
||||
<data name="TagEditor_Description" xml:space="preserve">
|
||||
<value>描述</value>
|
||||
</data>
|
||||
<data name="TagEditor_InvalidName" xml:space="preserve">
|
||||
<value>名称为空或已被使用。</value>
|
||||
</data>
|
||||
<data name="DeleteTag_Confirm_Title" xml:space="preserve">
|
||||
<value>删除标签</value>
|
||||
</data>
|
||||
<data name="DeleteTag_Confirm_Message" xml:space="preserve">
|
||||
<value>删除标签“{0}”?将从 {1} 张卡片中移除。</value>
|
||||
</data>
|
||||
<data name="MoveDialog_Title" xml:space="preserve">
|
||||
<value>移动卡片</value>
|
||||
</data>
|
||||
@@ -391,4 +439,10 @@
|
||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
|
||||
</data>
|
||||
<data name="Export_Done_Title" xml:space="preserve">
|
||||
<value>导出完成</value>
|
||||
</data>
|
||||
<data name="Export_Done_Message" xml:space="preserve">
|
||||
<value>已导出到:{0}</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Search;
|
||||
|
||||
namespace YKanBan.Storage.Workspace;
|
||||
|
||||
@@ -172,6 +173,28 @@ public sealed class WorkspaceRepository : IDisposable
|
||||
return cards;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of the cards matching a compiled search predicate.
|
||||
/// </summary>
|
||||
/// <param name="search">The compiled search predicate and its parameters.</param>
|
||||
/// <returns>The matching card ids.</returns>
|
||||
public IReadOnlyCollection<long> GetCardIdsMatching(CompiledSearch search)
|
||||
{
|
||||
using var command = CreateCommand($"SELECT c.id FROM cards c WHERE {search.Predicate};");
|
||||
foreach ((string name, object value) in search.Parameters)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
var ids = new HashSet<long>();
|
||||
while (reader.Read())
|
||||
{
|
||||
ids.Add(reader.GetInt64(0));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the card with the given id, with its tags loaded.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Search;
|
||||
using YKanBan.Services;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.Storage.Workspace;
|
||||
@@ -19,6 +20,9 @@ public sealed partial class BoardViewModel : ViewModelBase
|
||||
private readonly AppServices _services;
|
||||
private readonly WorkspaceRepository _repository;
|
||||
|
||||
// The active search result filter, or null when the whole board is shown.
|
||||
private IReadOnlyCollection<long>? _matchedCardIds;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the board, loading its current content.
|
||||
/// </summary>
|
||||
@@ -66,6 +70,21 @@ public sealed partial class BoardViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private double columnWidth;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a search filter is currently applied.
|
||||
/// </summary>
|
||||
public bool IsFiltering => _matchedCardIds is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized filter status text shown as the status icon tooltip.
|
||||
/// </summary>
|
||||
public string FilterStatusText => IsFiltering ? Resources.Board_FilterActive : Resources.Board_FilterInactive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status icon opacity: full while filtering, dimmed otherwise.
|
||||
/// </summary>
|
||||
public double FilterIconOpacity => IsFiltering ? 1.0 : 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the board has any columns.
|
||||
/// </summary>
|
||||
@@ -99,8 +118,8 @@ public sealed partial class BoardViewModel : ViewModelBase
|
||||
/// <summary>Gets the interaction that opens the move-card dialog.</summary>
|
||||
public Interaction<CardViewModel> MoveCard { get; } = new();
|
||||
|
||||
/// <summary>Gets the interaction that runs the current search.</summary>
|
||||
public Interaction<Unit> RunSearch { get; } = new();
|
||||
/// <summary>Gets the interaction that shows the search error dialog, carrying the English parser detail.</summary>
|
||||
public Interaction<string> ShowSearchError { get; } = new();
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -113,16 +132,64 @@ public sealed partial class BoardViewModel : ViewModelBase
|
||||
private Task AddNewColumnAsync() => AddColumn.Handle(Unit.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the current search (wired up with the search feature).
|
||||
/// Runs the current search. A blank query shows everything; an invalid
|
||||
/// expression opens the error dialog and keeps the previous results and text.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private Task SearchAsync() => RunSearch.Handle(Unit.Value);
|
||||
private async Task SearchAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SearchText))
|
||||
{
|
||||
ApplyFilter(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SearchNode? query = SearchQueryParser.Parse(SearchText);
|
||||
if (query is null)
|
||||
{
|
||||
ApplyFilter(null);
|
||||
return;
|
||||
}
|
||||
|
||||
CompiledSearch compiled = SearchSqlCompiler.Compile(query);
|
||||
ApplyFilter(_repository.GetCardIdsMatching(compiled));
|
||||
}
|
||||
catch (SearchSyntaxException exception)
|
||||
{
|
||||
// The error dialog carries the parser's non-localized English detail.
|
||||
await ShowSearchError.Handle(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the search box.
|
||||
/// Clears the search box and restores the unfiltered board.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ClearSearch() => SearchText = string.Empty;
|
||||
private void ClearSearch()
|
||||
{
|
||||
SearchText = string.Empty;
|
||||
ApplyFilter(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a new result filter (null clears it) and rebuilds the board when it changed.
|
||||
/// </summary>
|
||||
/// <param name="matchedIds">The new filter, or <see langword="null"/> for no filter.</param>
|
||||
private void ApplyFilter(IReadOnlyCollection<long>? matchedIds)
|
||||
{
|
||||
if (_matchedCardIds is null && matchedIds is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_matchedCardIds = matchedIds;
|
||||
OnPropertyChanged(nameof(IsFiltering));
|
||||
OnPropertyChanged(nameof(FilterStatusText));
|
||||
OnPropertyChanged(nameof(FilterIconOpacity));
|
||||
Reload();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -182,6 +249,12 @@ public sealed partial class BoardViewModel : ViewModelBase
|
||||
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
|
||||
IReadOnlyList<CardModel> cards = _repository.GetCards();
|
||||
|
||||
// An active search hides non-matching cards while every column stays visible.
|
||||
if (_matchedCardIds is not null)
|
||||
{
|
||||
cards = cards.Where(card => _matchedCardIds.Contains(card.Id)).ToList();
|
||||
}
|
||||
|
||||
// Group cards by column once so each column can be built independently.
|
||||
Dictionary<long, List<CardModel>> cardsByColumn = cards
|
||||
.GroupBy(card => card.ColumnId)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace YKanBan.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// The confirmation shown before deleting a tag, naming the tag and how many
|
||||
/// cards it will be removed from.
|
||||
/// </summary>
|
||||
public sealed class DeleteTagConfirmViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the confirmation.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The name of the tag to delete.</param>
|
||||
/// <param name="usageCount">The number of cards using the tag.</param>
|
||||
public DeleteTagConfirmViewModel(string tagName, long usageCount)
|
||||
{
|
||||
Message = string.Format(
|
||||
Resources.Culture ?? CultureInfo.CurrentCulture,
|
||||
Resources.DeleteTag_Confirm_Message,
|
||||
tagName,
|
||||
usageCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized confirmation message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace YKanBan.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// The confirmation dialog shown after a successful Markdown export, naming the
|
||||
/// written file.
|
||||
/// </summary>
|
||||
public sealed class ExportDoneViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the dialog with the exported file path.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path the export was written to.</param>
|
||||
public ExportDoneViewModel(string filePath)
|
||||
{
|
||||
Message = string.Format(
|
||||
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Export_Done_Message, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized confirmation message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace YKanBan.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// The pure-information dialog shown when the search expression is invalid. It
|
||||
/// wraps the parser's non-localized English detail in the localized message.
|
||||
/// </summary>
|
||||
public sealed class SearchErrorViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the dialog with the parser's English detail.
|
||||
/// </summary>
|
||||
/// <param name="detail">The non-localized parser error description.</param>
|
||||
public SearchErrorViewModel(string detail)
|
||||
{
|
||||
Message = string.Format(
|
||||
Resources.Culture ?? CultureInfo.CurrentCulture,
|
||||
Resources.Search_Error_Message,
|
||||
detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized error message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.Workspace;
|
||||
|
||||
namespace YKanBan.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// The add/edit tag dialog: a name, a color chosen with a color picker and an
|
||||
/// optional description. The write happens when the dialog is accepted; a
|
||||
/// duplicate or empty name keeps the dialog open with an inline error.
|
||||
/// </summary>
|
||||
public sealed partial class TagEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkspaceRepository _repository;
|
||||
private readonly TagModel? _existing;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the dialog for a new or existing tag.
|
||||
/// </summary>
|
||||
/// <param name="repository">The workspace repository.</param>
|
||||
/// <param name="existing">The tag to edit, or <see langword="null"/> to add one.</param>
|
||||
public TagEditorViewModel(WorkspaceRepository repository, TagModel? existing)
|
||||
{
|
||||
_repository = repository;
|
||||
_existing = existing;
|
||||
name = existing?.Name ?? string.Empty;
|
||||
description = existing?.Description ?? string.Empty;
|
||||
color = existing is not null
|
||||
? Color.FromRgb(existing.Color.R, existing.Color.G, existing.Color.B)
|
||||
: Colors.Gray;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this dialog creates a new tag.
|
||||
/// </summary>
|
||||
public bool IsNew => _existing is null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized window title.
|
||||
/// </summary>
|
||||
public string WindowTitle => IsNew ? Resources.TagEditor_AddTitle : Resources.TagEditor_EditTitle;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag color.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private Color color;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag description.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string description;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the inline error shown when the name is rejected.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string? errorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the tag when accepted.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the dialog may close.</returns>
|
||||
public bool TryAccept()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
var rgb = new RgbColor(Color.R, Color.G, Color.B);
|
||||
|
||||
try
|
||||
{
|
||||
if (_existing is null)
|
||||
{
|
||||
_repository.AddTag(Name, rgb, Description);
|
||||
}
|
||||
else
|
||||
{
|
||||
_repository.UpdateTag(_existing.Id, Name, rgb, Description);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (SqliteException)
|
||||
{
|
||||
ErrorMessage = Resources.TagEditor_InvalidName;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.ViewModels.Board;
|
||||
|
||||
namespace YKanBan.ViewModels.Tags;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the tag manager: a color swatch, the name, the description and the
|
||||
/// usage count, plus edit and delete actions.
|
||||
/// </summary>
|
||||
public sealed partial class TagItemViewModel
|
||||
{
|
||||
private readonly TagsViewModel _owner;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the row.
|
||||
/// </summary>
|
||||
/// <param name="owner">The owning tag manager.</param>
|
||||
/// <param name="tag">The tag model.</param>
|
||||
/// <param name="usageCount">The number of cards using the tag.</param>
|
||||
public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount)
|
||||
{
|
||||
_owner = owner;
|
||||
Tag = tag;
|
||||
Badge = new TagBadgeViewModel(tag);
|
||||
UsageCount = usageCount;
|
||||
UsageText = string.Format(
|
||||
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Tags_Usage, usageCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying tag model.
|
||||
/// </summary>
|
||||
public TagModel Tag { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the colored badge for the swatch and name.
|
||||
/// </summary>
|
||||
public TagBadgeViewModel Badge { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tag name.
|
||||
/// </summary>
|
||||
public string Name => Tag.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tag description.
|
||||
/// </summary>
|
||||
public string Description => Tag.Description;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the tag has a description.
|
||||
/// </summary>
|
||||
public bool HasDescription => Tag.Description.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of cards using the tag.
|
||||
/// </summary>
|
||||
public long UsageCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized usage count.
|
||||
/// </summary>
|
||||
public string UsageText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asks the manager to edit this tag.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void Edit() => _ = _owner.RequestEditAsync(this);
|
||||
|
||||
/// <summary>
|
||||
/// Asks the manager to delete this tag.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void Delete() => _ = _owner.RequestDeleteAsync(this);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using YKanBan.Models;
|
||||
using YKanBan.Storage.Workspace;
|
||||
|
||||
namespace YKanBan.ViewModels.Tags;
|
||||
|
||||
/// <summary>
|
||||
/// The tags tab: a manager for the workspace's tags (create, rename, recolor,
|
||||
/// re-describe and delete), showing each tag's color, name, description and
|
||||
/// usage count. Tag changes also refresh the board.
|
||||
/// </summary>
|
||||
public sealed partial class TagsViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkspaceRepository _repository;
|
||||
private readonly Action _boardChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the manager, loading the current tags.
|
||||
/// </summary>
|
||||
/// <param name="repository">The workspace repository.</param>
|
||||
/// <param name="boardChanged">Invoked after a tag change so the board refreshes.</param>
|
||||
public TagsViewModel(WorkspaceRepository repository, Action boardChanged)
|
||||
{
|
||||
_repository = repository;
|
||||
_boardChanged = boardChanged;
|
||||
Reload();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workspace repository used for tag access.
|
||||
/// </summary>
|
||||
public WorkspaceRepository Repository => _repository;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tags in id order.
|
||||
/// </summary>
|
||||
public ObservableCollection<TagItemViewModel> Tags { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the workspace has any tags.
|
||||
/// </summary>
|
||||
public bool HasTags => Tags.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the workspace has no tags.
|
||||
/// </summary>
|
||||
public bool HasNoTags => Tags.Count == 0;
|
||||
|
||||
/// <summary>Gets the interaction that opens the new-tag editor.</summary>
|
||||
public Interaction<Unit> AddTag { get; } = new();
|
||||
|
||||
/// <summary>Gets the interaction that opens the tag editor.</summary>
|
||||
public Interaction<TagItemViewModel> EditTag { get; } = new();
|
||||
|
||||
/// <summary>Gets the interaction that confirms and deletes a tag.</summary>
|
||||
public Interaction<TagItemViewModel> DeleteTag { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Opens the new-tag editor.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private Task AddNewTagAsync() => AddTag.Handle(Unit.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Requests the editor for a tag.
|
||||
/// </summary>
|
||||
/// <param name="item">The tag to edit.</param>
|
||||
/// <returns>A task completing after the editor closes.</returns>
|
||||
public Task RequestEditAsync(TagItemViewModel item) => EditTag.Handle(item);
|
||||
|
||||
/// <summary>
|
||||
/// Requests deletion of a tag.
|
||||
/// </summary>
|
||||
/// <param name="item">The tag to delete.</param>
|
||||
/// <returns>A task completing after confirmation.</returns>
|
||||
public Task RequestDeleteAsync(TagItemViewModel item) => DeleteTag.Handle(item);
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the tag list from the repository.
|
||||
/// </summary>
|
||||
public void Reload()
|
||||
{
|
||||
Tags.Clear();
|
||||
foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage())
|
||||
{
|
||||
Tags.Add(new TagItemViewModel(this, tag, usage));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasTags));
|
||||
OnPropertyChanged(nameof(HasNoTags));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the tags and refreshes the board after a tag change.
|
||||
/// </summary>
|
||||
public void RefreshAfterChange()
|
||||
{
|
||||
Reload();
|
||||
_boardChanged();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using YKanBan.Services;
|
||||
using YKanBan.Storage.Workspace;
|
||||
using YKanBan.ViewModels.Board;
|
||||
using YKanBan.ViewModels.Tags;
|
||||
|
||||
namespace YKanBan.ViewModels;
|
||||
|
||||
@@ -32,7 +33,10 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
||||
WorkspacePath = session.FolderPath;
|
||||
_showSettings = showSettings;
|
||||
_showAbout = showAbout;
|
||||
|
||||
// Both tabs share the same repository; tag changes refresh the board.
|
||||
Board = new BoardViewModel(services, session);
|
||||
Tags = new TagsViewModel(session.Repository, Board.Reload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,6 +59,16 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
public BoardViewModel Board { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tags tab view model.
|
||||
/// </summary>
|
||||
public TagsViewModel Tags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the interaction that runs the export flow.
|
||||
/// </summary>
|
||||
public Interaction<Unit> Export { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Opens the settings dialog through the registered interaction.
|
||||
/// </summary>
|
||||
@@ -68,11 +82,8 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
|
||||
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Raised by the export button; the export flow is wired up with the export
|
||||
/// feature.
|
||||
/// Runs the export flow through the registered interaction.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private static void Export()
|
||||
{
|
||||
}
|
||||
private async Task RunExportAsync() => await Export.Handle(Unit.Value);
|
||||
}
|
||||
@@ -13,28 +13,40 @@
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Toolbar: search, add column and the combined display options. -->
|
||||
<Grid Grid.Row="0" Margin="8" ColumnDefinitions="*,Auto,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding SearchText}" VerticalAlignment="Center"
|
||||
Watermark="{x:Static lang:Resources.Board_SearchPlaceholder}"/>
|
||||
<Grid Grid.Row="0" Margin="8" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
|
||||
<!-- Wrapped in a transparent-border hit area so the tooltip triggers across the whole 16x16 box. -->
|
||||
<Border Grid.Column="0" Background="Transparent" Padding="2" Margin="0,0,6,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding FilterStatusText}">
|
||||
<icons:PackIconLucide Kind="ListFilter" Width="16" Height="16"
|
||||
Opacity="{Binding FilterIconOpacity}"/>
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="1" Margin="4,0,0,0" Command="{Binding ClearSearchCommand}"
|
||||
<TextBox Grid.Column="1" Text="{Binding SearchText}" VerticalAlignment="Center"
|
||||
Watermark="{x:Static lang:Resources.Board_SearchPlaceholder}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Grid.Column="2" Margin="4,0,0,0" Command="{Binding ClearSearchCommand}"
|
||||
ToolTip.Tip="{x:Static lang:Resources.Board_ClearSearch}">
|
||||
<icons:PackIconLucide Kind="X" Width="16" Height="16"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Margin="4,0,0,0" Command="{Binding SearchCommand}"
|
||||
<Button Grid.Column="3" Margin="4,0,0,0" Command="{Binding SearchCommand}"
|
||||
ToolTip.Tip="{x:Static lang:Resources.Board_Search}">
|
||||
<icons:PackIconLucide Kind="Search" Width="16" Height="16"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" Margin="12,0,0,0" Command="{Binding AddNewColumnCommand}">
|
||||
<Button Grid.Column="4" Margin="12,0,0,0" Command="{Binding AddNewColumnCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
||||
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="4" Margin="4,0,0,0">
|
||||
<Button Grid.Column="5" Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<icons:PackIconLucide Kind="SlidersHorizontal" Width="16" Height="16"/>
|
||||
<TextBlock Text="{x:Static lang:Resources.Board_Display}"/>
|
||||
|
||||
@@ -27,9 +27,7 @@ public partial class BoardView : UserControl
|
||||
board.EditCard.RegisterHandler(card => EditCardAsync(board, null, card));
|
||||
board.DeleteCard.RegisterHandler(card => DeleteCardAsync(board, card));
|
||||
board.MoveCard.RegisterHandler(card => MoveCardAsync(board, card));
|
||||
|
||||
// The search button is present in the shell; searching arrives with the search feature.
|
||||
board.RunSearch.RegisterHandler(_ => Task.CompletedTask);
|
||||
board.ShowSearchError.RegisterHandler(ShowSearchErrorAsync);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,4 +143,19 @@ public partial class BoardView : UserControl
|
||||
board.Reload();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shows the search syntax error dialog.</summary>
|
||||
private async Task ShowSearchErrorAsync(string detail)
|
||||
{
|
||||
if (OwningWindow is not Window owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new SearchErrorDialog
|
||||
{
|
||||
DataContext = new SearchErrorViewModel(detail),
|
||||
};
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="200"
|
||||
x:Class="YKanBan.Views.Dialogs.DeleteTagConfirmDialog"
|
||||
x:DataType="vm:DeleteTagConfirmViewModel"
|
||||
Title="{x:Static lang:Resources.DeleteTag_Confirm_Title}"
|
||||
Width="420" Height="200" MinWidth="420" MinHeight="200" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="20">
|
||||
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
|
||||
MinWidth="90" IsDefault="True"/>
|
||||
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
|
||||
MinWidth="90" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace YKanBan.Views.Dialogs;
|
||||
|
||||
public partial class DeleteTagConfirmDialog : Window
|
||||
{
|
||||
public DeleteTagConfirmDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Confirms the deletion.</summary>
|
||||
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
|
||||
|
||||
/// <summary>Cancels the deletion.</summary>
|
||||
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="460" d:DesignHeight="200"
|
||||
x:Class="YKanBan.Views.Dialogs.ExportDoneDialog"
|
||||
x:DataType="vm:ExportDoneViewModel"
|
||||
Title="{x:Static lang:Resources.Export_Done_Title}"
|
||||
Width="460" Height="200" MinWidth="460" MinHeight="200" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="20">
|
||||
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
|
||||
<Button Grid.Row="1" Content="{x:Static lang:Resources.Common_Ok}"
|
||||
Click="OnOkClick" HorizontalAlignment="Right" MinWidth="90"
|
||||
IsDefault="True" IsCancel="True"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace YKanBan.Views.Dialogs;
|
||||
|
||||
public partial class ExportDoneDialog : Window
|
||||
{
|
||||
public ExportDoneDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Closes the dialog.</summary>
|
||||
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="440" d:DesignHeight="200"
|
||||
x:Class="YKanBan.Views.Dialogs.SearchErrorDialog"
|
||||
x:DataType="vm:SearchErrorViewModel"
|
||||
Title="{x:Static lang:Resources.Search_Error_Title}"
|
||||
Width="440" Height="200" MinWidth="440" MinHeight="200" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="20">
|
||||
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
|
||||
<Button Grid.Row="1" Content="{x:Static lang:Resources.Common_Ok}"
|
||||
Click="OnOkClick" HorizontalAlignment="Right" MinWidth="90"
|
||||
IsDefault="True" IsCancel="True"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace YKanBan.Views.Dialogs;
|
||||
|
||||
public partial class SearchErrorDialog : Window
|
||||
{
|
||||
public SearchErrorDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Closes the dialog.</summary>
|
||||
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="440" d:DesignHeight="380"
|
||||
x:Class="YKanBan.Views.Dialogs.TagEditorDialog"
|
||||
x:DataType="vm:TagEditorViewModel"
|
||||
Title="{Binding WindowTitle}"
|
||||
Width="440" Height="380" MinWidth="440" MinHeight="380" CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="20">
|
||||
<StackPanel Grid.Row="0" Spacing="6">
|
||||
<TextBlock Text="{x:Static lang:Resources.TagEditor_Name}"/>
|
||||
<TextBox Text="{Binding Name}"/>
|
||||
|
||||
<TextBlock Text="{x:Static lang:Resources.TagEditor_Color}" Margin="0,6,0,0"/>
|
||||
<ColorPicker Color="{Binding Color, Mode=TwoWay}" HorizontalAlignment="Left"/>
|
||||
|
||||
<TextBlock Text="{x:Static lang:Resources.TagEditor_Description}" Margin="0,6,0,0"/>
|
||||
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalContentAlignment="Top" Height="70"/>
|
||||
|
||||
<TextBlock Text="{Binding ErrorMessage}"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
|
||||
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="8" Margin="0,16,0,0">
|
||||
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick" MinWidth="90" IsDefault="True"/>
|
||||
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" MinWidth="90" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using YKanBan.ViewModels.Dialogs;
|
||||
|
||||
namespace YKanBan.Views.Dialogs;
|
||||
|
||||
public partial class TagEditorDialog : Window
|
||||
{
|
||||
public TagEditorDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Writes the tag and closes when accepted.</summary>
|
||||
private void OnOkClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept())
|
||||
{
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes without writing.</summary>
|
||||
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.Tags"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:icons="using:IconPacks.Avalonia.Lucide"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||
x:Class="YKanBan.Views.Tags.TagsView"
|
||||
x:DataType="vm:TagsViewModel">
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Margin="8">
|
||||
|
||||
<Button Grid.Row="0" Command="{Binding AddNewTagCommand}"
|
||||
HorizontalAlignment="Right" Margin="0,0,0,8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
|
||||
<TextBlock Text="{x:Static lang:Resources.Tags_AddTag}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Tags}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TagItemViewModel">
|
||||
<Border BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Border Grid.Column="0" Width="16" Height="16" CornerRadius="4"
|
||||
Background="{Binding Badge.Background}"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Description}" IsVisible="{Binding HasDescription}"
|
||||
Opacity="0.7" FontSize="12" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding UsageText}" Opacity="0.6" FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Command="{Binding EditCommand}" Padding="4,2" Margin="4,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{x:Static lang:Resources.Common_Edit}">
|
||||
<icons:PackIconLucide Kind="Pencil" Width="16" Height="16"/>
|
||||
</Button>
|
||||
<Button Grid.Column="3" Command="{Binding DeleteCommand}" Padding="4,2"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{x:Static lang:Resources.Common_Delete}">
|
||||
<icons:PackIconLucide Kind="Trash2" Width="16" Height="16"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="{x:Static lang:Resources.Tags_Empty}"
|
||||
IsVisible="{Binding HasNoTags}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,76 @@
|
||||
using Avalonia.Controls;
|
||||
using YKanBan.ViewModels.Dialogs;
|
||||
using YKanBan.ViewModels.Tags;
|
||||
using YKanBan.Views.Dialogs;
|
||||
|
||||
namespace YKanBan.Views.Tags;
|
||||
|
||||
public partial class TagsView : UserControl
|
||||
{
|
||||
private bool _registered;
|
||||
|
||||
public TagsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is TagsViewModel tags && !_registered)
|
||||
{
|
||||
_registered = true;
|
||||
tags.AddTag.RegisterHandler(_ => EditTagAsync(tags, null));
|
||||
tags.EditTag.RegisterHandler(item => EditTagAsync(tags, item));
|
||||
tags.DeleteTag.RegisterHandler(item => DeleteTagAsync(tags, item));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the window that owns this view, used as the dialog owner.</summary>
|
||||
private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
/// <summary>Shows the add/edit tag dialog and refreshes on success.</summary>
|
||||
private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item)
|
||||
{
|
||||
if (OwningWindow is not Window owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new TagEditorDialog
|
||||
{
|
||||
DataContext = new TagEditorViewModel(tags.Repository, item?.Tag),
|
||||
};
|
||||
if (await dialog.ShowDialog<bool>(owner))
|
||||
{
|
||||
tags.RefreshAfterChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Confirms and deletes a tag, then refreshes.</summary>
|
||||
private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item)
|
||||
{
|
||||
if (OwningWindow is not Window owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool confirmed = true;
|
||||
if (App.Services?.Config.Confirmations.DeleteTag == true)
|
||||
{
|
||||
var dialog = new DeleteTagConfirmDialog
|
||||
{
|
||||
DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount),
|
||||
};
|
||||
confirmed = await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tags.Repository.DeleteTag(item.Tag.Id);
|
||||
tags.RefreshAfterChange();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:icons="using:IconPacks.Avalonia.Lucide"
|
||||
xmlns:board="using:YKanBan.Views.Board"
|
||||
xmlns:tags="using:YKanBan.Views.Tags"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
|
||||
@@ -27,7 +28,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Command="{Binding ExportCommand}">
|
||||
<Button Command="{Binding RunExportCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<icons:PackIconLucide Kind="FileDown" Width="16" Height="16"/>
|
||||
<TextBlock Text="{x:Static lang:Resources.Main_Export}"/>
|
||||
@@ -54,7 +55,9 @@
|
||||
<TabItem Header="{x:Static lang:Resources.Tab_Board}">
|
||||
<board:BoardView DataContext="{Binding Board}"/>
|
||||
</TabItem>
|
||||
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
|
||||
<TabItem Header="{x:Static lang:Resources.Tab_Tags}">
|
||||
<tags:TagsView DataContext="{Binding Tags}"/>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,11 +1,79 @@
|
||||
using System.Text;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using YKanBan.Export;
|
||||
using YKanBan.ViewModels;
|
||||
using YKanBan.ViewModels.Dialogs;
|
||||
using YKanBan.Views.Dialogs;
|
||||
using Resx = YKanBan.Resources;
|
||||
|
||||
namespace YKanBan.Views;
|
||||
|
||||
public partial class WorkspaceView : UserControl
|
||||
{
|
||||
private bool _registered;
|
||||
|
||||
public WorkspaceView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is WorkspaceViewModel viewModel && !_registered)
|
||||
{
|
||||
_registered = true;
|
||||
viewModel.Export.RegisterHandler(ExportAsync);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Prompts for a file and writes the Markdown board export to it.</summary>
|
||||
private async Task ExportAsync(Unit input)
|
||||
{
|
||||
if (DataContext is not WorkspaceViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset exportedAt = DateTimeOffset.Now;
|
||||
IStorageFile? file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = Resx.Main_Export,
|
||||
SuggestedFileName = MarkdownExporter.BuildFileName(viewModel.WorkspaceName, exportedAt),
|
||||
DefaultExtension = "md",
|
||||
FileTypeChoices = [new FilePickerFileType("Markdown") { Patterns = ["*.md"] }],
|
||||
});
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var repository = viewModel.Session.Repository;
|
||||
string markdown = MarkdownExporter.Export(
|
||||
viewModel.WorkspaceName,
|
||||
repository.GetColumns(),
|
||||
repository.GetCards(),
|
||||
repository.GetTagsWithUsage(),
|
||||
exportedAt);
|
||||
|
||||
await using (Stream stream = await file.OpenWriteAsync())
|
||||
await using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
|
||||
{
|
||||
await writer.WriteAsync(markdown);
|
||||
}
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is Window owner)
|
||||
{
|
||||
var dialog = new ExportDoneDialog
|
||||
{
|
||||
DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name),
|
||||
};
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.6" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.6" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.6" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.6" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.6">
|
||||
@@ -48,6 +47,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageReference Include="Semi.Avalonia" Version="11.2.1.10" />
|
||||
<PackageReference Include="Semi.Avalonia.ColorPicker" Version="11.2.1.10" />
|
||||
<PackageReference Include="IconPacks.Avalonia.Lucide" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.20" />
|
||||
<PackageReference Include="Pidgin" Version="3.5.1" />
|
||||
|
||||
Reference in new issue
Block a user