feat: implement board tab with dialogs, virtualized card lists and refresh-after-write

- board: columns in id order, per-column sort via config, column width presets (240/320/480) and custom 160-720
- column header new-card and edit/delete menu; card menu delete and move-to-column
- card editor as one unit of work with dirty check and discard confirmation; nested tag picker writes new tags immediately
- schema constraint violations mapped to typed exceptions and shown inside the modal
- dialog service with owner stack so nested modals center on their parent and get FlowDirection
- repository: filtered GetCards, CountCards; view-model tests
This commit is contained in:
doyaGu committed 2026-10-03 03:33:25 -04:00
1 parent 84d08c6768
commit e3f58ce150
41 files changed
+3004 -26

No files matched your search

@@ -1,5 +1,7 @@
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Search;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
@@ -253,8 +255,87 @@ public class WorkspaceRepositoryTests
public void AddTagWithDuplicateNameIsRejected()
{
using var fixture = new RepositoryFixture();
Assert.ThrowsExactly<SqliteException>(() =>
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
DuplicateNameException exception = Assert.ThrowsExactly<DuplicateNameException>(() =>
fixture.Repository.AddTag("BUG", RgbColor.Parse("#123456"), ""));
Assert.AreEqual(NamedEntity.Tag, exception.Entity);
Assert.AreEqual("BUG", exception.Name);
Assert.AreEqual(2, fixture.Repository.GetTags().Count);
}
[TestMethod]
public void AddTagWithEmptyNameIsRejected()
{
using var fixture = new RepositoryFixture();
NameRequiredException exception = Assert.ThrowsExactly<NameRequiredException>(() =>
fixture.Repository.AddTag("", RgbColor.Parse("#123456"), ""));
Assert.AreEqual(NamedEntity.Tag, exception.Entity);
}
[TestMethod]
public void UpdateTagToExistingNameIsRejectedAndKeepsTheRow()
{
using var fixture = new RepositoryFixture();
Assert.ThrowsExactly<DuplicateNameException>(() =>
fixture.Repository.UpdateTag(fixture.UiTagId, "Bug", RgbColor.Parse("#00ff00"), ""));
Assert.AreEqual("ui", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.UiTagId).Name);
}
[TestMethod]
public void AddColumnWithDuplicateTitleIsRejectedCaseInsensitively()
{
using var fixture = new RepositoryFixture();
DuplicateNameException exception = Assert.ThrowsExactly<DuplicateNameException>(() =>
fixture.Repository.AddColumn("COL", ""));
Assert.AreEqual(NamedEntity.Column, exception.Entity);
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
}
[TestMethod]
public void AddColumnWithEmptyTitleIsRejected()
{
using var fixture = new RepositoryFixture();
NameRequiredException exception = Assert.ThrowsExactly<NameRequiredException>(() =>
fixture.Repository.AddColumn("", "description"));
Assert.AreEqual(NamedEntity.Column, exception.Entity);
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
}
[TestMethod]
public void UpdateColumnConstraintViolationsAreTyped()
{
using var fixture = new RepositoryFixture();
ColumnModel other = fixture.Repository.AddColumn("Other", "");
Assert.ThrowsExactly<DuplicateNameException>(() => fixture.Repository.UpdateColumn(other.Id, "col", ""));
Assert.ThrowsExactly<NameRequiredException>(() => fixture.Repository.UpdateColumn(other.Id, "", ""));
Assert.AreEqual("Other", fixture.Repository.GetColumn(other.Id)!.Title);
}
[TestMethod]
public void GetCardsWithFilterReturnsMatchesWithAllOfTheirTags()
{
using var fixture = new RepositoryFixture();
fixture.Repository.AddCard(fixture.ColumnId, "Other", "", []);
// Filtering by one tag must still load both tags of the matching card.
CompiledSearch filter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("tag:ui")!);
IReadOnlyList<CardModel> cards = fixture.Repository.GetCards(filter);
Assert.AreEqual(1, cards.Count);
Assert.AreEqual(fixture.CardId, cards[0].Id);
CollectionAssert.AreEqual(new[] { "bug", "ui" }, cards[0].Tags.Select(tag => tag.Name).ToArray());
Assert.AreEqual(2, fixture.Repository.GetCards(null).Count);
}
[TestMethod]
public void CountCardsCountsPerColumn()
{
using var fixture = new RepositoryFixture();
ColumnModel empty = fixture.Repository.AddColumn("Empty", "");
fixture.Repository.AddCard(fixture.ColumnId, "", "", []);
Assert.AreEqual(2L, fixture.Repository.CountCards(fixture.ColumnId));
Assert.AreEqual(0L, fixture.Repository.CountCards(empty.Id));
}
[TestMethod]
@@ -0,0 +1,393 @@
using YKanBan.Models;
using YKanBan.Search;
using YKanBan.Storage;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Tests.ViewModels;
/// <summary>
/// View-model tests of the board and its dialogs against a real temporary
/// workspace, with a scripted dialog service standing in for the modal windows.
/// </summary>
[TestClass]
public class BoardViewModelTests
{
/// <summary>
/// Records every dialog shown and lets a test script answer it. The result
/// is whatever the dialog closed with; a dialog the script leaves open
/// counts as dismissed.
/// </summary>
private sealed class ScriptedDialogService : IDialogService
{
public List<DialogViewModelBase> Shown { get; } = [];
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
{
Shown.Add(dialog);
bool? result = null;
dialog.CloseRequested += (_, closed) => result ??= closed;
await Script(dialog);
return result ?? false;
}
}
/// <summary>Answers every confirmation dialog with the given choice.</summary>
private static Func<DialogViewModelBase, Task> AnswerConfirmations(bool confirm) => dialog =>
{
if (dialog is ConfirmDialogViewModel confirmation)
{
if (confirm)
{
confirmation.ConfirmCommand.Execute(null);
return Task.CompletedTask;
}
return confirmation.CancelCommand.ExecuteAsync(null);
}
return Task.CompletedTask;
};
private sealed class Fixture : IDisposable
{
public TempDirectory Directory { get; } = new();
public WorkspaceRepository Repository { get; }
public AppConfig Config { get; } = new();
public ScriptedDialogService Dialogs { get; } = new();
public Fixture()
{
WorkspaceInitializer.Initialize(Directory.FullPath);
Repository = new WorkspaceRepository(Directory.FullPath);
}
public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs);
public void Dispose()
{
Repository.Dispose();
Directory.Dispose();
}
}
[TestMethod]
public void RefreshListsColumnsInIdOrderAndSortsCardsWithinEachColumn()
{
using var fixture = new Fixture();
ColumnModel first = fixture.Repository.AddColumn("A", "");
ColumnModel second = fixture.Repository.AddColumn("B", "");
fixture.Repository.AddCard(first.Id, "zeta", "", []);
fixture.Repository.AddCard(first.Id, "alpha", "", []);
fixture.Repository.AddCard(second.Id, "x", "", []);
fixture.Config.Sort.Card = CardSortOption.Title;
BoardViewModel board = fixture.CreateBoard();
CollectionAssert.AreEqual(new[] { "A", "B" }, board.Columns.Select(column => column.Title).ToArray());
CollectionAssert.AreEqual(new[] { "alpha", "zeta" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
Assert.AreEqual(1, board.Columns[1].CardCount);
}
[TestMethod]
public void ChangingTheSortPersistsItToTheConfigurationAndResorts()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "b", "", []);
fixture.Repository.AddCard(column.Id, "a", "", []);
BoardViewModel board = fixture.CreateBoard();
CollectionAssert.AreEqual(new[] { "b", "a" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
board.SelectedSort = board.SortOptions.Single(option => (CardSortOption)option.Value == CardSortOption.Title);
Assert.AreEqual(CardSortOption.Title, fixture.Config.Sort.Card);
CollectionAssert.AreEqual(new[] { "a", "b" }, board.Columns[0].Cards.Select(card => card.Title).ToArray());
}
[TestMethod]
public async Task WritesRefreshWithTheActiveFilterAndKeepEmptyColumns()
{
using var fixture = new Fixture();
ColumnModel todo = fixture.Repository.AddColumn("Todo", "");
ColumnModel done = fixture.Repository.AddColumn("Done", "");
CardModel match = fixture.Repository.AddCard(todo.Id, "match me", "", []);
fixture.Repository.AddCard(todo.Id, "other", "", []);
BoardViewModel board = fixture.CreateBoard();
board.ActiveFilter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("match")!);
board.Refresh();
Assert.AreEqual(1, board.Columns[0].CardCount);
// A move is a write: the board re-queries with the active filter.
CardViewModel card = board.Columns[0].Cards.Single();
MoveTargetViewModel target = card.MoveTargets.Single(entry => entry.ColumnId == done.Id);
Assert.IsFalse(card.MoveTargets.Single(entry => entry.ColumnId == todo.Id).IsEnabled);
await target.Command.ExecuteAsync(target.ColumnId);
Assert.AreEqual(2, board.Columns.Count, "Columns emptied by the filter stay on the board.");
Assert.AreEqual(0, board.Columns[0].CardCount);
Assert.AreEqual(match.Id, board.Columns[1].Cards.Single().Card.Id);
}
[TestMethod]
public async Task DeclinedColumnDeletionKeepsTheColumnAndShowsItsCardCount()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "", "", []);
fixture.Repository.AddCard(column.Id, "", "", []);
BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(false);
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
var confirmation = (ConfirmDialogViewModel)fixture.Dialogs.Shown.Single();
StringAssert.Contains(confirmation.Message, "2");
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
}
[TestMethod]
public async Task ColumnDeletionWithoutConfirmationDeletesImmediately()
{
using var fixture = new Fixture();
fixture.Repository.AddColumn("A", "");
fixture.Config.Confirmations.DeleteColumn = false;
BoardViewModel board = fixture.CreateBoard();
await board.Columns[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
Assert.AreEqual(0, board.Columns.Count);
}
[TestMethod]
public async Task ConfirmedCardDeletionDeletesTheCard()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "gone", "", []);
BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(true);
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
Assert.AreEqual(0, board.Columns[0].CardCount);
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
}
[TestMethod]
public async Task DuplicateColumnTitleIsReportedInsideTheDialog()
{
using var fixture = new Fixture();
fixture.Repository.AddColumn("Todo", "");
BoardViewModel board = fixture.CreateBoard();
ColumnEditorDialogViewModel? editor = null;
bool closed = false;
fixture.Dialogs.Script = dialog =>
{
editor = (ColumnEditorDialogViewModel)dialog;
editor.CloseRequested += (_, _) => closed = true;
editor.ColumnTitle = "TODO";
editor.ConfirmCommand.Execute(null);
return Task.CompletedTask;
};
await board.AddColumnCommand.ExecuteAsync(null);
Assert.IsNotNull(editor);
Assert.IsFalse(closed, "The dialog stays open on a constraint violation.");
Assert.IsTrue(editor.HasError);
StringAssert.Contains(editor.ErrorMessage, "TODO");
Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
}
[TestMethod]
public async Task NewCardIsWrittenWithItsTagsOnConfirmation()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(255, 0, 0), "");
BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = async dialog =>
{
switch (dialog)
{
case CardEditorDialogViewModel editor:
editor.CardTitle = "Fix it";
editor.Content = "Details";
await editor.AddTagCommand.ExecuteAsync(null);
editor.ConfirmCommand.Execute(null);
break;
case TagPickerDialogViewModel picker:
picker.PickCommand.Execute(picker.FilteredTags.Single());
break;
}
};
await board.Columns[0].AddCardCommand.ExecuteAsync(null);
CardModel card = fixture.Repository.GetCards().Single();
Assert.AreEqual("Fix it", card.Title);
Assert.AreEqual("Details", card.Content);
Assert.AreEqual(bug.Id, card.Tags.Single().Id);
Assert.AreEqual(card.Id, board.Columns[0].Cards.Single().Card.Id);
}
[TestMethod]
public void UnchangedEditConfirmationWritesNothing()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
CardModel card = fixture.Repository.AddCard(column.Id, "t", "c", []);
using (var connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath))
{
SqliteTestHelper.Exec(connection, "UPDATE cards SET updated_at = 100;");
}
card = fixture.Repository.GetCard(card.Id)!;
var editor = CardEditorDialogViewModel.ForExistingCard(fixture.Repository, fixture.Dialogs, card, confirmDiscard: true);
editor.CardTitle = "changed";
editor.CardTitle = "t";
Assert.IsFalse(editor.IsDirty);
editor.ConfirmCommand.Execute(null);
Assert.AreEqual(100, fixture.Repository.GetCard(card.Id)!.UpdatedAt);
}
[TestMethod]
public async Task DirtyCancelAsksAndStaysOpenWhenDeclined()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true);
bool? closedWith = null;
editor.CloseRequested += (_, result) => closedWith = result;
editor.Content = "draft";
fixture.Dialogs.Script = AnswerConfirmations(false);
await editor.RequestCancelAsync();
Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
fixture.Dialogs.Script = AnswerConfirmations(true);
await editor.RequestCancelAsync();
Assert.IsFalse(closedWith);
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
}
[TestMethod]
public async Task CleanCancelClosesWithoutAsking()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true);
bool? closedWith = null;
editor.CloseRequested += (_, result) => closedWith = result;
await editor.RequestCancelAsync();
Assert.IsFalse(closedWith);
Assert.AreEqual(0, fixture.Dialogs.Shown.Count);
}
[TestMethod]
public async Task TagCreatedInThePickerSurvivesACancelledCardEdit()
{
using var fixture = new Fixture();
ColumnModel column = fixture.Repository.AddColumn("A", "");
var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: false);
fixture.Dialogs.Script = dialog =>
{
if (dialog is TagPickerDialogViewModel picker)
{
picker.NewName = "fresh";
picker.NewColor = "#123456";
picker.CreateCommand.Execute(null);
}
return Task.CompletedTask;
};
await editor.AddTagCommand.ExecuteAsync(null);
Assert.AreEqual("fresh", editor.Tags.Single().Name);
await editor.RequestCancelAsync();
Assert.AreEqual("fresh", fixture.Repository.GetTags().Single().Name);
Assert.AreEqual(0, fixture.Repository.GetCards().Count);
}
[TestMethod]
public void TagPickerReportsInvalidColorAndDuplicateNameWithoutClosing()
{
using var fixture = new Fixture();
fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "");
var picker = new TagPickerDialogViewModel(fixture.Repository, []);
bool closed = false;
picker.CloseRequested += (_, _) => closed = true;
picker.NewName = "new";
picker.NewColor = "red";
picker.CreateCommand.Execute(null);
Assert.AreEqual(YKanBan.Resources.Error_Tag_InvalidColor, picker.ErrorMessage);
picker.NewName = "BUG";
picker.NewColor = "#ff0000";
picker.CreateCommand.Execute(null);
StringAssert.Contains(picker.ErrorMessage, "BUG");
Assert.IsFalse(closed);
Assert.AreEqual(1, fixture.Repository.GetTags().Count);
}
[TestMethod]
public void TagPickerHidesAssignedTagsAndFiltersCaseInsensitively()
{
using var fixture = new Fixture();
TagModel bug = fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), "");
fixture.Repository.AddTag("UI", new RgbColor(1, 2, 3), "");
fixture.Repository.AddTag("Build", new RgbColor(1, 2, 3), "");
var picker = new TagPickerDialogViewModel(fixture.Repository, [bug.Id]);
picker.FilterText = "bu";
CollectionAssert.AreEqual(new[] { "Build" }, picker.FilteredTags.Select(tag => tag.Name).ToArray());
picker.FilterText = "zzz";
Assert.IsTrue(picker.NoMatches);
}
[TestMethod]
public void ColumnWidthDialogCommitsPresetsAndClampsCustomPixels()
{
var settings = new ColumnWidthSettings();
var dialog = new ColumnWidthDialogViewModel(settings) { IsStandard = false, IsCustom = true, CustomPixels = 5000 };
dialog.ConfirmCommand.Execute(null);
Assert.AreEqual(ColumnWidthPreset.Custom, settings.Preset);
Assert.AreEqual(AppConfig.MaxColumnPixels, settings.CustomPixels);
Assert.AreEqual(AppConfig.MaxColumnPixels, ColumnWidths.Resolve(settings));
dialog = new ColumnWidthDialogViewModel(settings) { IsCustom = false, IsWide = true };
dialog.ConfirmCommand.Execute(null);
Assert.AreEqual(ColumnWidthPreset.Wide, settings.Preset);
Assert.AreEqual(ColumnWidths.Wide, ColumnWidths.Resolve(settings));
}
[TestMethod]
public void CardHeaderAndExcerptFollowTheDisplayRules()
{
var untitled = new CardModel(61, 1, "", "", 0, 0, []);
var titled = untitled with { Title = "Foo bar" };
Assert.AreEqual("#61", CardViewModel.FormatHeader(untitled));
Assert.AreEqual("#61 Foo bar", CardViewModel.FormatHeader(titled));
Assert.AreEqual("short", CardViewModel.MakeExcerpt(" short \n"));
string longContent = string.Concat(Enumerable.Repeat("😀", CardViewModel.ExcerptLength + 5));
string excerpt = CardViewModel.MakeExcerpt(longContent);
Assert.IsTrue(excerpt.EndsWith('…'));
Assert.AreEqual((CardViewModel.ExcerptLength * 2) + 1, excerpt.Length, "Surrogate pairs are kept whole.");
}
}
+10
View File
@@ -26,10 +26,20 @@
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/>
<SolidColorBrush x:Key="PanelBorderBrush" Color="#E2E4EA"/>
<SolidColorBrush x:Key="ColumnBackgroundBrush" Color="#F3F4F7"/>
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardForegroundBrush" Color="#1C1F23"/>
<SolidColorBrush x:Key="ErrorTextBrush" Color="#D52515"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/>
<SolidColorBrush x:Key="PanelBorderBrush" Color="#33363F"/>
<SolidColorBrush x:Key="ColumnBackgroundBrush" Color="#1A1C21"/>
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#25282F"/>
<SolidColorBrush x:Key="CardForegroundBrush" Color="#E6E8EC"/>
<SolidColorBrush x:Key="ErrorTextBrush" Color="#FC725A"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
+5 -2
View File
@@ -58,12 +58,15 @@ public partial class App : Application
: FlowDirection.LeftToRight;
ApplyTheme(Services.Config.Theme);
var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []);
desktop.MainWindow = new MainWindow
var dialogs = new DialogService();
var viewModel = new MainWindowViewModel(Services, dialogs, desktop.Args ?? []);
var mainWindow = new MainWindow
{
DataContext = viewModel,
FlowDirection = UiFlowDirection,
};
dialogs.Attach(mainWindow);
desktop.MainWindow = mainWindow;
}
base.OnFrameworkInitializationCompleted();
+156
View File
@@ -184,4 +184,160 @@
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| Name | Color | Description | Usage |</value>
</data>
<data name="Common_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Common_Discard" xml:space="preserve">
<value>Discard</value>
</data>
<data name="Common_TimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm</value>
</data>
<data name="Board_AddColumn" xml:space="preserve">
<value>New column</value>
</data>
<data name="Board_ColumnWidth" xml:space="preserve">
<value>Column width</value>
</data>
<data name="Board_Sort" xml:space="preserve">
<value>Card order</value>
</data>
<data name="Board_AddCard" xml:space="preserve">
<value>New card</value>
</data>
<data name="Sort_Id" xml:space="preserve">
<value>By id</value>
</data>
<data name="Sort_UpdatedAt" xml:space="preserve">
<value>Recently modified</value>
</data>
<data name="Sort_Title" xml:space="preserve">
<value>By title</value>
</data>
<data name="Menu_More" xml:space="preserve">
<value>More</value>
</data>
<data name="Menu_Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="Menu_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Menu_MoveTo" xml:space="preserve">
<value>Move to column</value>
</data>
<data name="Field_Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="Field_Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="Field_Content" xml:space="preserve">
<value>Content</value>
</data>
<data name="Field_Tags" xml:space="preserve">
<value>Tags</value>
</data>
<data name="Field_Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="Field_Color" xml:space="preserve">
<value>#RRGGBB</value>
</data>
<data name="ColumnEditor_TitleNew" xml:space="preserve">
<value>New column</value>
</data>
<data name="ColumnEditor_TitleEdit" xml:space="preserve">
<value>Edit column</value>
</data>
<data name="ColumnWidth_Title" xml:space="preserve">
<value>Column width</value>
</data>
<data name="ColumnWidth_Narrow" xml:space="preserve">
<value>Narrow ({0} px)</value>
</data>
<data name="ColumnWidth_Standard" xml:space="preserve">
<value>Standard ({0} px)</value>
</data>
<data name="ColumnWidth_Wide" xml:space="preserve">
<value>Wide ({0} px)</value>
</data>
<data name="ColumnWidth_Custom" xml:space="preserve">
<value>Custom</value>
</data>
<data name="ColumnWidth_Range" xml:space="preserve">
<value>{0}–{1} px</value>
</data>
<data name="CardEditor_TitleNew" xml:space="preserve">
<value>New card</value>
</data>
<data name="CardEditor_TitleEdit" xml:space="preserve">
<value>Edit card</value>
</data>
<data name="CardEditor_TitleWatermark" xml:space="preserve">
<value>No title</value>
</data>
<data name="CardEditor_Created" xml:space="preserve">
<value>Created {0}</value>
</data>
<data name="CardEditor_Modified" xml:space="preserve">
<value>Modified {0}</value>
</data>
<data name="CardEditor_AddTag" xml:space="preserve">
<value>Add tag</value>
</data>
<data name="CardEditor_RemoveTag" xml:space="preserve">
<value>Remove tag</value>
</data>
<data name="TagPicker_Title" xml:space="preserve">
<value>Add tag</value>
</data>
<data name="TagPicker_FilterWatermark" xml:space="preserve">
<value>Filter tags</value>
</data>
<data name="TagPicker_NoMatches" xml:space="preserve">
<value>No matching tags</value>
</data>
<data name="TagPicker_NewTag" xml:space="preserve">
<value>New tag</value>
</data>
<data name="TagPicker_Create" xml:space="preserve">
<value>Create and add</value>
</data>
<data name="Confirm_Title" xml:space="preserve">
<value>Confirm</value>
</data>
<data name="Confirm_DeleteCard" xml:space="preserve">
<value>Delete card "{0}"?</value>
</data>
<data name="Confirm_DeleteColumn" xml:space="preserve">
<value>Delete column "{0}" and its {1} card(s)?</value>
</data>
<data name="Confirm_DiscardEdit" xml:space="preserve">
<value>Discard the unsaved changes?</value>
</data>
<data name="Error_Column_TitleRequired" xml:space="preserve">
<value>The column title is required.</value>
</data>
<data name="Error_Column_Duplicate" xml:space="preserve">
<value>A column named "{0}" already exists (names are case-insensitive).</value>
</data>
<data name="Error_Tag_NameRequired" xml:space="preserve">
<value>The tag name is required.</value>
</data>
<data name="Error_Tag_Duplicate" xml:space="preserve">
<value>A tag named "{0}" already exists (names are case-insensitive).</value>
</data>
<data name="Error_Tag_InvalidColor" xml:space="preserve">
<value>The color must be written as #RRGGBB.</value>
</data>
<data name="Error_SaveFailed" xml:space="preserve">
<value>Saving failed: {0}</value>
</data>
<data name="Error_Operation_Title" xml:space="preserve">
<value>Operation failed</value>
</data>
<data name="Error_Operation_Message" xml:space="preserve">
<value>The change could not be written to the workspace. The board shows the stored state.</value>
</data>
</root>
@@ -184,4 +184,160 @@
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
</data>
<data name="Common_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Common_Discard" xml:space="preserve">
<value>丢弃</value>
</data>
<data name="Common_TimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="Board_AddColumn" xml:space="preserve">
<value>新建列</value>
</data>
<data name="Board_ColumnWidth" xml:space="preserve">
<value>列宽</value>
</data>
<data name="Board_Sort" xml:space="preserve">
<value>卡片排序</value>
</data>
<data name="Board_AddCard" xml:space="preserve">
<value>新建卡片</value>
</data>
<data name="Sort_Id" xml:space="preserve">
<value>按 ID</value>
</data>
<data name="Sort_UpdatedAt" xml:space="preserve">
<value>最近修改</value>
</data>
<data name="Sort_Title" xml:space="preserve">
<value>按标题</value>
</data>
<data name="Menu_More" xml:space="preserve">
<value>更多</value>
</data>
<data name="Menu_Edit" xml:space="preserve">
<value>编辑</value>
</data>
<data name="Menu_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Menu_MoveTo" xml:space="preserve">
<value>移动到列</value>
</data>
<data name="Field_Title" xml:space="preserve">
<value>标题</value>
</data>
<data name="Field_Description" xml:space="preserve">
<value>描述</value>
</data>
<data name="Field_Content" xml:space="preserve">
<value>内容</value>
</data>
<data name="Field_Tags" xml:space="preserve">
<value>标签</value>
</data>
<data name="Field_Name" xml:space="preserve">
<value>名称</value>
</data>
<data name="Field_Color" xml:space="preserve">
<value>#RRGGBB</value>
</data>
<data name="ColumnEditor_TitleNew" xml:space="preserve">
<value>新建列</value>
</data>
<data name="ColumnEditor_TitleEdit" xml:space="preserve">
<value>编辑列</value>
</data>
<data name="ColumnWidth_Title" xml:space="preserve">
<value>列宽</value>
</data>
<data name="ColumnWidth_Narrow" xml:space="preserve">
<value>窄({0} px)</value>
</data>
<data name="ColumnWidth_Standard" xml:space="preserve">
<value>标准({0} px)</value>
</data>
<data name="ColumnWidth_Wide" xml:space="preserve">
<value>宽({0} px)</value>
</data>
<data name="ColumnWidth_Custom" xml:space="preserve">
<value>自定义</value>
</data>
<data name="ColumnWidth_Range" xml:space="preserve">
<value>{0}–{1} px</value>
</data>
<data name="CardEditor_TitleNew" xml:space="preserve">
<value>新建卡片</value>
</data>
<data name="CardEditor_TitleEdit" xml:space="preserve">
<value>编辑卡片</value>
</data>
<data name="CardEditor_TitleWatermark" xml:space="preserve">
<value>无标题</value>
</data>
<data name="CardEditor_Created" xml:space="preserve">
<value>创建于 {0}</value>
</data>
<data name="CardEditor_Modified" xml:space="preserve">
<value>修改于 {0}</value>
</data>
<data name="CardEditor_AddTag" xml:space="preserve">
<value>添加标签</value>
</data>
<data name="CardEditor_RemoveTag" xml:space="preserve">
<value>移除标签</value>
</data>
<data name="TagPicker_Title" xml:space="preserve">
<value>添加标签</value>
</data>
<data name="TagPicker_FilterWatermark" xml:space="preserve">
<value>筛选标签</value>
</data>
<data name="TagPicker_NoMatches" xml:space="preserve">
<value>没有匹配的标签</value>
</data>
<data name="TagPicker_NewTag" xml:space="preserve">
<value>新建标签</value>
</data>
<data name="TagPicker_Create" xml:space="preserve">
<value>新建并添加</value>
</data>
<data name="Confirm_Title" xml:space="preserve">
<value>确认</value>
</data>
<data name="Confirm_DeleteCard" xml:space="preserve">
<value>删除卡片“{0}”?</value>
</data>
<data name="Confirm_DeleteColumn" xml:space="preserve">
<value>删除列“{0}”及其中的 {1} 张卡片?</value>
</data>
<data name="Confirm_DiscardEdit" xml:space="preserve">
<value>放弃未保存的更改?</value>
</data>
<data name="Error_Column_TitleRequired" xml:space="preserve">
<value>列标题不能为空。</value>
</data>
<data name="Error_Column_Duplicate" xml:space="preserve">
<value>已存在名为“{0}”的列(名称不区分大小写)。</value>
</data>
<data name="Error_Tag_NameRequired" xml:space="preserve">
<value>标签名称不能为空。</value>
</data>
<data name="Error_Tag_Duplicate" xml:space="preserve">
<value>已存在名为“{0}”的标签(名称不区分大小写)。</value>
</data>
<data name="Error_Tag_InvalidColor" xml:space="preserve">
<value>颜色须为 #RRGGBB 格式。</value>
</data>
<data name="Error_SaveFailed" xml:space="preserve">
<value>保存失败:{0}</value>
</data>
<data name="Error_Operation_Title" xml:space="preserve">
<value>操作失败</value>
</data>
<data name="Error_Operation_Message" xml:space="preserve">
<value>更改未能写入工作区,看板显示的是已保存的状态。</value>
</data>
</root>
+66
View File
@@ -136,3 +136,69 @@ public sealed class SchemaVersionException : YKanBanException
SupportedVersion = supportedVersion;
}
}
/// <summary>
/// The entities whose name/title is constrained by the schema to be non-empty
/// and table-unique.
/// </summary>
public enum NamedEntity
{
/// <summary>A board column (constraint on columns.title).</summary>
Column,
/// <summary>A tag (constraint on tags.name).</summary>
Tag,
}
/// <summary>
/// Signals that a column title or tag name was empty, violating the schema's
/// CHECK constraint. Nothing was written.
/// </summary>
public sealed class NameRequiredException : YKanBanException
{
/// <summary>
/// Gets the entity whose name was empty.
/// </summary>
public NamedEntity Entity { get; }
/// <summary>
/// Initializes the exception for an empty name.
/// </summary>
/// <param name="entity">The entity whose name was empty.</param>
/// <param name="innerException">The underlying constraint violation.</param>
public NameRequiredException(NamedEntity entity, Exception innerException)
: base($"The {entity.ToString().ToLowerInvariant()} name must not be empty.", innerException)
{
Entity = entity;
}
}
/// <summary>
/// Signals that a column title or tag name already exists (compared with ASCII
/// case folding), violating the schema's UNIQUE constraint. Nothing was written.
/// </summary>
public sealed class DuplicateNameException : YKanBanException
{
/// <summary>
/// Gets the entity whose name collided.
/// </summary>
public NamedEntity Entity { get; }
/// <summary>
/// Gets the rejected name.
/// </summary>
public string Name { get; }
/// <summary>
/// Initializes the exception for a name collision.
/// </summary>
/// <param name="entity">The entity whose name collided.</param>
/// <param name="name">The rejected name.</param>
/// <param name="innerException">The underlying constraint violation.</param>
public DuplicateNameException(NamedEntity entity, string name, Exception innerException)
: base($"A {entity.ToString().ToLowerInvariant()} named '{name}' already exists.", innerException)
{
Entity = entity;
Name = name;
}
}
@@ -1,5 +1,6 @@
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Search;
namespace YKanBan.Storage.Workspace;
@@ -71,14 +72,15 @@ public sealed class WorkspaceRepository : IDisposable
/// <param name="title">The non-empty, table-unique title.</param>
/// <param name="description">The optional description.</param>
/// <returns>The created column.</returns>
/// <exception cref="SqliteException">The title is empty or not unique.</exception>
/// <exception cref="NameRequiredException">The title is empty.</exception>
/// <exception cref="DuplicateNameException">The title is not unique.</exception>
public ColumnModel AddColumn(string title, string description)
{
long now = UnixTime.Now;
using var transaction = _connection.BeginTransaction();
long id = ExecuteInsert(transaction,
long id = MapNameConstraint(NamedEntity.Column, title, () => ExecuteInsert(transaction,
"INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);",
("$title", title), ("$description", description), ("$now", now));
("$title", title), ("$description", description), ("$now", now)));
transaction.Commit();
return new ColumnModel(id, title, description, now, now);
@@ -93,7 +95,8 @@ public sealed class WorkspaceRepository : IDisposable
/// <param name="description">The new description.</param>
/// <returns>The updated column, or the unchanged one for a no-op.</returns>
/// <exception cref="InvalidOperationException">The column does not exist.</exception>
/// <exception cref="SqliteException">The new title is empty or not unique.</exception>
/// <exception cref="NameRequiredException">The new title is empty.</exception>
/// <exception cref="DuplicateNameException">The new title is not unique.</exception>
public ColumnModel UpdateColumn(long columnId, string title, string description)
{
ColumnModel existing = GetColumn(columnId)
@@ -112,7 +115,7 @@ public sealed class WorkspaceRepository : IDisposable
command.Parameters.AddWithValue("$description", description);
command.Parameters.AddWithValue("$now", now);
command.Parameters.AddWithValue("$id", columnId);
command.ExecuteNonQuery();
MapNameConstraint(NamedEntity.Column, title, command.ExecuteNonQuery);
return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
}
@@ -132,16 +135,34 @@ public sealed class WorkspaceRepository : IDisposable
/// Returns all cards with their assigned tags loaded, ordered by id ascending.
/// </summary>
/// <returns>All cards in id order.</returns>
public IReadOnlyList<CardModel> GetCards()
public IReadOnlyList<CardModel> GetCards() => GetCards(null);
/// <summary>
/// Returns the cards matching a compiled search, with all of their assigned
/// tags loaded (the filter selects cards, it never trims their tag lists),
/// ordered by id ascending.
/// </summary>
/// <param name="filter">The compiled search, or <see langword="null"/> for every card.</param>
/// <returns>The matching cards in id order.</returns>
public IReadOnlyList<CardModel> GetCards(CompiledSearch? filter)
{
using var command = CreateCommand("""
string where = filter is null ? "" : $"WHERE {filter.Predicate}";
using var command = CreateCommand($"""
SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
t.id, t.name, t.color, t.description
FROM cards c
LEFT JOIN card_tags ct ON ct.card_id = c.id
LEFT JOIN tags t ON t.id = ct.tag_id
ORDER BY c.id ASC;
{where}
ORDER BY c.id ASC, t.id ASC;
""");
if (filter is not null)
{
foreach ((string name, object value) in filter.Parameters)
{
command.Parameters.AddWithValue(name, value);
}
}
using var reader = command.ExecuteReader();
// Single grouped pass: one output card per distinct card id, with its tags accumulated alongside.
@@ -186,7 +207,7 @@ public sealed class WorkspaceRepository : IDisposable
LEFT JOIN card_tags ct ON ct.card_id = c.id
LEFT JOIN tags t ON t.id = ct.tag_id
WHERE c.id = $id
ORDER BY c.id ASC;
ORDER BY t.id ASC;
""");
command.Parameters.AddWithValue("$id", cardId);
using var reader = command.ExecuteReader();
@@ -302,6 +323,18 @@ public sealed class WorkspaceRepository : IDisposable
return command.ExecuteNonQuery() > 0;
}
/// <summary>
/// Counts the cards of a column, regardless of any search filter.
/// </summary>
/// <param name="columnId">The column id.</param>
/// <returns>The number of cards in the column.</returns>
public long CountCards(long columnId)
{
using var command = CreateCommand("SELECT COUNT(*) FROM cards WHERE column_id = $id;");
command.Parameters.AddWithValue("$id", columnId);
return (long)command.ExecuteScalar()!;
}
/// <summary>
/// Deletes a card; the DDL cascade removes its tag links.
/// </summary>
@@ -361,13 +394,14 @@ public sealed class WorkspaceRepository : IDisposable
/// <param name="color">The tag color.</param>
/// <param name="description">The optional description.</param>
/// <returns>The created tag.</returns>
/// <exception cref="SqliteException">The name is empty or not unique.</exception>
/// <exception cref="NameRequiredException">The name is empty.</exception>
/// <exception cref="DuplicateNameException">The name is not unique.</exception>
public TagModel AddTag(string name, RgbColor color, string description)
{
using var transaction = _connection.BeginTransaction();
long id = ExecuteInsert(transaction,
long id = MapNameConstraint(NamedEntity.Tag, name, () => ExecuteInsert(transaction,
"INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
("$name", name), ("$color", color.ToString()), ("$description", description));
("$name", name), ("$color", color.ToString()), ("$description", description)));
transaction.Commit();
return new TagModel(id, name, color, description);
@@ -382,7 +416,8 @@ public sealed class WorkspaceRepository : IDisposable
/// <param name="description">The new description.</param>
/// <returns>The updated tag, or the unchanged one for a no-op.</returns>
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
/// <exception cref="SqliteException">The new name is empty or not unique.</exception>
/// <exception cref="NameRequiredException">The new name is empty.</exception>
/// <exception cref="DuplicateNameException">The new name is not unique.</exception>
public TagModel UpdateTag(long tagId, string name, RgbColor color, string description)
{
TagModel existing = GetTag(tagId)
@@ -400,7 +435,7 @@ public sealed class WorkspaceRepository : IDisposable
command.Parameters.AddWithValue("$color", color.ToString());
command.Parameters.AddWithValue("$description", description);
command.Parameters.AddWithValue("$id", tagId);
command.ExecuteNonQuery();
MapNameConstraint(NamedEntity.Tag, name, command.ExecuteNonQuery);
return new TagModel(tagId, name, color, description);
}
@@ -416,6 +451,40 @@ public sealed class WorkspaceRepository : IDisposable
#region Helpers
/// <summary>SQLite extended result code of a failed CHECK constraint.</summary>
private const int SqliteConstraintCheck = 275;
/// <summary>SQLite extended result code of a failed UNIQUE constraint.</summary>
private const int SqliteConstraintUnique = 2067;
/// <summary>
/// Runs a write against columns.title or tags.name and translates the
/// schema's non-empty (CHECK) and uniqueness (UNIQUE) violations into typed
/// exceptions that the UI maps to localized messages.
/// </summary>
/// <typeparam name="T">The write's result type.</typeparam>
/// <param name="entity">The entity being written.</param>
/// <param name="name">The name or title being written.</param>
/// <param name="write">The write to run.</param>
/// <returns>The write's result.</returns>
/// <exception cref="NameRequiredException">The name is empty.</exception>
/// <exception cref="DuplicateNameException">The name is not unique.</exception>
private static T MapNameConstraint<T>(NamedEntity entity, string name, Func<T> write)
{
try
{
return write();
}
catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintCheck)
{
throw new NameRequiredException(entity, exception);
}
catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintUnique)
{
throw new DuplicateNameException(entity, name, exception);
}
}
/// <summary>
/// Returns the tag with the given id.
/// </summary>
+270
View File
@@ -0,0 +1,270 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Search;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// The board tab: the toolbar (new column, column width, card sort) and the
/// columns laid out left to right, each listing its cards top to bottom.
///
/// Every write is followed by <see cref="Refresh"/>, which re-queries with the
/// most recently successful search expression (<see cref="ActiveFilter"/>), not
/// with whatever the search box currently holds: cards that no longer match
/// disappear, new cards that do not match are not shown, and empty columns stay.
/// </summary>
public sealed partial class BoardViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly AppConfig _config;
private readonly IDialogService _dialogs;
/// <summary>
/// Initializes the board and loads it.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="config">The live application configuration (sort, column width, confirmations).</param>
/// <param name="dialogs">The modal dialog service.</param>
public BoardViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs)
{
_repository = repository;
_config = config;
_dialogs = dialogs;
SortOptions =
[
new OptionItem(CardSortOption.Id, Resources.Sort_Id),
new OptionItem(CardSortOption.UpdatedAt, Resources.Sort_UpdatedAt),
new OptionItem(CardSortOption.Title, Resources.Sort_Title),
];
selectedSort = SortOptions.FirstOrDefault(option => (CardSortOption)option.Value == config.Sort.Card)
?? SortOptions[0];
columnWidth = ColumnWidths.Resolve(config.ColumnWidth);
Refresh();
}
/// <summary>
/// Gets the columns in id order.
/// </summary>
public ObservableCollection<ColumnViewModel> Columns { get; } = [];
/// <summary>
/// Gets the card sort options shown in the combobox.
/// </summary>
public IReadOnlyList<OptionItem> SortOptions { get; }
/// <summary>
/// Gets the column models in id order, as of the last refresh.
/// </summary>
public IReadOnlyList<ColumnModel> ColumnModels { get; private set; } = [];
/// <summary>
/// Gets or sets the most recently successful search; <see langword="null"/> shows every card.
/// </summary>
public CompiledSearch? ActiveFilter { get; set; }
/// <summary>
/// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board.
/// </summary>
[ObservableProperty]
private OptionItem selectedSort;
/// <summary>
/// Gets the effective column width in pixels.
/// </summary>
[ObservableProperty]
private int columnWidth;
/// <summary>
/// Reloads the columns and the cards matching <see cref="ActiveFilter"/>.
/// </summary>
public void Refresh()
{
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
ILookup<long, CardModel> cardsByColumn = _repository.GetCards(ActiveFilter).ToLookup(card => card.ColumnId);
var sort = (CardSortOption)SelectedSort.Value;
ColumnModels = columns;
Columns.Clear();
foreach (ColumnModel column in columns)
{
Columns.Add(new ColumnViewModel(this, column, CardOrdering.Sort(cardsByColumn[column.Id], sort)));
}
}
/// <summary>
/// Opens the new-column dialog.
/// </summary>
/// <returns>A task completing when the dialog closes.</returns>
[RelayCommand]
private async Task AddColumnAsync()
{
var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description));
if (await _dialogs.ShowAsync(dialog))
{
Refresh();
}
}
/// <summary>
/// Opens the column width dialog and applies the choice.
/// </summary>
/// <returns>A task completing when the dialog closes.</returns>
[RelayCommand]
private async Task AdjustColumnWidthAsync()
{
if (await _dialogs.ShowAsync(new ColumnWidthDialogViewModel(_config.ColumnWidth)))
{
ColumnWidth = ColumnWidths.Resolve(_config.ColumnWidth);
}
}
/// <summary>
/// Opens the edit-column dialog.
/// </summary>
/// <param name="column">The column to edit.</param>
/// <returns>A task completing when the dialog closes.</returns>
internal async Task EditColumnAsync(ColumnModel column)
{
var dialog = new ColumnEditorDialogViewModel(
column, (title, description) => _repository.UpdateColumn(column.Id, title, description));
if (await _dialogs.ShowAsync(dialog))
{
Refresh();
}
}
/// <summary>
/// Deletes a column with its cards, asking first when that confirmation is enabled.
/// </summary>
/// <param name="column">The column to delete.</param>
/// <returns>A task completing when the column is deleted or the deletion was declined.</returns>
internal async Task DeleteColumnAsync(ColumnModel column)
{
await RunWriteAsync(async () =>
{
if (_config.Confirmations.DeleteColumn)
{
long count = _repository.CountCards(column.Id);
string message = string.Format(Resources.Culture, Resources.Confirm_DeleteColumn, column.Title, count);
if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete))
{
return false;
}
}
_repository.DeleteColumn(column.Id);
return true;
});
}
/// <summary>
/// Opens the card editor for a new card in a column.
/// </summary>
/// <param name="columnId">The column receiving the card.</param>
/// <returns>A task completing when the editor closes.</returns>
internal async Task AddCardAsync(long columnId)
{
var editor = CardEditorDialogViewModel.ForNewCard(
_repository, _dialogs, columnId, _config.Confirmations.DiscardEdit);
await _dialogs.ShowAsync(editor);
// Refresh even after a cancel: the nested picker may have created tags.
Refresh();
}
/// <summary>
/// Opens the card editor for an existing card, reloaded from the database.
/// </summary>
/// <param name="cardId">The card to edit.</param>
/// <returns>A task completing when the editor closes.</returns>
internal async Task EditCardAsync(long cardId)
{
CardModel? card = _repository.GetCard(cardId);
if (card is null)
{
Refresh();
return;
}
var editor = CardEditorDialogViewModel.ForExistingCard(
_repository, _dialogs, card, _config.Confirmations.DiscardEdit);
await _dialogs.ShowAsync(editor);
Refresh();
}
/// <summary>
/// Deletes a card, asking first when that confirmation is enabled.
/// </summary>
/// <param name="card">The card to delete.</param>
/// <returns>A task completing when the card is deleted or the deletion was declined.</returns>
internal async Task DeleteCardAsync(CardModel card)
{
await RunWriteAsync(async () =>
{
if (_config.Confirmations.DeleteCard)
{
string message = string.Format(Resources.Culture, Resources.Confirm_DeleteCard, CardViewModel.FormatHeader(card));
if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete))
{
return false;
}
}
_repository.DeleteCard(card.Id);
return true;
});
}
/// <summary>
/// Moves a card to another column.
/// </summary>
/// <param name="cardId">The card to move.</param>
/// <param name="columnId">The target column.</param>
/// <returns>A task completing when the card is moved.</returns>
internal Task MoveCardAsync(long cardId, long columnId) =>
RunWriteAsync(() =>
{
_repository.MoveCard(cardId, columnId);
return Task.FromResult(true);
});
/// <summary>
/// Runs an immediate write and refreshes after it; an unexpected failure is
/// reported in a message dialog and the board is refreshed to the stored state.
/// </summary>
/// <param name="write">The write; returns <see langword="false"/> when the user declined it.</param>
/// <returns>A task completing after the refresh.</returns>
private async Task RunWriteAsync(Func<Task<bool>> write)
{
try
{
if (!await write())
{
return;
}
}
catch (Exception exception)
{
Debug.WriteLine(exception);
await _dialogs.ShowMessageAsync(
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
}
Refresh();
}
/// <summary>
/// Persists the sort option and re-sorts the board.
/// </summary>
/// <param name="value">The new sort option.</param>
partial void OnSelectedSortChanged(OptionItem value)
{
_config.Sort.Card = (CardSortOption)value.Value;
Refresh();
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.Globalization;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// One card in a column list: "#id" in bold on the same line as the title, the
/// content cut to a fixed length, and the tag badges below. No timestamps.
/// Clicking the card opens the editor; its menu deletes or moves it.
/// </summary>
public sealed partial class CardViewModel : ViewModelBase
{
/// <summary>
/// The number of text elements of content shown on the card.
/// </summary>
public const int ExcerptLength = 200;
private readonly BoardViewModel _board;
/// <summary>
/// Initializes the card.
/// </summary>
/// <param name="board">The owning board.</param>
/// <param name="card">The card shown.</param>
public CardViewModel(BoardViewModel board, CardModel card)
{
_board = board;
Card = card;
IdText = $"#{card.Id.ToString(CultureInfo.InvariantCulture)}";
Excerpt = MakeExcerpt(card.Content);
Tags = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList();
}
/// <summary>
/// Gets the card shown.
/// </summary>
public CardModel Card { get; }
/// <summary>
/// Gets the "#id" text.
/// </summary>
public string IdText { get; }
/// <summary>
/// Gets the title; empty when the card has none.
/// </summary>
public string Title => Card.Title;
/// <summary>
/// Gets the title as it follows "#id" on the header line: a space and the
/// title, or nothing when the card has no title.
/// </summary>
public string TitleSuffix => Card.Title.Length == 0 ? "" : " " + Card.Title;
/// <summary>
/// Gets the truncated content.
/// </summary>
public string Excerpt { get; }
/// <summary>
/// Gets whether there is content to show.
/// </summary>
public bool HasExcerpt => Excerpt.Length > 0;
/// <summary>
/// Gets the tag badges.
/// </summary>
public IReadOnlyList<TagBadgeViewModel> Tags { get; }
/// <summary>
/// Gets whether the card has tags.
/// </summary>
public bool HasTags => Tags.Count > 0;
/// <summary>
/// Gets the "move to column" targets; built when the menu asks for them.
/// The card's current column is listed but disabled.
/// </summary>
public IReadOnlyList<MoveTargetViewModel> MoveTargets =>
_board.ColumnModels
.Select(column => new MoveTargetViewModel(column.Title, column.Id != Card.ColumnId, MoveToCommand, column.Id))
.ToList();
/// <summary>
/// Formats the "#id title" header text of a card ("#id" alone without a title).
/// </summary>
/// <param name="card">The card.</param>
/// <returns>The header text.</returns>
public static string FormatHeader(CardModel card) =>
card.Title.Length == 0
? $"#{card.Id.ToString(CultureInfo.InvariantCulture)}"
: $"#{card.Id.ToString(CultureInfo.InvariantCulture)} {card.Title}";
/// <summary>
/// Cuts content to <see cref="ExcerptLength"/> text elements, appending an
/// ellipsis when something was cut. Surrogate pairs and combining sequences
/// are never split.
/// </summary>
/// <param name="content">The card content.</param>
/// <returns>The excerpt.</returns>
public static string MakeExcerpt(string content)
{
string trimmed = content.Trim();
var info = new StringInfo(trimmed);
return info.LengthInTextElements <= ExcerptLength
? trimmed
: info.SubstringByTextElements(0, ExcerptLength).TrimEnd() + "…";
}
/// <summary>
/// Opens the card editor.
/// </summary>
/// <returns>A task completing when the editor closes.</returns>
[RelayCommand]
private Task EditAsync() => _board.EditCardAsync(Card.Id);
/// <summary>
/// Deletes the card.
/// </summary>
/// <returns>A task completing when the card is deleted or the deletion was declined.</returns>
[RelayCommand]
private Task DeleteAsync() => _board.DeleteCardAsync(Card);
/// <summary>
/// Moves the card to another column.
/// </summary>
/// <param name="columnId">The target column.</param>
/// <returns>A task completing when the card is moved.</returns>
[RelayCommand]
private Task MoveToAsync(long columnId) => _board.MoveCardAsync(Card.Id, columnId);
}
/// <summary>
/// An entry of a card's "move to column" submenu.
/// </summary>
/// <param name="Title">The column title.</param>
/// <param name="IsEnabled">Whether the entry can be picked (false for the card's own column).</param>
/// <param name="Command">The move command.</param>
/// <param name="ColumnId">The target column, passed to <paramref name="Command"/>.</param>
public sealed record MoveTargetViewModel(string Title, bool IsEnabled, IAsyncRelayCommand<long> Command, long ColumnId);
@@ -0,0 +1,77 @@
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// One board column: its header (title, card count, description, the new-card
/// button and the edit/delete menu) and its virtualized card list.
/// </summary>
public sealed partial class ColumnViewModel : ViewModelBase
{
private readonly BoardViewModel _board;
/// <summary>
/// Initializes the column.
/// </summary>
/// <param name="board">The owning board.</param>
/// <param name="column">The column shown.</param>
/// <param name="cards">The column's visible cards, in display order.</param>
public ColumnViewModel(BoardViewModel board, ColumnModel column, IReadOnlyList<CardModel> cards)
{
_board = board;
Column = column;
Cards = cards.Select(card => new CardViewModel(board, card)).ToList();
}
/// <summary>
/// Gets the column shown.
/// </summary>
public ColumnModel Column { get; }
/// <summary>
/// Gets the column title.
/// </summary>
public string Title => Column.Title;
/// <summary>
/// Gets the column description.
/// </summary>
public string Description => Column.Description;
/// <summary>
/// Gets whether the column has a description to show.
/// </summary>
public bool HasDescription => Column.Description.Length > 0;
/// <summary>
/// Gets the number of visible cards.
/// </summary>
public int CardCount => Cards.Count;
/// <summary>
/// Gets the visible cards in display order.
/// </summary>
public IReadOnlyList<CardViewModel> Cards { get; }
/// <summary>
/// Opens the card editor for a new card in this column.
/// </summary>
/// <returns>A task completing when the editor closes.</returns>
[RelayCommand]
private Task AddCardAsync() => _board.AddCardAsync(Column.Id);
/// <summary>
/// Opens the edit-column dialog.
/// </summary>
/// <returns>A task completing when the dialog closes.</returns>
[RelayCommand]
private Task EditAsync() => _board.EditColumnAsync(Column);
/// <summary>
/// Deletes the column with its cards.
/// </summary>
/// <returns>A task completing when the column is deleted or the deletion was declined.</returns>
[RelayCommand]
private Task DeleteAsync() => _board.DeleteColumnAsync(Column);
}
@@ -0,0 +1,57 @@
using Avalonia.Media;
using Avalonia.Media.Immutable;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// A colored tag badge (color + name). Tag colors are user data: they are shown
/// as the literal #RRGGBB value in both themes, with a black or white text color
/// chosen for contrast.
/// </summary>
public sealed class TagBadgeViewModel : ViewModelBase
{
/// <summary>
/// Initializes the badge.
/// </summary>
/// <param name="tag">The tag shown.</param>
public TagBadgeViewModel(TagModel tag)
{
Tag = tag;
Background = new ImmutableSolidColorBrush(Color.FromRgb(tag.Color.R, tag.Color.G, tag.Color.B));
Foreground = IsLight(tag.Color) ? Brushes.Black : Brushes.White;
}
/// <summary>
/// Gets the tag shown.
/// </summary>
public TagModel Tag { 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 the badge fill: the tag's own color.
/// </summary>
public IBrush Background { get; }
/// <summary>
/// Gets the text color contrasting with <see cref="Background"/>.
/// </summary>
public IBrush Foreground { get; }
/// <summary>
/// Decides whether dark text reads better on a color, by its relative luminance.
/// </summary>
/// <param name="color">The background color.</param>
/// <returns><see langword="true"/> for light colors.</returns>
public static bool IsLight(RgbColor color) =>
(0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B) > 150;
}
@@ -0,0 +1,228 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The card editor — one unit of work. Title, content and the tag assignment
/// are edited in memory and written together in a single transaction on
/// confirmation; cancelling writes nothing about the card or its assignment.
/// Confirming an existing card without effective changes writes nothing and
/// keeps its timestamps. The only immediate write is the creation of a new tag
/// in the nested tag picker (see <see cref="TagPickerDialogViewModel"/>).
/// </summary>
public sealed partial class CardEditorDialogViewModel : DialogViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly IDialogService _dialogs;
private readonly CardModel? _existing;
private readonly long _columnId;
private readonly bool _confirmDiscard;
private readonly string _initialTitle;
private readonly string _initialContent;
private readonly HashSet<long> _initialTagIds;
/// <summary>
/// Initializes the editor for a new card in a column.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="dialogs">The dialog service, for the nested tag picker and the discard confirmation.</param>
/// <param name="columnId">The column receiving the new card.</param>
/// <param name="confirmDiscard">Whether discarding changes asks for confirmation.</param>
/// <returns>The editor.</returns>
public static CardEditorDialogViewModel ForNewCard(
WorkspaceRepository repository, IDialogService dialogs, long columnId, bool confirmDiscard) =>
new(repository, dialogs, null, columnId, confirmDiscard);
/// <summary>
/// Initializes the editor for an existing card.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="dialogs">The dialog service, for the nested tag picker and the discard confirmation.</param>
/// <param name="card">The card to edit.</param>
/// <param name="confirmDiscard">Whether discarding changes asks for confirmation.</param>
/// <returns>The editor.</returns>
public static CardEditorDialogViewModel ForExistingCard(
WorkspaceRepository repository, IDialogService dialogs, CardModel card, bool confirmDiscard) =>
new(repository, dialogs, card, card.ColumnId, confirmDiscard);
private CardEditorDialogViewModel(
WorkspaceRepository repository, IDialogService dialogs, CardModel? existing, long columnId, bool confirmDiscard)
{
_repository = repository;
_dialogs = dialogs;
_existing = existing;
_columnId = columnId;
_confirmDiscard = confirmDiscard;
_initialTitle = existing?.Title ?? "";
_initialContent = existing?.Content ?? "";
_initialTagIds = existing?.Tags.Select(tag => tag.Id).ToHashSet() ?? [];
cardTitle = _initialTitle;
content = _initialContent;
Tags = new ObservableCollection<TagBadgeViewModel>(
existing?.Tags.Select(tag => new TagBadgeViewModel(tag)) ?? []);
if (existing is null)
{
Title = Resources.CardEditor_TitleNew;
}
else
{
CultureInfo? culture = Resources.Culture;
Title = Resources.CardEditor_TitleEdit;
IdText = $"#{existing.Id.ToString(CultureInfo.InvariantCulture)}";
CreatedText = string.Format(culture, Resources.CardEditor_Created, FormatTime(existing.CreatedAt));
ModifiedText = string.Format(culture, Resources.CardEditor_Modified, FormatTime(existing.UpdatedAt));
}
}
/// <summary>
/// Gets the localized window title.
/// </summary>
public string Title { get; }
/// <summary>
/// Gets whether the card already exists (and thus has an id and timestamps to show).
/// </summary>
public bool IsExisting => _existing is not null;
/// <summary>
/// Gets the read-only "#id" text of an existing card.
/// </summary>
public string? IdText { get; }
/// <summary>
/// Gets the read-only creation time line of an existing card.
/// </summary>
public string? CreatedText { get; }
/// <summary>
/// Gets the read-only modification time line of an existing card.
/// </summary>
public string? ModifiedText { get; }
/// <summary>
/// Gets the tags assigned in this edit session, in assignment order.
/// </summary>
public ObservableCollection<TagBadgeViewModel> Tags { get; }
/// <summary>
/// Gets the card written on confirmation; <see langword="null"/> until then.
/// </summary>
public CardModel? SavedCard { get; private set; }
/// <summary>
/// Gets or sets the card title; the empty string means "no title".
/// </summary>
[ObservableProperty]
private string cardTitle;
/// <summary>
/// Gets or sets the multiline card content.
/// </summary>
[ObservableProperty]
private string content;
/// <summary>
/// Gets the localized error of the last confirmation, or <see langword="null"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
private string? errorMessage;
/// <summary>
/// Gets whether an error is shown.
/// </summary>
public bool HasError => ErrorMessage is not null;
/// <summary>
/// Gets whether the in-memory state differs from the state the editor opened with.
/// </summary>
public bool IsDirty =>
CardTitle != _initialTitle
|| Content != _initialContent
|| !_initialTagIds.SetEquals(Tags.Select(tag => tag.Tag.Id));
/// <summary>
/// Opens the nested tag picker and assigns the picked (or newly created) tag.
/// </summary>
/// <returns>A task completing when the picker closes.</returns>
[RelayCommand]
private async Task AddTagAsync()
{
var picker = new TagPickerDialogViewModel(_repository, Tags.Select(tag => tag.Tag.Id).ToHashSet());
if (await _dialogs.ShowAsync(picker) && picker.PickedTag is { } picked)
{
Tags.Add(new TagBadgeViewModel(picked));
}
}
/// <summary>
/// Removes a tag from the in-memory assignment.
/// </summary>
/// <param name="tag">The tag to remove.</param>
[RelayCommand]
private void RemoveTag(TagBadgeViewModel tag) => Tags.Remove(tag);
/// <summary>
/// Writes the card in one transaction and closes; an unchanged existing card
/// is not written at all.
/// </summary>
[RelayCommand]
private void Confirm()
{
if (_existing is not null && !IsDirty)
{
SavedCard = _existing;
Close(true);
return;
}
long[] tagIds = Tags.Select(tag => tag.Tag.Id).ToArray();
try
{
SavedCard = _existing is null
? _repository.AddCard(_columnId, CardTitle, Content, tagIds)
: _repository.UpdateCard(_existing.Id, CardTitle, Content, tagIds);
}
catch (Exception exception)
{
ErrorMessage = WriteErrorMessages.Describe(exception);
return;
}
Close(true);
}
/// <summary>
/// Closes without writing the card, asking first when there are unsaved
/// changes and the discard confirmation is enabled.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
/// <inheritdoc/>
public override async Task RequestCancelAsync()
{
if (_confirmDiscard && IsDirty
&& !await _dialogs.ConfirmAsync(Resources.Confirm_DiscardEdit, Resources.Common_Discard))
{
return;
}
Close(false);
}
/// <summary>
/// Formats a Unix-seconds timestamp in local time with the language's pattern.
/// </summary>
/// <param name="unixSeconds">The timestamp.</param>
/// <returns>The formatted time.</returns>
private static string FormatTime(long unixSeconds) =>
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime()
.ToString(Resources.Common_TimePattern, Resources.Culture);
}
@@ -0,0 +1,82 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The new-column / edit-column dialog: a required title and an optional
/// description. Confirming writes through the supplied save callback; a
/// schema-constraint violation (empty or duplicate title) is shown inside the
/// dialog, which stays open with the user's input intact.
/// </summary>
public sealed partial class ColumnEditorDialogViewModel : DialogViewModelBase
{
private readonly Action<string, string> _save;
/// <summary>
/// Initializes the dialog.
/// </summary>
/// <param name="existing">The column being edited, or <see langword="null"/> for a new column.</param>
/// <param name="save">Writes the title and description; throws the repository's typed exceptions.</param>
public ColumnEditorDialogViewModel(ColumnModel? existing, Action<string, string> save)
{
_save = save;
Title = existing is null ? Resources.ColumnEditor_TitleNew : Resources.ColumnEditor_TitleEdit;
columnTitle = existing?.Title ?? "";
description = existing?.Description ?? "";
}
/// <summary>
/// Gets the localized window title.
/// </summary>
public string Title { get; }
/// <summary>
/// Gets or sets the column title being edited (not trimmed).
/// </summary>
[ObservableProperty]
private string columnTitle;
/// <summary>
/// Gets or sets the column description being edited.
/// </summary>
[ObservableProperty]
private string description;
/// <summary>
/// Gets the localized constraint error of the last confirmation, or <see langword="null"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
private string? errorMessage;
/// <summary>
/// Gets whether an error is shown.
/// </summary>
public bool HasError => ErrorMessage is not null;
/// <summary>
/// Writes the column; closes on success, otherwise shows the constraint error.
/// </summary>
[RelayCommand]
private void Confirm()
{
try
{
_save(ColumnTitle, Description);
}
catch (Exception exception)
{
ErrorMessage = WriteErrorMessages.Describe(exception);
return;
}
Close(true);
}
/// <summary>
/// Closes without writing.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
}
@@ -0,0 +1,129 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Storage.AppData;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The column width dialog: one of the narrow / standard / wide presets or a
/// manual pixel width (160–720). The choice is global and is committed to the
/// live configuration on confirmation only.
/// </summary>
public sealed partial class ColumnWidthDialogViewModel : DialogViewModelBase
{
private readonly ColumnWidthSettings _settings;
/// <summary>
/// Initializes the dialog from the live settings.
/// </summary>
/// <param name="settings">The live column width settings.</param>
public ColumnWidthDialogViewModel(ColumnWidthSettings settings)
{
_settings = settings;
CultureInfo? culture = Resources.Culture;
NarrowLabel = string.Format(culture, Resources.ColumnWidth_Narrow, ColumnWidths.Narrow);
StandardLabel = string.Format(culture, Resources.ColumnWidth_Standard, ColumnWidths.Standard);
WideLabel = string.Format(culture, Resources.ColumnWidth_Wide, ColumnWidths.Wide);
RangeLabel = string.Format(culture, Resources.ColumnWidth_Range, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels);
isNarrow = settings.Preset == ColumnWidthPreset.Narrow;
isStandard = settings.Preset == ColumnWidthPreset.Standard;
isWide = settings.Preset == ColumnWidthPreset.Wide;
isCustom = settings.Preset == ColumnWidthPreset.Custom;
customPixels = settings.CustomPixels;
}
/// <summary>Gets the localized narrow preset label.</summary>
public string NarrowLabel { get; }
/// <summary>Gets the localized standard preset label.</summary>
public string StandardLabel { get; }
/// <summary>Gets the localized wide preset label.</summary>
public string WideLabel { get; }
/// <summary>Gets the localized accepted pixel range.</summary>
public string RangeLabel { get; }
/// <summary>Gets the smallest accepted custom width.</summary>
public int MinPixels => AppConfig.MinColumnPixels;
/// <summary>Gets the largest accepted custom width.</summary>
public int MaxPixels => AppConfig.MaxColumnPixels;
/// <summary>Gets or sets whether the narrow preset is selected.</summary>
[ObservableProperty]
private bool isNarrow;
/// <summary>Gets or sets whether the standard preset is selected.</summary>
[ObservableProperty]
private bool isStandard;
/// <summary>Gets or sets whether the wide preset is selected.</summary>
[ObservableProperty]
private bool isWide;
/// <summary>Gets or sets whether the manual width is selected.</summary>
[ObservableProperty]
private bool isCustom;
/// <summary>Gets or sets the manual width in pixels; <see langword="null"/> while the box is empty.</summary>
[ObservableProperty]
private decimal? customPixels;
/// <summary>
/// Commits the selection to the live settings and closes.
/// </summary>
[RelayCommand]
private void Confirm()
{
if (IsCustom)
{
int pixels = (int)Math.Clamp(CustomPixels ?? _settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels);
_settings.Preset = ColumnWidthPreset.Custom;
_settings.CustomPixels = pixels;
}
else
{
_settings.Preset = IsNarrow ? ColumnWidthPreset.Narrow
: IsWide ? ColumnWidthPreset.Wide
: ColumnWidthPreset.Standard;
}
Close(true);
}
/// <summary>
/// Closes without changing anything.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
}
/// <summary>
/// Pixel widths of the column width presets.
/// </summary>
public static class ColumnWidths
{
/// <summary>The narrow preset width.</summary>
public const int Narrow = 240;
/// <summary>The standard (default) preset width.</summary>
public const int Standard = 320;
/// <summary>The wide preset width.</summary>
public const int Wide = 480;
/// <summary>
/// Resolves the effective column width of the settings.
/// </summary>
/// <param name="settings">The column width settings.</param>
/// <returns>The width in pixels.</returns>
public static int Resolve(ColumnWidthSettings settings) => settings.Preset switch
{
ColumnWidthPreset.Narrow => Narrow,
ColumnWidthPreset.Wide => Wide,
ColumnWidthPreset.Custom => Math.Clamp(settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels),
_ => Standard,
};
}
@@ -0,0 +1,43 @@
using CommunityToolkit.Mvvm.Input;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// A yes/no confirmation: a message, a confirming button with an action
/// specific label (for example "Delete") and a cancel button.
/// </summary>
public sealed partial class ConfirmDialogViewModel : DialogViewModelBase
{
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="message">The localized question.</param>
/// <param name="confirmLabel">The localized label of the confirming button.</param>
public ConfirmDialogViewModel(string message, string confirmLabel)
{
Message = message;
ConfirmLabel = confirmLabel;
}
/// <summary>
/// Gets the localized question.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets the localized label of the confirming button.
/// </summary>
public string ConfirmLabel { get; }
/// <summary>
/// Closes with a positive result.
/// </summary>
[RelayCommand]
private void Confirm() => Close(true);
/// <summary>
/// Closes with a negative result.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
}
@@ -0,0 +1,33 @@
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// Base class for modal dialog view models shown through <see cref="IDialogService"/>.
/// The dialog decides when it closes: it raises <see cref="CloseRequested"/>
/// with its result, and a close attempted by the window itself (title-bar close
/// button, Escape) is routed to <see cref="RequestCancelAsync"/> so that the
/// dialog can veto it, for example to ask before discarding edits.
/// </summary>
public abstract class DialogViewModelBase : ViewModelBase
{
/// <summary>
/// Raised when the dialog wants to close; the argument is the dialog result.
/// </summary>
public event EventHandler<bool>? CloseRequested;
/// <summary>
/// Handles a cancel request coming from the window (or a cancel button).
/// The default closes immediately with a negative result.
/// </summary>
/// <returns>A task completing once the request has been handled.</returns>
public virtual Task RequestCancelAsync()
{
Close(false);
return Task.CompletedTask;
}
/// <summary>
/// Asks the hosting window to close with the given result.
/// </summary>
/// <param name="result">The dialog result.</param>
protected void Close(bool result) => CloseRequested?.Invoke(this, result);
}
@@ -0,0 +1,43 @@
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// Opens modal dialogs on behalf of view models, which never reference views.
/// Every dialog is owned by the top-most open window, so a dialog opened from
/// inside another dialog (nested modal) is centered on and blocks that dialog.
/// </summary>
public interface IDialogService
{
/// <summary>
/// Shows a dialog modally and waits until it closes.
/// </summary>
/// <param name="dialog">The dialog view model; its window is resolved by naming convention.</param>
/// <returns>The dialog result: <see langword="true"/> when it was accepted.</returns>
Task<bool> ShowAsync(DialogViewModelBase dialog);
}
/// <summary>
/// Convenience wrappers for the plain confirmation and message dialogs.
/// </summary>
public static class DialogServiceExtensions
{
/// <summary>
/// Asks a yes/no question.
/// </summary>
/// <param name="dialogs">The dialog service.</param>
/// <param name="message">The localized question.</param>
/// <param name="confirmLabel">The localized label of the confirming button.</param>
/// <returns><see langword="true"/> when the user confirmed.</returns>
public static Task<bool> ConfirmAsync(this IDialogService dialogs, string message, string confirmLabel) =>
dialogs.ShowAsync(new ConfirmDialogViewModel(message, confirmLabel));
/// <summary>
/// Shows an informational message with an optional read-only details block.
/// </summary>
/// <param name="dialogs">The dialog service.</param>
/// <param name="title">The localized window title.</param>
/// <param name="message">The localized message.</param>
/// <param name="details">Untranslated diagnostic details, or <see langword="null"/>.</param>
/// <returns>A task completing when the dialog closes.</returns>
public static Task ShowMessageAsync(this IDialogService dialogs, string title, string message, string? details = null) =>
dialogs.ShowAsync(new MessageDialogViewModel(title, message, details));
}
@@ -0,0 +1,49 @@
using CommunityToolkit.Mvvm.Input;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// A purely informational dialog with a single OK button: a localized message
/// plus optional untranslated, copyable diagnostic details.
/// </summary>
public sealed partial class MessageDialogViewModel : DialogViewModelBase
{
/// <summary>
/// Initializes the message.
/// </summary>
/// <param name="title">The localized window title.</param>
/// <param name="message">The localized message.</param>
/// <param name="details">Untranslated diagnostic details, or <see langword="null"/>.</param>
public MessageDialogViewModel(string title, string message, string? details)
{
Title = title;
Message = message;
Details = details;
}
/// <summary>
/// Gets the localized window title.
/// </summary>
public string Title { get; }
/// <summary>
/// Gets the localized message.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets the diagnostic details, or <see langword="null"/> when there are none.
/// </summary>
public string? Details { get; }
/// <summary>
/// Gets whether a details block is shown.
/// </summary>
public bool HasDetails => !string.IsNullOrEmpty(Details);
/// <summary>
/// Closes the dialog.
/// </summary>
[RelayCommand]
private void Ok() => Close(true);
}
@@ -0,0 +1,185 @@
using System.Collections.ObjectModel;
using Avalonia.Media;
using Avalonia.Media.Immutable;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The nested "add tag" modal of the card editor: a filterable list of the
/// existing tags not yet on the card, plus a new-tag entry. Picking a tag closes
/// the dialog with it as <see cref="PickedTag"/>.
///
/// Exception to the edit-dialog unit of work: a tag created here is written to
/// the tags table immediately (tags are global entities), so it survives even
/// when the outer card edit is cancelled — only the assignment is discarded.
/// </summary>
public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
{
/// <summary>
/// Default colors proposed for new tags, cycled by the number of existing tags.
/// </summary>
private static readonly RgbColor[] Palette =
[
new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04),
new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7),
];
private readonly WorkspaceRepository _repository;
private readonly IReadOnlyList<TagBadgeViewModel> _available;
/// <summary>
/// Initializes the picker.
/// </summary>
/// <param name="repository">The workspace repository; new tags are written through it immediately.</param>
/// <param name="assignedTagIds">The tags already on the card, which are not offered again.</param>
public TagPickerDialogViewModel(WorkspaceRepository repository, IReadOnlyCollection<long> assignedTagIds)
{
_repository = repository;
IReadOnlyList<TagModel> all = repository.GetTags();
_available = all
.Where(tag => !assignedTagIds.Contains(tag.Id))
.Select(tag => new TagBadgeViewModel(tag))
.ToList();
FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available);
newColor = Palette[all.Count % Palette.Length].ToString();
UpdateColorPreview();
}
/// <summary>
/// Gets the tag the user picked or created, once the dialog closed positively.
/// </summary>
public TagModel? PickedTag { get; private set; }
/// <summary>
/// Gets the available tags matching <see cref="FilterText"/>.
/// </summary>
public ObservableCollection<TagBadgeViewModel> FilteredTags { get; }
/// <summary>
/// Gets whether no available tag matches the filter.
/// </summary>
public bool NoMatches => FilteredTags.Count == 0;
/// <summary>
/// Gets or sets the list filter: a case-insensitive substring of the tag name.
/// </summary>
[ObservableProperty]
private string filterText = "";
/// <summary>
/// Gets or sets the name of the tag to create (not trimmed).
/// </summary>
[ObservableProperty]
private string newName = "";
/// <summary>
/// Gets or sets the #RRGGBB color text of the tag to create.
/// </summary>
[ObservableProperty]
private string newColor;
/// <summary>
/// Gets or sets the description of the tag to create.
/// </summary>
[ObservableProperty]
private string newDescription = "";
/// <summary>
/// Gets the preview of <see cref="NewColor"/>, transparent while it is not valid.
/// </summary>
[ObservableProperty]
private IBrush newColorPreview = Brushes.Transparent;
/// <summary>
/// Gets the localized error of the last create attempt, or <see langword="null"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
private string? errorMessage;
/// <summary>
/// Gets whether an error is shown.
/// </summary>
public bool HasError => ErrorMessage is not null;
/// <summary>
/// Picks an existing tag and closes.
/// </summary>
/// <param name="tag">The picked tag.</param>
[RelayCommand]
private void Pick(TagBadgeViewModel tag)
{
PickedTag = tag.Tag;
Close(true);
}
/// <summary>
/// Creates the new tag right away and closes with it picked; constraint
/// violations and an invalid color keep the dialog open with an error.
/// </summary>
[RelayCommand]
private void Create()
{
if (!RgbColor.TryParse(NewColor, out RgbColor color))
{
ErrorMessage = Resources.Error_Tag_InvalidColor;
return;
}
try
{
PickedTag = _repository.AddTag(NewName, color, NewDescription);
}
catch (Exception exception)
{
ErrorMessage = WriteErrorMessages.Describe(exception);
return;
}
Close(true);
}
/// <summary>
/// Closes without picking anything.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
/// <summary>
/// Re-filters the list.
/// </summary>
/// <param name="value">The new filter text.</param>
partial void OnFilterTextChanged(string value)
{
string folded = SqliteDatabase.Fold(value);
FilteredTags.Clear();
foreach (TagBadgeViewModel tag in _available)
{
if (SqliteDatabase.Fold(tag.Name).Contains(folded, StringComparison.Ordinal))
{
FilteredTags.Add(tag);
}
}
OnPropertyChanged(nameof(NoMatches));
}
/// <summary>
/// Refreshes the color preview.
/// </summary>
/// <param name="value">The new color text.</param>
partial void OnNewColorChanged(string value) => UpdateColorPreview();
/// <summary>
/// Recomputes <see cref="NewColorPreview"/> from <see cref="NewColor"/>.
/// </summary>
private void UpdateColorPreview() =>
NewColorPreview = RgbColor.TryParse(NewColor, out RgbColor color)
? new ImmutableSolidColorBrush(Color.FromRgb(color.R, color.G, color.B))
: Brushes.Transparent;
}
@@ -0,0 +1,33 @@
using System.Globalization;
using YKanBan.Storage;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// Maps a failed dialog write to the localized message shown inside the dialog
/// that attempted it (the dialog stays open and keeps the user's input). The
/// typed schema-constraint exceptions get dedicated messages; anything else
/// gets a generic one carrying the untranslated exception message.
/// </summary>
public static class WriteErrorMessages
{
/// <summary>
/// Returns the localized message for a failed write.
/// </summary>
/// <param name="exception">The exception thrown by the write.</param>
/// <returns>The localized message.</returns>
public static string Describe(Exception exception)
{
CultureInfo? culture = Resources.Culture;
return exception switch
{
NameRequiredException { Entity: NamedEntity.Column } => Resources.Error_Column_TitleRequired,
NameRequiredException { Entity: NamedEntity.Tag } => Resources.Error_Tag_NameRequired,
DuplicateNameException { Entity: NamedEntity.Column } duplicate =>
string.Format(culture, Resources.Error_Column_Duplicate, duplicate.Name),
DuplicateNameException { Entity: NamedEntity.Tag } duplicate =>
string.Format(culture, Resources.Error_Tag_Duplicate, duplicate.Name),
_ => string.Format(culture, Resources.Error_SaveFailed, exception.Message),
};
}
}
+6 -2
View File
@@ -4,6 +4,7 @@ using YKanBan.Launching;
using YKanBan.Services;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Dialogs;
using YKanBan.ViewModels.ErrorPages;
namespace YKanBan.ViewModels;
@@ -16,15 +17,18 @@ namespace YKanBan.ViewModels;
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppServices _services;
private readonly IDialogService _dialogs;
/// <summary>
/// Initializes the view model and resolves the workspace state.
/// </summary>
/// <param name="services">The process composition root.</param>
/// <param name="dialogs">The modal dialog service used by the board.</param>
/// <param name="arguments">The command-line arguments (excluding the executable).</param>
public MainWindowViewModel(AppServices services, IReadOnlyList<string> arguments)
public MainWindowViewModel(AppServices services, IDialogService dialogs, IReadOnlyList<string> arguments)
{
_services = services;
_dialogs = dialogs;
ShowSettings = new Interaction<Unit>();
ShowAbout = new Interaction<Unit>();
InitializeWorkspace = new Interaction<Unit>();
@@ -104,7 +108,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenWorkspace(string folderPath)
{
WorkspaceSession session = _services.OpenWorkspace(folderPath);
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout);
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout, _services.Config, _dialogs);
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
}
+14 -1
View File
@@ -1,5 +1,8 @@
using CommunityToolkit.Mvvm.Input;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.ViewModels;
@@ -19,16 +22,21 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// <param name="session">The open workspace session.</param>
/// <param name="showSettings">The interaction that opens the settings dialog.</param>
/// <param name="showAbout">The interaction that opens the about dialog.</param>
/// <param name="config">The live application configuration.</param>
/// <param name="dialogs">The modal dialog service of the board.</param>
public WorkspaceViewModel(
WorkspaceSession session,
Interaction<Unit> showSettings,
Interaction<Unit> showAbout)
Interaction<Unit> showAbout,
AppConfig config,
IDialogService dialogs)
{
Session = session;
WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
WorkspacePath = session.FolderPath;
_showSettings = showSettings;
_showAbout = showAbout;
Board = new BoardViewModel(session.Repository, config, dialogs);
}
/// <summary>
@@ -46,6 +54,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// </summary>
public string WorkspacePath { get; }
/// <summary>
/// Gets the board tab.
/// </summary>
public BoardViewModel Board { get; }
/// <summary>
/// Opens the settings dialog through the registered interaction.
/// </summary>
+176
View File
@@ -0,0 +1,176 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Board"
xmlns:vmBase="using:YKanBan.ViewModels"
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.Board.BoardView"
x:DataType="vm:BoardViewModel"
x:Name="Root">
<Grid RowDefinitions="Auto,*" RowSpacing="8">
<!-- Toolbar. The search bar joins on the left with the search feature. -->
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Command="{Binding AddColumnCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
</StackPanel>
</Button>
<Button Command="{Binding AdjustColumnWidthCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="MoveHorizontal" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/>
</StackPanel>
</Button>
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSort}" MinWidth="160"
ToolTip.Tip="{x:Static lang:Resources.Board_Sort}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Columns left to right; each column scrolls its own virtualized card list. -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Columns}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="12"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ColumnViewModel">
<Border Width="{Binding #Root.((vm:BoardViewModel)DataContext).ColumnWidth}"
Background="{DynamicResource ColumnBackgroundBrush}"
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="8">
<Grid RowDefinitions="Auto,*" RowSpacing="8">
<!-- Column header: title, count, description, new card, edit/delete menu. -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding CardCount}" Opacity="0.6"/>
</StackPanel>
<TextBlock Text="{Binding Description}" IsVisible="{Binding HasDescription}"
FontSize="12" Opacity="0.7" TextWrapping="Wrap" MaxLines="3"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="1" Command="{Binding AddCardCommand}" VerticalAlignment="Top"
Theme="{StaticResource BorderlessButton}" Padding="4"
ToolTip.Tip="{x:Static lang:Resources.Board_AddCard}">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
</Button>
<Button Grid.Column="2" VerticalAlignment="Top"
Theme="{StaticResource BorderlessButton}" Padding="4"
ToolTip.Tip="{x:Static lang:Resources.Menu_More}">
<icons:PackIconLucide Kind="Ellipsis" Width="16" Height="16"/>
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="{x:Static lang:Resources.Menu_Edit}" Command="{Binding EditCommand}"/>
<MenuItem Header="{x:Static lang:Resources.Menu_Delete}" Command="{Binding DeleteCommand}"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<!-- Cards top to bottom, UI-virtualized for large columns. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Cards}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}"/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CardViewModel">
<Panel Margin="0,0,0,8">
<!-- The card body opens the editor. -->
<Button Command="{Binding EditCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Top"
Background="{DynamicResource CardBackgroundBrush}"
Foreground="{DynamicResource CardForegroundBrush}"
FontWeight="Normal"
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Cursor="Hand">
<StackPanel Spacing="6">
<TextBlock TextWrapping="Wrap" Margin="0,0,24,0">
<Run Text="{Binding IdText}" FontWeight="Bold"/><Run Text="{Binding TitleSuffix}"/>
</TextBlock>
<TextBlock Text="{Binding Excerpt}" IsVisible="{Binding HasExcerpt}"
TextWrapping="Wrap" FontSize="12" Opacity="0.8"
FontWeight="Normal"/>
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagBadgeViewModel">
<Border Background="{Binding Background}" CornerRadius="10"
Padding="8,1" Margin="0,0,4,4">
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}"
FontSize="11" FontWeight="Normal"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Button>
<!-- Card menu: delete, move to column. -->
<Button HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,4,4,0"
Theme="{StaticResource BorderlessButton}" Padding="4"
ToolTip.Tip="{x:Static lang:Resources.Menu_More}">
<icons:PackIconLucide Kind="Ellipsis" Width="14" Height="14"/>
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuItem Header="{x:Static lang:Resources.Menu_Delete}"
Command="{Binding DeleteCommand}"/>
<MenuItem Header="{x:Static lang:Resources.Menu_MoveTo}"
ItemsSource="{Binding MoveTargets}">
<MenuItem.ItemContainerTheme>
<ControlTheme TargetType="MenuItem"
BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:MoveTargetViewModel">
<Setter Property="Header" Value="{Binding Title}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding ColumnId}"/>
<Setter Property="IsEnabled" Value="{Binding IsEnabled}"/>
</ControlTheme>
</MenuItem.ItemContainerTheme>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Board;
public partial class BoardView : UserControl
{
public BoardView()
{
InitializeComponent();
}
}
+68
View File
@@ -0,0 +1,68 @@
using Avalonia.Controls;
using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs;
namespace YKanBan.Views;
/// <summary>
/// Shows dialog view models as modal windows. A <c>XxxDialogViewModel</c> in
/// <c>YKanBan.ViewModels.Dialogs</c> is shown by the <c>XxxDialog</c> window in
/// <c>YKanBan.Views.Dialogs</c>. Each dialog is owned by the top-most open
/// window, so nested modals (the tag picker inside the card editor, a discard
/// confirmation above it) center on their direct parent.
/// </summary>
public sealed class DialogService : IDialogService
{
private readonly Stack<Window> _owners = new();
/// <summary>
/// Sets the main window as the bottom of the owner stack.
/// </summary>
/// <param name="mainWindow">The main window.</param>
public void Attach(Window mainWindow)
{
_owners.Clear();
_owners.Push(mainWindow);
}
/// <inheritdoc/>
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
{
if (!_owners.TryPeek(out Window? owner))
{
throw new InvalidOperationException("The dialog service has no main window attached.");
}
DialogWindow window = CreateWindow(dialog);
window.DataContext = dialog;
// FlowDirection does not inherit across windows, so every modal gets it explicitly.
window.FlowDirection = App.UiFlowDirection;
_owners.Push(window);
try
{
return await window.ShowDialog<bool>(owner);
}
finally
{
_owners.Pop();
}
}
/// <summary>
/// Creates the window class matching a dialog view model by naming convention.
/// </summary>
/// <param name="dialog">The dialog view model.</param>
/// <returns>The window.</returns>
private static DialogWindow CreateWindow(DialogViewModelBase dialog)
{
string viewName = typeof(ConfirmDialog).Namespace + "." + dialog.GetType().Name.Replace("ViewModel", "", StringComparison.Ordinal);
Type? viewType = typeof(DialogService).Assembly.GetType(viewName);
if (viewType is null || !typeof(DialogWindow).IsAssignableFrom(viewType))
{
throw new InvalidOperationException($"No dialog window '{viewName}' for {dialog.GetType().Name}.");
}
return (DialogWindow)Activator.CreateInstance(viewType)!;
}
}
@@ -0,0 +1,81 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:board="using:YKanBan.ViewModels.Board"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
x:Class="YKanBan.Views.Dialogs.CardEditorDialog"
x:DataType="vm:CardEditorDialogViewModel"
x:Name="Root"
Title="{Binding Title}"
SizeToContent="Manual" CanResize="True"
Width="600" Height="560" MinWidth="440" MinHeight="420"
WindowStartupLocation="CenterOwner">
<Grid Margin="20" RowDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" RowSpacing="12">
<!-- Read-only id and timestamps; not shown while the card does not exist yet. -->
<WrapPanel Grid.Row="0" IsVisible="{Binding IsExisting}" Orientation="Horizontal">
<TextBlock Text="{Binding IdText}" FontWeight="Bold" Margin="0,0,16,0"/>
<TextBlock Text="{Binding CreatedText}" Opacity="0.7" Margin="0,0,16,0"/>
<TextBlock Text="{Binding ModifiedText}" Opacity="0.7"/>
</WrapPanel>
<StackPanel Grid.Row="1" Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Field_Title}"/>
<TextBox Text="{Binding CardTitle}" Watermark="{x:Static lang:Resources.CardEditor_TitleWatermark}"/>
</StackPanel>
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="4">
<TextBlock Grid.Row="0" Text="{x:Static lang:Resources.Field_Content}"/>
<TextBox Grid.Row="1" Text="{Binding Content}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalAlignment="Stretch"
VerticalContentAlignment="Top"/>
</Grid>
<StackPanel Grid.Row="3" Spacing="6">
<TextBlock Text="{x:Static lang:Resources.Field_Tags}"/>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="board:TagBadgeViewModel">
<Border Background="{Binding Background}" CornerRadius="10" Padding="8,2,4,2"
Margin="0,0,6,6" ToolTip.Tip="{Binding Description}">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}"
VerticalAlignment="Center" FontSize="12"/>
<Button Command="{Binding #Root.((vm:CardEditorDialogViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}"
Theme="{StaticResource BorderlessButton}" Padding="2"
ToolTip.Tip="{x:Static lang:Resources.CardEditor_RemoveTag}">
<icons:PackIconLucide Kind="X" Width="12" Height="12" Foreground="{Binding Foreground}"/>
</Button>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Command="{Binding AddTagCommand}" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="14" Height="14"/>
<TextBlock Text="{x:Static lang:Resources.CardEditor_AddTag}"/>
</StackPanel>
</Button>
</StackPanel>
<TextBlock Grid.Row="4" Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
Classes="Primary" MinWidth="90"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class CardEditorDialog : DialogWindow
{
public CardEditorDialog()
{
InitializeComponent();
}
}
@@ -0,0 +1,33 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog"
x:DataType="vm:ColumnEditorDialogViewModel"
Title="{Binding Title}"
Width="440"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="14">
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Field_Title}"/>
<TextBox Text="{Binding ColumnTitle}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Field_Description}"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="72" MaxHeight="200"/>
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
Classes="Primary" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</StackPanel>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class ColumnEditorDialog : DialogWindow
{
public ColumnEditorDialog()
{
InitializeComponent();
}
}
@@ -0,0 +1,30 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Dialogs.ColumnWidthDialog"
x:DataType="vm:ColumnWidthDialogViewModel"
Title="{x:Static lang:Resources.ColumnWidth_Title}"
Width="360"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="10">
<RadioButton GroupName="Width" Content="{Binding NarrowLabel}" IsChecked="{Binding IsNarrow}"/>
<RadioButton GroupName="Width" Content="{Binding StandardLabel}" IsChecked="{Binding IsStandard}"/>
<RadioButton GroupName="Width" Content="{Binding WideLabel}" IsChecked="{Binding IsWide}"/>
<RadioButton GroupName="Width" Content="{x:Static lang:Resources.ColumnWidth_Custom}" IsChecked="{Binding IsCustom}"/>
<StackPanel Orientation="Horizontal" Spacing="8" Margin="28,0,0,0" IsEnabled="{Binding IsCustom}">
<NumericUpDown Value="{Binding CustomPixels}" Minimum="{Binding MinPixels}" Maximum="{Binding MaxPixels}"
Increment="10" FormatString="0" Width="140"/>
<TextBlock Text="{Binding RangeLabel}" VerticalAlignment="Center" Opacity="0.7"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,8,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
Classes="Primary" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</StackPanel>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class ColumnWidthDialog : DialogWindow
{
public ColumnWidthDialog()
{
InitializeComponent();
}
}
+21
View File
@@ -0,0 +1,21 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Dialogs.ConfirmDialog"
x:DataType="vm:ConfirmDialogViewModel"
Title="{x:Static lang:Resources.Confirm_Title}"
MinWidth="360" MaxWidth="520"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="16">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{Binding ConfirmLabel}" Command="{Binding ConfirmCommand}"
Classes="Danger" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</StackPanel>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class ConfirmDialog : DialogWindow
{
public ConfirmDialog()
{
InitializeComponent();
}
}
+86
View File
@@ -0,0 +1,86 @@
using Avalonia.Controls;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
/// <summary>
/// Base class of the windows shown by <see cref="DialogService"/>: a standard
/// Avalonia window centered on its owner that closes when its
/// <see cref="DialogViewModelBase"/> asks, with the result as the dialog result.
/// The title bar close button is routed to the view model's cancel path, so a
/// dialog with unsaved changes can ask before discarding them.
/// </summary>
public class DialogWindow : Window
{
private DialogViewModelBase? _viewModel;
private bool _closeRequested;
private bool _cancelPending;
public DialogWindow()
{
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
CanResize = false;
SizeToContent = SizeToContent.WidthAndHeight;
}
/// <summary>Styles the subclasses as plain windows.</summary>
protected override Type StyleKeyOverride => typeof(Window);
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (_viewModel is not null)
{
_viewModel.CloseRequested -= OnCloseRequested;
}
_viewModel = DataContext as DialogViewModelBase;
if (_viewModel is not null)
{
_viewModel.CloseRequested += OnCloseRequested;
}
}
protected override async void OnClosing(WindowClosingEventArgs e)
{
base.OnClosing(e);
// Only the user's close button goes through the view model; shutdown and
// owner closing are let through.
if (_closeRequested || _viewModel is null || e.CloseReason != WindowCloseReason.WindowClosing)
{
return;
}
e.Cancel = true;
if (_cancelPending)
{
return;
}
_cancelPending = true;
try
{
await _viewModel.RequestCancelAsync();
}
finally
{
_cancelPending = false;
}
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
if (_viewModel is not null)
{
_viewModel.CloseRequested -= OnCloseRequested;
}
}
private void OnCloseRequested(object? sender, bool result)
{
_closeRequested = true;
Close(result);
}
}
+19
View File
@@ -0,0 +1,19 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Dialogs.MessageDialog"
x:DataType="vm:MessageDialogViewModel"
Title="{Binding Title}"
MinWidth="360" MaxWidth="560"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
<SelectableTextBlock Text="{Binding Details}" IsVisible="{Binding HasDetails}"
TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding OkCommand}"
HorizontalAlignment="Right" MinWidth="90" IsDefault="True" IsCancel="True"/>
</StackPanel>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class MessageDialog : DialogWindow
{
public MessageDialog()
{
InitializeComponent();
}
}
@@ -0,0 +1,76 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:board="using:YKanBan.ViewModels.Board"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
x:Class="YKanBan.Views.Dialogs.TagPickerDialog"
x:DataType="vm:TagPickerDialogViewModel"
x:Name="Root"
Title="{x:Static lang:Resources.TagPicker_Title}"
SizeToContent="Manual" CanResize="True"
Width="460" Height="560" MinWidth="380" MinHeight="440"
WindowStartupLocation="CenterOwner">
<Grid Margin="20" RowDefinitions="Auto,*,Auto,Auto" RowSpacing="12">
<TextBox Grid.Row="0" Text="{Binding FilterText}" Watermark="{x:Static lang:Resources.TagPicker_FilterWatermark}">
<TextBox.InnerLeftContent>
<icons:PackIconLucide Kind="Search" Width="14" Height="14" Margin="8,0,0,0" Opacity="0.6"
VerticalAlignment="Center"/>
</TextBox.InnerLeftContent>
</TextBox>
<!-- Existing tags not yet on the card; one click picks a tag. -->
<Border Grid.Row="1" BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1" CornerRadius="4">
<Panel>
<ListBox ItemsSource="{Binding FilteredTags}" Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="board:TagBadgeViewModel">
<Button Command="{Binding #Root.((vm:TagPickerDialogViewModel)DataContext).PickCommand}"
CommandParameter="{Binding}"
Theme="{StaticResource BorderlessButton}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" Padding="4">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Background="{Binding Background}" CornerRadius="10" Padding="8,2">
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}" FontSize="12"/>
</Border>
<TextBlock Text="{Binding Description}" Opacity="0.7" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{x:Static lang:Resources.TagPicker_NoMatches}" IsVisible="{Binding NoMatches}"
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
</Panel>
</Border>
<!-- New tag: written to the tags table right away, even if the card edit is cancelled. -->
<StackPanel Grid.Row="2" Spacing="8">
<TextBlock Text="{x:Static lang:Resources.TagPicker_NewTag}" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding NewName}" Watermark="{x:Static lang:Resources.Field_Name}"/>
<TextBox Grid.Column="1" Text="{Binding NewColor}" Width="100"
Watermark="{x:Static lang:Resources.Field_Color}"/>
<Border Grid.Column="2" Width="28" Height="28" CornerRadius="4"
Background="{Binding NewColorPreview}"
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
</Grid>
<TextBox Text="{Binding NewDescription}" Watermark="{x:Static lang:Resources.Field_Description}"/>
<TextBlock Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
<Button Command="{Binding CreateCommand}" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="14" Height="14"/>
<TextBlock Text="{x:Static lang:Resources.TagPicker_Create}"/>
</StackPanel>
</Button>
</StackPanel>
<Button Grid.Row="3" Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
HorizontalAlignment="Right" MinWidth="90" IsCancel="True"/>
</Grid>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class TagPickerDialog : DialogWindow
{
public TagPickerDialog()
{
InitializeComponent();
}
}
+4 -2
View File
@@ -48,9 +48,11 @@
</Grid>
</Border>
<!-- Tags and board tabs; their contents arrive with the corresponding features. -->
<!-- Board and tags tabs; the tags tab is filled in by the tag feature. -->
<TabControl Grid.Row="1" Margin="8">
<TabItem Header="{x:Static lang:Resources.Tab_Board}"/>
<TabItem Header="{x:Static lang:Resources.Tab_Board}">
<ContentControl Content="{Binding Board}" Margin="0,8,0,0"/>
</TabItem>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
</TabControl>
+3 -3
View File
@@ -176,7 +176,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
**看板选项卡**:
- 搜索栏(点击搜索按钮才执行,无实时搜索,带 × 一键清空);右侧依次:新增列按钮、列宽调整按钮、排序 combobox
- 列宽:模态选择预设窄/标准/宽或手动像素值(范围 160–720,默认"标准"= 320),全局生效,持久化到 app.json
- 列宽:模态选择预设窄/标准/宽或手动像素值(窄 = 240 / 标准 = 320 / 宽 = 480;手动范围 160–720,默认"标准"),全局生效,持久化到 app.json
- 看板区域(与 GitHub Projects 基本一致):列左→右横排,卡片上→下展开;**无拖拽**
- 列内卡片列表必须使用 UI 虚拟化(如 `VirtualizingStackPanel`),满足 10000 张卡片量级的流畅显示
- 列右上角:+新建卡片、⋯菜单(编辑、删除);"编辑" = 标题 + description 编辑模态
@@ -372,8 +372,8 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
| M2 仓库层 | 已完成 | CRUD/移动/级联/标签指派、单连接生命周期 | 单测 |
| M3 搜索与导出 | 已完成 | Pidgin 解析器(严格校验,非法输入返回英文错误消息)、查询→SQL 编译、Markdown 导出(直接读 ResX,en / zh-Hans 输出均有测试) | 单测(成功与错误全分支) |
| M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 |
| **M4R v1.9 回补** | 待办 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 |
| M5 看板界面 | 待办 | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 |
| **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 |
| M5 看板界面 | 已完成(待人工验收) | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 |
| M6 标签与搜索集成 | 待办 | 标签 tab、搜索执行与空列保留、导出接线(含文件名/时间戳 i18n 模式) | 人工 |
| M7 发布与文档 | 待办 | 单一 workflow(4 RID 矩阵 build→test→artifact,osx-x64 用 Intel 运行器)文件就绪(结构自查)、根目录 USAGE.md、README(引用 USAGE.md)、人工验收清单(含语言切换重启生效、导出语言跟随界面、主题三态切换、app.json 多实例脏写四项) | workflow 结构自查通过;实际触发与产物验证视网络条件推迟 |