feat: add board interface with cards, columns and dialogs

This commit is contained in:
yyc12345 committed 2026-10-03 21:18:54 +08:00
1 parent f36561cb7f
commit 830f1153fd
45 files changed
+2843 -101

No files matched your search

@@ -21,9 +21,10 @@ public class AppConfigStoreTests
Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version); Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
Assert.AreEqual(AppConfig.DefaultLanguage, config.Language); Assert.AreEqual(AppConfig.DefaultLanguage, config.Language);
Assert.AreEqual(ThemeOption.FollowSystem, config.Theme); Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
Assert.AreEqual(CardSortOption.Id, config.Sort.Card); Assert.AreEqual(SortOption.IdAscending, config.Display.CardSort);
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset); Assert.AreEqual(SortOption.IdAscending, config.Display.ColumnSort);
Assert.AreEqual(320, config.ColumnWidth.CustomPixels); Assert.AreEqual(ColumnWidthPreset.Standard, config.Display.ColumnWidth.Preset);
Assert.AreEqual(320, config.Display.ColumnWidth.CustomPixels);
Assert.IsTrue(config.Confirmations.DeleteCard); Assert.IsTrue(config.Confirmations.DeleteCard);
Assert.IsTrue(config.Confirmations.DeleteColumn); Assert.IsTrue(config.Confirmations.DeleteColumn);
Assert.IsTrue(config.Confirmations.DeleteTag); Assert.IsTrue(config.Confirmations.DeleteTag);
@@ -66,8 +67,11 @@ public class AppConfigStoreTests
"version": {{AppConfig.CurrentFormatVersion}}, "version": {{AppConfig.CurrentFormatVersion}},
"language": "zh-Hans", "language": "zh-Hans",
"theme": "dark", "theme": "dark",
"sort": { "card": "title" }, "display": {
"column-width": { "preset": "custom", "custom-pixels": 400 }, "column-width": { "preset": "custom", "custom-pixels": 400 },
"card-sort": "title-asc",
"column-sort": "id-desc"
},
"confirmations": { "confirmations": {
"delete-card": false, "delete-card": false,
"delete-column": false, "delete-column": false,
@@ -82,9 +86,10 @@ public class AppConfigStoreTests
Assert.AreEqual("zh-Hans", config.Language); Assert.AreEqual("zh-Hans", config.Language);
Assert.AreEqual(ThemeOption.Dark, config.Theme); Assert.AreEqual(ThemeOption.Dark, config.Theme);
Assert.AreEqual(CardSortOption.Title, config.Sort.Card); Assert.AreEqual(SortOption.TitleAscending, config.Display.CardSort);
Assert.AreEqual(ColumnWidthPreset.Custom, config.ColumnWidth.Preset); Assert.AreEqual(SortOption.IdDescending, config.Display.ColumnSort);
Assert.AreEqual(400, config.ColumnWidth.CustomPixels); Assert.AreEqual(ColumnWidthPreset.Custom, config.Display.ColumnWidth.Preset);
Assert.AreEqual(400, config.Display.ColumnWidth.CustomPixels);
Assert.IsFalse(config.Confirmations.DeleteCard); Assert.IsFalse(config.Confirmations.DeleteCard);
Assert.IsFalse(config.Confirmations.DeleteColumn); Assert.IsFalse(config.Confirmations.DeleteColumn);
Assert.IsFalse(config.Confirmations.DeleteTag); Assert.IsFalse(config.Confirmations.DeleteTag);
@@ -101,10 +106,10 @@ public class AppConfigStoreTests
AppConfig config = store.Load(); AppConfig config = store.Load();
Assert.IsNotNull(config.Sort); Assert.IsNotNull(config.Display);
Assert.IsNotNull(config.ColumnWidth); Assert.IsNotNull(config.Display.ColumnWidth);
Assert.IsNotNull(config.Confirmations); Assert.IsNotNull(config.Confirmations);
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset); Assert.AreEqual(ColumnWidthPreset.Standard, config.Display.ColumnWidth.Preset);
} }
[TestMethod] [TestMethod]
@@ -117,8 +122,12 @@ public class AppConfigStoreTests
var config = new AppConfig var config = new AppConfig
{ {
Theme = ThemeOption.FollowSystem, Theme = ThemeOption.FollowSystem,
Sort = new SortSettings { Card = CardSortOption.UpdatedAt }, Display = new DisplaySettings
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 }, {
CardSort = SortOption.UpdatedDescending,
ColumnSort = SortOption.CreatedAscending,
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 },
},
Confirmations = new ConfirmationSettings { DeleteColumn = false }, Confirmations = new ConfirmationSettings { DeleteColumn = false },
}; };
store.Save(config); store.Save(config);
@@ -126,9 +135,12 @@ public class AppConfigStoreTests
string json = File.ReadAllText(path); string json = File.ReadAllText(path);
StringAssert.Contains(json, "\"delete-column\""); StringAssert.Contains(json, "\"delete-column\"");
StringAssert.Contains(json, "\"discard-edit\""); StringAssert.Contains(json, "\"discard-edit\"");
StringAssert.Contains(json, "\"card-sort\"");
StringAssert.Contains(json, "\"column-sort\"");
StringAssert.Contains(json, "\"custom-pixels\""); StringAssert.Contains(json, "\"custom-pixels\"");
StringAssert.Contains(json, "\"follow-system\""); StringAssert.Contains(json, "\"follow-system\"");
StringAssert.Contains(json, "\"updated-at\""); StringAssert.Contains(json, "\"updated-desc\"");
StringAssert.Contains(json, "\"created-asc\"");
// No member-name CamelCase must leak into the file. // No member-name CamelCase must leak into the file.
Assert.IsFalse(json.Contains("deleteColumn", StringComparison.Ordinal)); Assert.IsFalse(json.Contains("deleteColumn", StringComparison.Ordinal));
Assert.IsFalse(json.Contains("CustomPixels", StringComparison.Ordinal)); Assert.IsFalse(json.Contains("CustomPixels", StringComparison.Ordinal));
@@ -145,8 +157,12 @@ public class AppConfigStoreTests
{ {
Language = "zh-Hans", Language = "zh-Hans",
Theme = ThemeOption.Light, Theme = ThemeOption.Light,
Sort = new SortSettings { Card = CardSortOption.CreatedAt }, Display = new DisplaySettings
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide }, {
CardSort = SortOption.CreatedDescending,
ColumnSort = SortOption.TitleAscending,
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
},
Confirmations = new ConfirmationSettings { DiscardEdit = false }, Confirmations = new ConfirmationSettings { DiscardEdit = false },
}; };
store.Save(config); store.Save(config);
@@ -154,8 +170,9 @@ public class AppConfigStoreTests
AppConfig reloaded = store.Load(); AppConfig reloaded = store.Load();
Assert.AreEqual(config.Language, reloaded.Language); Assert.AreEqual(config.Language, reloaded.Language);
Assert.AreEqual(config.Theme, reloaded.Theme); Assert.AreEqual(config.Theme, reloaded.Theme);
Assert.AreEqual(config.Sort.Card, reloaded.Sort.Card); Assert.AreEqual(config.Display.CardSort, reloaded.Display.CardSort);
Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset); Assert.AreEqual(config.Display.ColumnSort, reloaded.Display.ColumnSort);
Assert.AreEqual(config.Display.ColumnWidth.Preset, reloaded.Display.ColumnWidth.Preset);
Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit); Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit);
} }
} }
@@ -6,34 +6,43 @@ using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace; namespace YKanBan.Tests.Storage.Workspace;
/// <summary> /// <summary>
/// Verifies that the preset column titles follow the UI language active when /// Verifies that the preset columns and tags follow the UI language active when
/// they are added and then live as ordinary data. These tests mutate the /// they are added and then live as ordinary data. These tests mutate the global
/// global <see cref="Resources.Culture"/>, so they are marked do-not-parallelize. /// <see cref="Resources.Culture"/>, so they are marked do-not-parallelize.
/// </summary> /// </summary>
[TestClass] [TestClass]
[DoNotParallelize] [DoNotParallelize]
public class WorkspacePresetTests public class WorkspacePresetTests
{ {
[TestMethod] [TestMethod]
public void AddPresetColumnsStoresEnglishTitlesUnderEnglishCulture() public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture()
{ {
CultureInfo? original = Resources.Culture; CultureInfo? original = Resources.Culture;
try try
{ {
Resources.Culture = new CultureInfo("en"); Resources.Culture = new CultureInfo("en");
string[] expected =
[
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
];
using var directory = new TempDirectory(); using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath); WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(directory.FullPath); WorkspacePreset.AddPresetColumns(directory.FullPath);
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); CollectionAssert.AreEqual(
new[]
{
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
},
SqliteTestHelper.GetColumnTitles(connection));
CollectionAssert.AreEqual(
new[]
{
Resources.PresetColumn_Todo_Description,
Resources.PresetColumn_InProgress_Description,
Resources.PresetColumn_Done_Description,
},
SqliteTestHelper.GetColumnDescriptions(connection));
} }
finally finally
{ {
@@ -42,25 +51,29 @@ public class WorkspacePresetTests
} }
[TestMethod] [TestMethod]
public void AddPresetColumnsStoresChineseTitlesUnderChineseCulture() public void AddPresetColumnsStoresChineseTextUnderChineseCulture()
{ {
CultureInfo? original = Resources.Culture; CultureInfo? original = Resources.Culture;
try try
{ {
Resources.Culture = new CultureInfo("zh-Hans"); Resources.Culture = new CultureInfo("zh-Hans");
string[] expected =
[
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
];
using var directory = new TempDirectory(); using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath); WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(directory.FullPath); WorkspacePreset.AddPresetColumns(directory.FullPath);
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); CollectionAssert.AreEqual(
new[]
{
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
},
SqliteTestHelper.GetColumnTitles(connection));
// Chinese resources must resolve to different text than English.
Assert.AreEqual("待办", Resources.PresetColumn_Todo);
Assert.AreEqual("已完成的任务。", Resources.PresetColumn_Done_Description);
} }
finally finally
{ {
@@ -69,27 +82,74 @@ public class WorkspacePresetTests
} }
[TestMethod] [TestMethod]
public void PresetTitlesAreDataAndIgnoreLaterCultureChanges() public void AddPresetTagsStoresEightTagsUnderEnglishCulture()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("en");
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetTags(directory.FullPath);
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
List<(string Name, string Color, string Description)> tags = SqliteTestHelper.GetTags(connection);
Assert.AreEqual(8, tags.Count);
Assert.AreEqual(("bug", "#d73a4a", "Something isn't working"), tags[0]);
Assert.AreEqual(("documentation", "#0075ca", "Improvements or additions to documentation"), tags[1]);
Assert.AreEqual(("wontfix", "#ffffff", "This will not be worked on"), tags[7]);
}
finally
{
Resources.Culture = original;
}
}
[TestMethod]
public void AddPresetTagsStoresChineseNamesUnderChineseCulture()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("zh-Hans");
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetTags(directory.FullPath);
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
List<(string Name, string Color, string Description)> tags = SqliteTestHelper.GetTags(connection);
Assert.AreEqual(8, tags.Count);
Assert.AreEqual("缺陷", tags[0].Name);
Assert.AreEqual("功能没有正常工作", tags[0].Description);
}
finally
{
Resources.Culture = original;
}
}
[TestMethod]
public void PresetContentIsDataAndIgnoresLaterCultureChanges()
{ {
CultureInfo? original = Resources.Culture; CultureInfo? original = Resources.Culture;
try try
{ {
Resources.Culture = new CultureInfo("en"); Resources.Culture = new CultureInfo("en");
string[] expected =
[
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
];
using var directory = new TempDirectory(); using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath); WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(directory.FullPath); WorkspacePreset.AddPresetColumns(directory.FullPath);
WorkspacePreset.AddPresetTags(directory.FullPath);
// Switching the language after adding the presets must not rewrite stored data. // Switching the language after adding the presets must not rewrite stored data.
Resources.Culture = new CultureInfo("zh-Hans"); Resources.Culture = new CultureInfo("zh-Hans");
using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection)); Assert.AreEqual("To Do", SqliteTestHelper.GetColumnTitles(connection)[0]);
Assert.AreEqual("bug", SqliteTestHelper.GetTags(connection)[0].Name);
} }
finally finally
{ {
@@ -87,6 +87,44 @@ public static class SqliteTestHelper
return titles; return titles;
} }
/// <summary>
/// Returns the descriptions of all columns, ordered by id ascending.
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The column descriptions in id order.</returns>
public static List<string> GetColumnDescriptions(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT description FROM columns ORDER BY id ASC;";
using var reader = command.ExecuteReader();
var descriptions = new List<string>();
while (reader.Read())
{
descriptions.Add(reader.GetString(0));
}
return descriptions;
}
/// <summary>
/// Returns all tags as name/color/description tuples, ordered by id ascending.
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The tags in id order.</returns>
public static List<(string Name, string Color, string Description)> GetTags(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT name, color, description FROM tags ORDER BY id ASC;";
using var reader = command.ExecuteReader();
var tags = new List<(string, string, string)>();
while (reader.Read())
{
tags.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2)));
}
return tags;
}
/// <summary> /// <summary>
/// Opens a workspace database with the real workspace migration list. /// Opens a workspace database with the real workspace migration list.
/// </summary> /// </summary>
+10
View File
@@ -26,10 +26,20 @@
<ResourceDictionary x:Key="Light"> <ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/> <SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/> <SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/>
<SolidColorBrush x:Key="BoardColumnBackgroundBrush" Color="#F1F2F5"/>
<SolidColorBrush x:Key="BoardColumnBorderBrush" Color="#E2E4EA"/>
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBorderBrush" Color="#E2E4EA"/>
<SolidColorBrush x:Key="ErrorTextBrush" Color="#C0392B"/>
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Dark"> <ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/> <SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/> <SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/>
<SolidColorBrush x:Key="BoardColumnBackgroundBrush" Color="#17181C"/>
<SolidColorBrush x:Key="BoardColumnBorderBrush" Color="#2C2F37"/>
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#23252B"/>
<SolidColorBrush x:Key="CardBorderBrush" Color="#33363F"/>
<SolidColorBrush x:Key="ErrorTextBrush" Color="#FF6B6B"/>
</ResourceDictionary> </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries> </ResourceDictionary.ThemeDictionaries>
</ResourceDictionary> </ResourceDictionary>
+227 -2
View File
@@ -25,6 +25,9 @@
<data name="Common_Cancel" xml:space="preserve"> <data name="Common_Cancel" xml:space="preserve">
<value>Cancel</value> <value>Cancel</value>
</data> </data>
<data name="Common_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Main_Export" xml:space="preserve"> <data name="Main_Export" xml:space="preserve">
<value>Export</value> <value>Export</value>
</data> </data>
@@ -40,6 +43,171 @@
<data name="Tab_Board" xml:space="preserve"> <data name="Tab_Board" xml:space="preserve">
<value>Board</value> <value>Board</value>
</data> </data>
<data name="Board_SearchPlaceholder" xml:space="preserve">
<value>Search…</value>
</data>
<data name="Board_Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="Board_ClearSearch" xml:space="preserve">
<value>Clear</value>
</data>
<data name="Board_AddColumn" xml:space="preserve">
<value>Add Column</value>
</data>
<data name="Board_ColumnWidth" xml:space="preserve">
<value>Column Width</value>
</data>
<data name="Board_SortCard" xml:space="preserve">
<value>Card Sort</value>
</data>
<data name="Board_SortColumn" xml:space="preserve">
<value>Column Sort</value>
</data>
<data name="Board_Display" xml:space="preserve">
<value>Display</value>
</data>
<data name="Board_NoColumns" xml:space="preserve">
<value>No columns yet.</value>
</data>
<data name="Sort_IdAsc" xml:space="preserve">
<value>ID Ascending</value>
</data>
<data name="Sort_IdDesc" xml:space="preserve">
<value>ID Descending</value>
</data>
<data name="Sort_CreatedAsc" xml:space="preserve">
<value>Created Ascending</value>
</data>
<data name="Sort_CreatedDesc" xml:space="preserve">
<value>Created Descending</value>
</data>
<data name="Sort_UpdatedAsc" xml:space="preserve">
<value>Modified Ascending</value>
</data>
<data name="Sort_UpdatedDesc" xml:space="preserve">
<value>Modified Descending</value>
</data>
<data name="Sort_TitleAsc" xml:space="preserve">
<value>Title Ascending</value>
</data>
<data name="Sort_TitleDesc" xml:space="preserve">
<value>Title Descending</value>
</data>
<data name="Column_AddCard" xml:space="preserve">
<value>New Card</value>
</data>
<data name="Column_Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="Column_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Card_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Card_Move" xml:space="preserve">
<value>Move to Column…</value>
</data>
<data name="ColumnDialog_AddTitle" xml:space="preserve">
<value>New Column</value>
</data>
<data name="ColumnDialog_EditTitle" xml:space="preserve">
<value>Edit Column</value>
</data>
<data name="ColumnDialog_Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="ColumnDialog_Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="ColumnDialog_InvalidTitle" xml:space="preserve">
<value>The title is empty or already used.</value>
</data>
<data name="CardDialog_AddTitle" xml:space="preserve">
<value>New Card</value>
</data>
<data name="CardDialog_EditTitle" xml:space="preserve">
<value>Edit Card</value>
</data>
<data name="CardDialog_Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="CardDialog_Content" xml:space="preserve">
<value>Content</value>
</data>
<data name="CardDialog_Tags" xml:space="preserve">
<value>Tags</value>
</data>
<data name="CardDialog_AddTag" xml:space="preserve">
<value>Add Tag</value>
</data>
<data name="CardDialog_Id" xml:space="preserve">
<value>ID: {0}</value>
</data>
<data name="CardDialog_Created" xml:space="preserve">
<value>Created: {0}</value>
</data>
<data name="CardDialog_Modified" xml:space="preserve">
<value>Modified: {0}</value>
</data>
<data name="CardDialog_TimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm</value>
</data>
<data name="CardDialog_Error" xml:space="preserve">
<value>Could not save the card.</value>
</data>
<data name="TagDialog_Title" xml:space="preserve">
<value>Add Tag</value>
</data>
<data name="TagDialog_Target" xml:space="preserve">
<value>Tag</value>
</data>
<data name="TagDialog_NoTargets" xml:space="preserve">
<value>There are no more tags to add.</value>
</data>
<data name="MoveDialog_Title" xml:space="preserve">
<value>Move Card</value>
</data>
<data name="MoveDialog_Target" xml:space="preserve">
<value>Target Column</value>
</data>
<data name="MoveDialog_NoTargets" xml:space="preserve">
<value>There is nowhere to move this card.</value>
</data>
<data name="WidthPreset_Narrow" xml:space="preserve">
<value>Narrow</value>
</data>
<data name="WidthPreset_Standard" xml:space="preserve">
<value>Standard</value>
</data>
<data name="WidthPreset_Wide" xml:space="preserve">
<value>Wide</value>
</data>
<data name="WidthPreset_Custom" xml:space="preserve">
<value>Custom</value>
</data>
<data name="WidthDialog_Pixels" xml:space="preserve">
<value>Pixels (160–720)</value>
</data>
<data name="Confirm_DeleteColumn_Title" xml:space="preserve">
<value>Delete Column</value>
</data>
<data name="Confirm_DeleteColumn_Message" xml:space="preserve">
<value>Delete column "{0}" and its {1} card(s)?</value>
</data>
<data name="Confirm_DeleteCard_Title" xml:space="preserve">
<value>Delete Card</value>
</data>
<data name="Confirm_DeleteCard_Message" xml:space="preserve">
<value>Delete card #{0}?</value>
</data>
<data name="Confirm_Discard_Title" xml:space="preserve">
<value>Discard Changes</value>
</data>
<data name="Confirm_Discard_Message" xml:space="preserve">
<value>Discard the unsaved changes?</value>
</data>
<data name="Error_Argument_Title" xml:space="preserve"> <data name="Error_Argument_Title" xml:space="preserve">
<value>Invalid arguments</value> <value>Invalid arguments</value>
</data> </data>
@@ -62,7 +230,7 @@
<value>The folder does not contain a .ykanban workspace: {0}</value> <value>The folder does not contain a .ykanban workspace: {0}</value>
</data> </data>
<data name="Error_NotInitialized_Initialize" xml:space="preserve"> <data name="Error_NotInitialized_Initialize" xml:space="preserve">
<value>Initialize workspace</value> <value>Initialize Workspace</value>
</data> </data>
<data name="Error_LockConflict_Title" xml:space="preserve"> <data name="Error_LockConflict_Title" xml:space="preserve">
<value>Workspace locked</value> <value>Workspace locked</value>
@@ -95,7 +263,7 @@
<value>Dark</value> <value>Dark</value>
</data> </data>
<data name="Settings_ThemeFollowSystem" xml:space="preserve"> <data name="Settings_ThemeFollowSystem" xml:space="preserve">
<value>Follow system</value> <value>Follow System</value>
</data> </data>
<data name="Settings_Confirmations" xml:space="preserve"> <data name="Settings_Confirmations" xml:space="preserve">
<value>Confirmations</value> <value>Confirmations</value>
@@ -130,12 +298,69 @@
<data name="PresetColumn_Todo" xml:space="preserve"> <data name="PresetColumn_Todo" xml:space="preserve">
<value>To Do</value> <value>To Do</value>
</data> </data>
<data name="PresetColumn_Todo_Description" xml:space="preserve">
<value>Tasks that are planned but not started.</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve"> <data name="PresetColumn_InProgress" xml:space="preserve">
<value>In Progress</value> <value>In Progress</value>
</data> </data>
<data name="PresetColumn_InProgress_Description" xml:space="preserve">
<value>Tasks that are currently being worked on.</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve"> <data name="PresetColumn_Done" xml:space="preserve">
<value>Done</value> <value>Done</value>
</data> </data>
<data name="PresetColumn_Done_Description" xml:space="preserve">
<value>Tasks that have been completed.</value>
</data>
<data name="PresetTag_Bug_Name" xml:space="preserve">
<value>bug</value>
</data>
<data name="PresetTag_Bug_Description" xml:space="preserve">
<value>Something isn't working</value>
</data>
<data name="PresetTag_Documentation_Name" xml:space="preserve">
<value>documentation</value>
</data>
<data name="PresetTag_Documentation_Description" xml:space="preserve">
<value>Improvements or additions to documentation</value>
</data>
<data name="PresetTag_Duplicate_Name" xml:space="preserve">
<value>duplicate</value>
</data>
<data name="PresetTag_Duplicate_Description" xml:space="preserve">
<value>This issue or pull request already exists</value>
</data>
<data name="PresetTag_Enhancement_Name" xml:space="preserve">
<value>enhancement</value>
</data>
<data name="PresetTag_Enhancement_Description" xml:space="preserve">
<value>New feature or request</value>
</data>
<data name="PresetTag_HelpWanted_Name" xml:space="preserve">
<value>help-wanted</value>
</data>
<data name="PresetTag_HelpWanted_Description" xml:space="preserve">
<value>Extra attention is needed</value>
</data>
<data name="PresetTag_Invalid_Name" xml:space="preserve">
<value>invalid</value>
</data>
<data name="PresetTag_Invalid_Description" xml:space="preserve">
<value>This doesn't seem right</value>
</data>
<data name="PresetTag_Question_Name" xml:space="preserve">
<value>question</value>
</data>
<data name="PresetTag_Question_Description" xml:space="preserve">
<value>Further information is requested</value>
</data>
<data name="PresetTag_Wontfix_Name" xml:space="preserve">
<value>wontfix</value>
</data>
<data name="PresetTag_Wontfix_Description" xml:space="preserve">
<value>This will not be worked on</value>
</data>
<data name="Export_FileNamePattern" xml:space="preserve"> <data name="Export_FileNamePattern" xml:space="preserve">
<value>{0}-export-{1:yyyyMMdd-HHmmss}.md</value> <value>{0}-export-{1:yyyyMMdd-HHmmss}.md</value>
</data> </data>
@@ -25,6 +25,9 @@
<data name="Common_Cancel" xml:space="preserve"> <data name="Common_Cancel" xml:space="preserve">
<value>取消</value> <value>取消</value>
</data> </data>
<data name="Common_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Main_Export" xml:space="preserve"> <data name="Main_Export" xml:space="preserve">
<value>导出</value> <value>导出</value>
</data> </data>
@@ -40,6 +43,171 @@
<data name="Tab_Board" xml:space="preserve"> <data name="Tab_Board" xml:space="preserve">
<value>看板</value> <value>看板</value>
</data> </data>
<data name="Board_SearchPlaceholder" xml:space="preserve">
<value>搜索…</value>
</data>
<data name="Board_Search" xml:space="preserve">
<value>搜索</value>
</data>
<data name="Board_ClearSearch" xml:space="preserve">
<value>清空</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_SortCard" xml:space="preserve">
<value>卡片排序</value>
</data>
<data name="Board_SortColumn" xml:space="preserve">
<value>列排序</value>
</data>
<data name="Board_Display" xml:space="preserve">
<value>显示</value>
</data>
<data name="Board_NoColumns" xml:space="preserve">
<value>还没有列。</value>
</data>
<data name="Sort_IdAsc" xml:space="preserve">
<value>ID 正序</value>
</data>
<data name="Sort_IdDesc" xml:space="preserve">
<value>ID 倒序</value>
</data>
<data name="Sort_CreatedAsc" xml:space="preserve">
<value>创建时间正序</value>
</data>
<data name="Sort_CreatedDesc" xml:space="preserve">
<value>创建时间倒序</value>
</data>
<data name="Sort_UpdatedAsc" xml:space="preserve">
<value>修改时间正序</value>
</data>
<data name="Sort_UpdatedDesc" xml:space="preserve">
<value>修改时间倒序</value>
</data>
<data name="Sort_TitleAsc" xml:space="preserve">
<value>标题正序</value>
</data>
<data name="Sort_TitleDesc" xml:space="preserve">
<value>标题倒序</value>
</data>
<data name="Column_AddCard" xml:space="preserve">
<value>新建卡片</value>
</data>
<data name="Column_Edit" xml:space="preserve">
<value>编辑</value>
</data>
<data name="Column_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Card_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Card_Move" xml:space="preserve">
<value>移动到列…</value>
</data>
<data name="ColumnDialog_AddTitle" xml:space="preserve">
<value>新增列</value>
</data>
<data name="ColumnDialog_EditTitle" xml:space="preserve">
<value>编辑列</value>
</data>
<data name="ColumnDialog_Title" xml:space="preserve">
<value>标题</value>
</data>
<data name="ColumnDialog_Description" xml:space="preserve">
<value>描述</value>
</data>
<data name="ColumnDialog_InvalidTitle" xml:space="preserve">
<value>标题为空或已被使用。</value>
</data>
<data name="CardDialog_AddTitle" xml:space="preserve">
<value>新增卡片</value>
</data>
<data name="CardDialog_EditTitle" xml:space="preserve">
<value>编辑卡片</value>
</data>
<data name="CardDialog_Title" xml:space="preserve">
<value>标题</value>
</data>
<data name="CardDialog_Content" xml:space="preserve">
<value>内容</value>
</data>
<data name="CardDialog_Tags" xml:space="preserve">
<value>标签</value>
</data>
<data name="CardDialog_AddTag" xml:space="preserve">
<value>添加标签</value>
</data>
<data name="CardDialog_Id" xml:space="preserve">
<value>编号:{0}</value>
</data>
<data name="CardDialog_Created" xml:space="preserve">
<value>创建:{0}</value>
</data>
<data name="CardDialog_Modified" xml:space="preserve">
<value>修改:{0}</value>
</data>
<data name="CardDialog_TimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="CardDialog_Error" xml:space="preserve">
<value>无法保存卡片。</value>
</data>
<data name="TagDialog_Title" xml:space="preserve">
<value>添加标签</value>
</data>
<data name="TagDialog_Target" xml:space="preserve">
<value>标签</value>
</data>
<data name="TagDialog_NoTargets" xml:space="preserve">
<value>没有可添加的标签。</value>
</data>
<data name="MoveDialog_Title" xml:space="preserve">
<value>移动卡片</value>
</data>
<data name="MoveDialog_Target" xml:space="preserve">
<value>目标列</value>
</data>
<data name="MoveDialog_NoTargets" xml:space="preserve">
<value>没有可移动到的目标列。</value>
</data>
<data name="WidthPreset_Narrow" xml:space="preserve">
<value>窄</value>
</data>
<data name="WidthPreset_Standard" xml:space="preserve">
<value>标准</value>
</data>
<data name="WidthPreset_Wide" xml:space="preserve">
<value>宽</value>
</data>
<data name="WidthPreset_Custom" xml:space="preserve">
<value>自定义</value>
</data>
<data name="WidthDialog_Pixels" xml:space="preserve">
<value>像素(160–720)</value>
</data>
<data name="Confirm_DeleteColumn_Title" xml:space="preserve">
<value>删除列</value>
</data>
<data name="Confirm_DeleteColumn_Message" xml:space="preserve">
<value>删除列“{0}”及其 {1} 张卡片?</value>
</data>
<data name="Confirm_DeleteCard_Title" xml:space="preserve">
<value>删除卡片</value>
</data>
<data name="Confirm_DeleteCard_Message" xml:space="preserve">
<value>删除卡片 #{0}?</value>
</data>
<data name="Confirm_Discard_Title" xml:space="preserve">
<value>放弃修改</value>
</data>
<data name="Confirm_Discard_Message" xml:space="preserve">
<value>放弃未保存的修改?</value>
</data>
<data name="Error_Argument_Title" xml:space="preserve"> <data name="Error_Argument_Title" xml:space="preserve">
<value>参数错误</value> <value>参数错误</value>
</data> </data>
@@ -130,12 +298,69 @@
<data name="PresetColumn_Todo" xml:space="preserve"> <data name="PresetColumn_Todo" xml:space="preserve">
<value>待办</value> <value>待办</value>
</data> </data>
<data name="PresetColumn_Todo_Description" xml:space="preserve">
<value>已计划但尚未开始的任务。</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve"> <data name="PresetColumn_InProgress" xml:space="preserve">
<value>进行中</value> <value>进行中</value>
</data> </data>
<data name="PresetColumn_InProgress_Description" xml:space="preserve">
<value>正在处理中的任务。</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve"> <data name="PresetColumn_Done" xml:space="preserve">
<value>已完成</value> <value>已完成</value>
</data> </data>
<data name="PresetColumn_Done_Description" xml:space="preserve">
<value>已完成的任务。</value>
</data>
<data name="PresetTag_Bug_Name" xml:space="preserve">
<value>缺陷</value>
</data>
<data name="PresetTag_Bug_Description" xml:space="preserve">
<value>功能没有正常工作</value>
</data>
<data name="PresetTag_Documentation_Name" xml:space="preserve">
<value>文档</value>
</data>
<data name="PresetTag_Documentation_Description" xml:space="preserve">
<value>文档的改进或补充</value>
</data>
<data name="PresetTag_Duplicate_Name" xml:space="preserve">
<value>重复</value>
</data>
<data name="PresetTag_Duplicate_Description" xml:space="preserve">
<value>该问题或请求已存在</value>
</data>
<data name="PresetTag_Enhancement_Name" xml:space="preserve">
<value>增强</value>
</data>
<data name="PresetTag_Enhancement_Description" xml:space="preserve">
<value>新功能或请求</value>
</data>
<data name="PresetTag_HelpWanted_Name" xml:space="preserve">
<value>需要帮助</value>
</data>
<data name="PresetTag_HelpWanted_Description" xml:space="preserve">
<value>需要额外关注</value>
</data>
<data name="PresetTag_Invalid_Name" xml:space="preserve">
<value>无效</value>
</data>
<data name="PresetTag_Invalid_Description" xml:space="preserve">
<value>似乎不正确</value>
</data>
<data name="PresetTag_Question_Name" xml:space="preserve">
<value>疑问</value>
</data>
<data name="PresetTag_Question_Description" xml:space="preserve">
<value>需要进一步信息</value>
</data>
<data name="PresetTag_Wontfix_Name" xml:space="preserve">
<value>不予处理</value>
</data>
<data name="PresetTag_Wontfix_Description" xml:space="preserve">
<value>不会被处理</value>
</data>
<data name="Export_FileNamePattern" xml:space="preserve"> <data name="Export_FileNamePattern" xml:space="preserve">
<value>{0}-导出-{1:yyyyMMdd-HHmmss}.md</value> <value>{0}-导出-{1:yyyyMMdd-HHmmss}.md</value>
</data> </data>
+59 -36
View File
@@ -4,26 +4,42 @@ using Newtonsoft.Json;
namespace YKanBan.Storage.AppData; namespace YKanBan.Storage.AppData;
/// <summary> /// <summary>
/// Card ordering options. Every order falls back to id ascending as the final /// Display ordering options, used for both cards and columns. Except for the id
/// tie-breaker. /// order, every option falls back to id ascending as the final tie-breaker.
/// </summary> /// </summary>
public enum CardSortOption public enum SortOption
{ {
/// <summary>Primary key ascending (default).</summary> /// <summary>Primary key ascending.</summary>
[EnumMember(Value = "id")] [EnumMember(Value = "id-asc")]
Id, IdAscending,
/// <summary>Creation time, oldest first.</summary> /// <summary>Primary key descending.</summary>
[EnumMember(Value = "created-at")] [EnumMember(Value = "id-desc")]
CreatedAt, IdDescending,
/// <summary>Last modification time, most recently modified first.</summary> /// <summary>Creation time ascending.</summary>
[EnumMember(Value = "updated-at")] [EnumMember(Value = "created-asc")]
UpdatedAt, CreatedAscending,
/// <summary>Title by code points (BINARY collation); empty titles sort first.</summary> /// <summary>Creation time descending.</summary>
[EnumMember(Value = "title")] [EnumMember(Value = "created-desc")]
Title, CreatedDescending,
/// <summary>Last modification time ascending.</summary>
[EnumMember(Value = "updated-asc")]
UpdatedAscending,
/// <summary>Last modification time descending.</summary>
[EnumMember(Value = "updated-desc")]
UpdatedDescending,
/// <summary>Title by code points (BINARY collation) ascending.</summary>
[EnumMember(Value = "title-asc")]
TitleAscending,
/// <summary>Title by code points (BINARY collation) descending.</summary>
[EnumMember(Value = "title-desc")]
TitleDescending,
} }
/// <summary> /// <summary>
@@ -66,18 +82,6 @@ public enum ThemeOption
FollowSystem, FollowSystem,
} }
/// <summary>
/// Card ordering settings. The chosen order is global, not per workspace.
/// </summary>
public sealed class SortSettings
{
/// <summary>
/// Gets or sets the order used by the board.
/// </summary>
[JsonProperty("card")]
public CardSortOption Card { get; set; } = CardSortOption.Id;
}
/// <summary> /// <summary>
/// Board column width settings, applied globally. /// Board column width settings, applied globally.
/// </summary> /// </summary>
@@ -97,6 +101,31 @@ public sealed class ColumnWidthSettings
public int CustomPixels { get; set; } = 320; public int CustomPixels { get; set; } = 320;
} }
/// <summary>
/// Everything that controls how the board is displayed: the column width and
/// the card and column ordering.
/// </summary>
public sealed class DisplaySettings
{
/// <summary>
/// Gets or sets the board column width settings.
/// </summary>
[JsonProperty("column-width")]
public ColumnWidthSettings ColumnWidth { get; set; } = new();
/// <summary>
/// Gets or sets the order of cards within their column.
/// </summary>
[JsonProperty("card-sort")]
public SortOption CardSort { get; set; } = SortOption.IdAscending;
/// <summary>
/// Gets or sets the left-to-right order of the columns.
/// </summary>
[JsonProperty("column-sort")]
public SortOption ColumnSort { get; set; } = SortOption.IdAscending;
}
/// <summary> /// <summary>
/// Confirmation-dialog toggles for the destructive actions. /// Confirmation-dialog toggles for the destructive actions.
/// </summary> /// </summary>
@@ -164,16 +193,10 @@ public sealed class AppConfig
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem; public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
/// <summary> /// <summary>
/// Gets or sets the card ordering settings. /// Gets or sets the board display settings (width and ordering).
/// </summary> /// </summary>
[JsonProperty("sort")] [JsonProperty("display")]
public SortSettings Sort { get; set; } = new(); public DisplaySettings Display { get; set; } = new();
/// <summary>
/// Gets or sets the board column width settings.
/// </summary>
[JsonProperty("column-width")]
public ColumnWidthSettings ColumnWidth { get; set; } = new();
/// <summary> /// <summary>
/// Gets or sets the confirmation-dialog settings. /// Gets or sets the confirmation-dialog settings.
+1 -2
View File
@@ -56,8 +56,7 @@ public sealed class AppConfigStore
} }
// Older or hand-edited files may miss whole sections; each falls back to its defaults. // Older or hand-edited files may miss whole sections; each falls back to its defaults.
config.Sort ??= new SortSettings(); config.Display ??= new DisplaySettings();
config.ColumnWidth ??= new ColumnWidthSettings();
config.Confirmations ??= new ConfirmationSettings(); config.Confirmations ??= new ConfirmationSettings();
return config; return config;
} }
+54 -12
View File
@@ -3,16 +3,16 @@ using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace; namespace YKanBan.Storage.Workspace;
/// <summary> /// <summary>
/// Adds the initial workspace content. The three preset column titles are read /// Adds the initial workspace content. The preset column titles/descriptions and
/// straight from the ResX resources in the UI language active when they are /// tag names/descriptions are read straight from the ResX resources in the UI
/// added; they then become ordinary data that no longer follows language /// language active when they are added; they then become ordinary data that no
/// switches. /// longer follows language switches.
/// </summary> /// </summary>
public static class WorkspacePreset public static class WorkspacePreset
{ {
/// <summary> /// <summary>
/// Inserts the three preset columns (the current language's equivalents of /// Inserts the three preset columns (the current language's equivalents of
/// To Do / In Progress / Done) in a single transaction. /// To Do / In Progress / Done) with their descriptions in a single transaction.
/// </summary> /// </summary>
/// <param name="folderPath">The initialized workspace folder.</param> /// <param name="folderPath">The initialized workspace folder.</param>
/// <exception cref="SqliteException">The workspace database does not exist or a title collides with an existing column.</exception> /// <exception cref="SqliteException">The workspace database does not exist or a title collides with an existing column.</exception>
@@ -23,28 +23,70 @@ public static class WorkspacePreset
long now = UnixTime.Now; long now = UnixTime.Now;
// Preset titles are data at creation time: whatever the current language says gets stored. // Preset titles and descriptions are data at creation time: whatever the current language says gets stored.
string[] titles = (string Title, string Description)[] columns =
[ [
Resources.PresetColumn_Todo, (Resources.PresetColumn_Todo, Resources.PresetColumn_Todo_Description),
Resources.PresetColumn_InProgress, (Resources.PresetColumn_InProgress, Resources.PresetColumn_InProgress_Description),
Resources.PresetColumn_Done, (Resources.PresetColumn_Done, Resources.PresetColumn_Done_Description),
]; ];
// Insert all three atomically so a partially populated workspace can never exist. // Insert all three atomically so a partially populated workspace can never exist.
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
foreach (string title in titles) foreach ((string title, string description) in columns)
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.Transaction = transaction; command.Transaction = transaction;
command.CommandText = """ command.CommandText = """
INSERT INTO columns (title, description, created_at, updated_at) INSERT INTO columns (title, description, created_at, updated_at)
VALUES ($title, '', $now, $now); VALUES ($title, $description, $now, $now);
"""; """;
command.Parameters.AddWithValue("$title", title); command.Parameters.AddWithValue("$title", title);
command.Parameters.AddWithValue("$description", description);
command.Parameters.AddWithValue("$now", now); command.Parameters.AddWithValue("$now", now);
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
transaction.Commit(); transaction.Commit();
} }
/// <summary>
/// Inserts the preset tag set (names, colors and descriptions in the current
/// language) in a single transaction.
/// </summary>
/// <param name="folderPath">The initialized workspace folder.</param>
/// <exception cref="SqliteException">The workspace database does not exist or a tag name collides.</exception>
public static void AddPresetTags(string folderPath)
{
using SqliteConnection connection = SqliteDatabase.Open(
WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
// Colors are stored in the canonical lowercase #rrggbb form.
(string Name, string Color, string Description)[] tags =
[
(Resources.PresetTag_Bug_Name, "#d73a4a", Resources.PresetTag_Bug_Description),
(Resources.PresetTag_Documentation_Name, "#0075ca", Resources.PresetTag_Documentation_Description),
(Resources.PresetTag_Duplicate_Name, "#cfd3d7", Resources.PresetTag_Duplicate_Description),
(Resources.PresetTag_Enhancement_Name, "#a2eeef", Resources.PresetTag_Enhancement_Description),
(Resources.PresetTag_HelpWanted_Name, "#008672", Resources.PresetTag_HelpWanted_Description),
(Resources.PresetTag_Invalid_Name, "#e4e669", Resources.PresetTag_Invalid_Description),
(Resources.PresetTag_Question_Name, "#d876e3", Resources.PresetTag_Question_Description),
(Resources.PresetTag_Wontfix_Name, "#ffffff", Resources.PresetTag_Wontfix_Description),
];
using var transaction = connection.BeginTransaction();
foreach ((string name, string color, string description) in tags)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO tags (name, color, description)
VALUES ($name, $color, $description);
""";
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$color", color);
command.Parameters.AddWithValue("$description", description);
command.ExecuteNonQuery();
}
transaction.Commit();
}
} }
+271
View File
@@ -0,0 +1,271 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Services;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// The board tab: a toolbar (search box, add-column and the Display button)
/// over horizontally arranged columns of vertically stacked cards. All
/// persistence goes through the workspace repository; the board reloads from it
/// after each change.
/// </summary>
public sealed partial class BoardViewModel : ViewModelBase
{
private readonly AppServices _services;
private readonly WorkspaceRepository _repository;
/// <summary>
/// Initializes the board, loading its current content.
/// </summary>
/// <param name="services">The process composition root (configuration access).</param>
/// <param name="session">The open workspace session.</param>
public BoardViewModel(AppServices services, WorkspaceSession session)
{
_services = services;
_repository = session.Repository;
Display = new DisplaySettingsViewModel(_services.Config, Reload);
Reload();
}
/// <summary>
/// Gets the workspace repository used for all board data access.
/// </summary>
public WorkspaceRepository Repository => _repository;
/// <summary>
/// Gets the live application configuration.
/// </summary>
public AppConfig Config => _services.Config;
/// <summary>
/// Gets the display options (column width, card sort, column sort).
/// </summary>
public DisplaySettingsViewModel Display { get; }
/// <summary>
/// Gets the columns shown by the board, in display order.
/// </summary>
public ObservableCollection<ColumnViewModel> Columns { get; } = [];
/// <summary>
/// Gets or sets the search box text. The board does not filter on change;
/// the search runs only when the search button is used.
/// </summary>
[ObservableProperty]
private string searchText = string.Empty;
/// <summary>
/// Gets the rendered column width in pixels, applied globally.
/// </summary>
[ObservableProperty]
private double columnWidth;
/// <summary>
/// Gets a value indicating whether the board has any columns.
/// </summary>
public bool HasColumns => Columns.Count > 0;
/// <summary>
/// Gets a value indicating whether the board is empty.
/// </summary>
public bool HasNoColumns => Columns.Count == 0;
#region Interactions
/// <summary>Gets the interaction that opens the new-column editor.</summary>
public Interaction<Unit> AddColumn { get; } = new();
/// <summary>Gets the interaction that opens the column editor.</summary>
public Interaction<ColumnViewModel> EditColumn { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a column.</summary>
public Interaction<ColumnViewModel> DeleteColumn { get; } = new();
/// <summary>Gets the interaction that opens the new-card editor.</summary>
public Interaction<ColumnViewModel> AddCard { get; } = new();
/// <summary>Gets the interaction that opens the card editor.</summary>
public Interaction<CardViewModel> EditCard { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a card.</summary>
public Interaction<CardViewModel> DeleteCard { get; } = new();
/// <summary>Gets the interaction that opens the move-card dialog.</summary>
public Interaction<CardViewModel> MoveCard { get; } = new();
/// <summary>Gets the interaction that runs the current search.</summary>
public Interaction<Unit> RunSearch { get; } = new();
#endregion
#region Commands
/// <summary>
/// Opens the new-column editor.
/// </summary>
[RelayCommand]
private Task AddNewColumnAsync() => AddColumn.Handle(Unit.Value);
/// <summary>
/// Runs the current search (wired up with the search feature).
/// </summary>
[RelayCommand]
private Task SearchAsync() => RunSearch.Handle(Unit.Value);
/// <summary>
/// Clears the search box.
/// </summary>
[RelayCommand]
private void ClearSearch() => SearchText = string.Empty;
#endregion
#region Requests from columns and cards
/// <summary>
/// Requests the new-card editor for the given column.
/// </summary>
/// <param name="column">The target column.</param>
/// <returns>A task completing after the editor closes.</returns>
public Task RequestAddCardAsync(ColumnViewModel column) => AddCard.Handle(column);
/// <summary>
/// Requests the editor for the given card.
/// </summary>
/// <param name="card">The card to edit.</param>
/// <returns>A task completing after the editor closes.</returns>
public Task RequestEditCardAsync(CardViewModel card) => EditCard.Handle(card);
/// <summary>
/// Requests deletion of the given card.
/// </summary>
/// <param name="card">The card to delete.</param>
/// <returns>A task completing after confirmation.</returns>
public Task RequestDeleteCardAsync(CardViewModel card) => DeleteCard.Handle(card);
/// <summary>
/// Requests moving the given card to another column.
/// </summary>
/// <param name="card">The card to move.</param>
/// <returns>A task completing after the move.</returns>
public Task RequestMoveCardAsync(CardViewModel card) => MoveCard.Handle(card);
/// <summary>
/// Requests the editor for the given column.
/// </summary>
/// <param name="column">The column to edit.</param>
/// <returns>A task completing after the editor closes.</returns>
public Task RequestEditColumnAsync(ColumnViewModel column) => EditColumn.Handle(column);
/// <summary>
/// Requests deletion of the given column.
/// </summary>
/// <param name="column">The column to delete.</param>
/// <returns>A task completing after confirmation.</returns>
public Task RequestDeleteColumnAsync(ColumnViewModel column) => DeleteColumn.Handle(column);
#endregion
#region Loading and ordering
/// <summary>
/// Reloads all columns and cards from the repository and rebuilds the board.
/// </summary>
public void Reload()
{
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
IReadOnlyList<CardModel> cards = _repository.GetCards();
// Group cards by column once so each column can be built independently.
Dictionary<long, List<CardModel>> cardsByColumn = cards
.GroupBy(card => card.ColumnId)
.ToDictionary(group => group.Key, group => group.ToList());
IEnumerable<ColumnModel> orderedColumns = SortColumns(columns, _services.Config.Display.ColumnSort);
Columns.Clear();
foreach (ColumnModel column in orderedColumns)
{
// Cards are sorted within their column by the selected card order.
IEnumerable<CardModel> columnCards = cardsByColumn.TryGetValue(column.Id, out List<CardModel>? list)
? SortCards(list, _services.Config.Display.CardSort)
: [];
Columns.Add(new ColumnViewModel(this, column, columnCards.Select(card => new CardViewModel(this, card))));
}
ApplyColumnWidth();
OnPropertyChanged(nameof(HasColumns));
OnPropertyChanged(nameof(HasNoColumns));
}
/// <summary>
/// Recomputes the global column width and pushes it to every column.
/// </summary>
public void ApplyColumnWidth()
{
ColumnWidth = ColumnWidthPixels(_services.Config.Display.ColumnWidth);
foreach (ColumnViewModel column in Columns)
{
column.Width = ColumnWidth;
}
}
/// <summary>
/// Resolves a column width setting to its pixel value.
/// </summary>
/// <param name="settings">The configured column width.</param>
/// <returns>The width in pixels.</returns>
public static double ColumnWidthPixels(ColumnWidthSettings settings) => settings.Preset switch
{
ColumnWidthPreset.Narrow => 240,
ColumnWidthPreset.Wide => 480,
ColumnWidthPreset.Custom => settings.CustomPixels,
_ => 320,
};
/// <summary>
/// Orders cards by the selected option, tie-breaking by id ascending.
/// </summary>
/// <param name="cards">The cards to order.</param>
/// <param name="option">The selected order.</param>
/// <returns>The ordered cards.</returns>
private static IEnumerable<CardModel> SortCards(IEnumerable<CardModel> cards, SortOption option) => option switch
{
SortOption.IdAscending => cards.OrderBy(card => card.Id),
SortOption.IdDescending => cards.OrderByDescending(card => card.Id),
SortOption.CreatedAscending => cards.OrderBy(card => card.CreatedAt).ThenBy(card => card.Id),
SortOption.CreatedDescending => cards.OrderByDescending(card => card.CreatedAt).ThenBy(card => card.Id),
SortOption.UpdatedAscending => cards.OrderBy(card => card.UpdatedAt).ThenBy(card => card.Id),
SortOption.UpdatedDescending => cards.OrderByDescending(card => card.UpdatedAt).ThenBy(card => card.Id),
SortOption.TitleAscending => cards.OrderBy(card => card.Title, StringComparer.Ordinal).ThenBy(card => card.Id),
SortOption.TitleDescending => cards.OrderByDescending(card => card.Title, StringComparer.Ordinal).ThenBy(card => card.Id),
_ => cards.OrderBy(card => card.Id),
};
/// <summary>
/// Orders columns by the selected option, tie-breaking by id ascending.
/// </summary>
/// <param name="columns">The columns to order.</param>
/// <param name="option">The selected order.</param>
/// <returns>The ordered columns.</returns>
private static IEnumerable<ColumnModel> SortColumns(IEnumerable<ColumnModel> columns, SortOption option) => option switch
{
SortOption.IdAscending => columns.OrderBy(column => column.Id),
SortOption.IdDescending => columns.OrderByDescending(column => column.Id),
SortOption.CreatedAscending => columns.OrderBy(column => column.CreatedAt).ThenBy(column => column.Id),
SortOption.CreatedDescending => columns.OrderByDescending(column => column.CreatedAt).ThenBy(column => column.Id),
SortOption.UpdatedAscending => columns.OrderBy(column => column.UpdatedAt).ThenBy(column => column.Id),
SortOption.UpdatedDescending => columns.OrderByDescending(column => column.UpdatedAt).ThenBy(column => column.Id),
SortOption.TitleAscending => columns.OrderBy(column => column.Title, StringComparer.Ordinal).ThenBy(column => column.Id),
SortOption.TitleDescending => columns.OrderByDescending(column => column.Title, StringComparer.Ordinal).ThenBy(column => column.Id),
_ => columns.OrderBy(column => column.Id),
};
#endregion
}
+105
View File
@@ -0,0 +1,105 @@
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// One card on the board. Clicking the card body opens the editor; the corner
/// menu offers delete and move. The shown text is the "#id title" heading, a
/// truncated content preview and the colored tag badges; timestamps are not
/// shown on the board.
/// </summary>
public sealed partial class CardViewModel : ViewModelBase
{
/// <summary>Maximum number of content characters shown in the card preview.</summary>
private const int PreviewLength = 200;
private readonly BoardViewModel _board;
/// <summary>
/// Initializes the card view from its model.
/// </summary>
/// <param name="board">The owning board view model.</param>
/// <param name="card">The card model to display.</param>
public CardViewModel(BoardViewModel board, CardModel card)
{
_board = board;
Model = card;
TagBadges = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList();
}
/// <summary>
/// Gets the underlying card model.
/// </summary>
public CardModel Model { get; }
/// <summary>
/// Gets the card id.
/// </summary>
public long Id => Model.Id;
/// <summary>
/// Gets the owning column id.
/// </summary>
public long ColumnId => Model.ColumnId;
/// <summary>
/// Gets the tag badges shown under the content.
/// </summary>
public IReadOnlyList<TagBadgeViewModel> TagBadges { get; }
/// <summary>
/// Gets the bold id marker, for example "#61".
/// </summary>
public string IdText => $"#{Model.Id}";
/// <summary>
/// Gets the card title, empty when the card has no title.
/// </summary>
public string TitleText => Model.Title;
/// <summary>
/// Gets a value indicating whether the card has a title to show.
/// </summary>
public bool HasTitle => Model.Title.Length > 0;
/// <summary>
/// Gets a value indicating whether the card has content to preview.
/// </summary>
public bool HasContent => Model.Content.Length > 0;
/// <summary>
/// Gets the content preview, collapsed to one line and truncated.
/// </summary>
public string PreviewContent
{
get
{
string singleLine = Model.Content.Replace("\r", " ").Replace("\n", " ");
return singleLine.Length <= PreviewLength ? singleLine : singleLine[..PreviewLength] + "…";
}
}
/// <summary>
/// Gets a value indicating whether the card has any tags.
/// </summary>
public bool HasTags => TagBadges.Count > 0;
/// <summary>
/// Opens the card editor.
/// </summary>
[RelayCommand]
private void Edit() => _ = _board.RequestEditCardAsync(this);
/// <summary>
/// Asks the board to delete this card.
/// </summary>
[RelayCommand]
private void Delete() => _ = _board.RequestDeleteCardAsync(this);
/// <summary>
/// Asks the board to move this card to another column.
/// </summary>
[RelayCommand]
private void Move() => _ = _board.RequestMoveCardAsync(this);
}
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// One board column: a header with its title, card count and actions, plus the
/// vertically stacked cards.
/// </summary>
public sealed partial class ColumnViewModel : ViewModelBase
{
private readonly BoardViewModel _board;
/// <summary>
/// Initializes the column view from its model and cards.
/// </summary>
/// <param name="board">The owning board view model.</param>
/// <param name="column">The column model.</param>
/// <param name="cards">The already sorted cards belonging to this column.</param>
public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable<CardViewModel> cards)
{
_board = board;
Model = column;
Cards = new ObservableCollection<CardViewModel>(cards);
width = board.ColumnWidth;
}
/// <summary>
/// Gets the underlying column model.
/// </summary>
public ColumnModel Model { get; }
/// <summary>
/// Gets the column id.
/// </summary>
public long Id => Model.Id;
/// <summary>
/// Gets the column title.
/// </summary>
public string Title => Model.Title;
/// <summary>
/// Gets the column description.
/// </summary>
public string Description => Model.Description;
/// <summary>
/// Gets the cards in this column, already in display order.
/// </summary>
public ObservableCollection<CardViewModel> Cards { get; }
/// <summary>
/// Gets the number of cards in this column.
/// </summary>
public int CardCount => Cards.Count;
/// <summary>
/// Gets the tooltip text: the column description when present, otherwise
/// <see langword="null"/> so no tooltip is shown.
/// </summary>
public string? ToolTipText => Model.Description.Length > 0 ? Model.Description : null;
/// <summary>
/// Gets or sets the rendered column width in pixels, applied globally.
/// </summary>
[ObservableProperty]
private double width;
/// <summary>
/// Opens the editor for a new card in this column.
/// </summary>
[RelayCommand]
private void AddCard() => _ = _board.RequestAddCardAsync(this);
/// <summary>
/// Opens the editor for this column.
/// </summary>
[RelayCommand]
private void Edit() => _ = _board.RequestEditColumnAsync(this);
/// <summary>
/// Asks the board to delete this column.
/// </summary>
[RelayCommand]
private void Delete() => _ = _board.RequestDeleteColumnAsync(this);
}
@@ -0,0 +1,170 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Storage.AppData;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// The board display options shown in the Display flyout: the global column
/// width and the separate card and column orderings. Every change writes to
/// the configuration and asks the board to refresh immediately.
/// </summary>
public sealed partial class DisplaySettingsViewModel : ViewModelBase
{
/// <summary>Smallest accepted manual width.</summary>
public const int MinimumPixels = 160;
/// <summary>Largest accepted manual width.</summary>
public const int MaximumPixels = 720;
private readonly AppConfig _config;
private readonly Action _refresh;
/// <summary>
/// Initializes the display options from the configuration.
/// </summary>
/// <param name="config">The live application configuration.</param>
/// <param name="refresh">Invoked after every change to refresh the board.</param>
public DisplaySettingsViewModel(AppConfig config, Action refresh)
{
_config = config;
_refresh = refresh;
Presets =
[
new OptionItem(ColumnWidthPreset.Narrow, Resources.WidthPreset_Narrow),
new OptionItem(ColumnWidthPreset.Standard, Resources.WidthPreset_Standard),
new OptionItem(ColumnWidthPreset.Wide, Resources.WidthPreset_Wide),
new OptionItem(ColumnWidthPreset.Custom, Resources.WidthPreset_Custom),
];
SortOptions = BuildSortOptions();
selectedPreset = Presets.FirstOrDefault(option => (ColumnWidthPreset)option.Value == config.Display.ColumnWidth.Preset)
?? Presets[1];
customPixels = config.Display.ColumnWidth.CustomPixels;
selectedCardSort = FindSort(SortOptions, config.Display.CardSort);
selectedColumnSort = FindSort(SortOptions, config.Display.ColumnSort);
}
/// <summary>
/// Gets the width preset choices.
/// </summary>
public IReadOnlyList<OptionItem> Presets { get; }
/// <summary>
/// Gets the ordering choices (shared by cards and columns).
/// </summary>
public IReadOnlyList<OptionItem> SortOptions { get; }
/// <summary>
/// Gets or sets the selected width preset.
/// </summary>
[ObservableProperty]
private OptionItem? selectedPreset;
/// <summary>
/// Gets or sets the manual pixel width used by the custom preset.
/// </summary>
[ObservableProperty]
private decimal? customPixels;
/// <summary>
/// Gets or sets the selected card order.
/// </summary>
[ObservableProperty]
private OptionItem? selectedCardSort;
/// <summary>
/// Gets or sets the selected column order.
/// </summary>
[ObservableProperty]
private OptionItem? selectedColumnSort;
/// <summary>
/// Gets a value indicating whether the custom preset is selected.
/// </summary>
public bool IsCustom => SelectedPreset is not null && (ColumnWidthPreset)SelectedPreset.Value == ColumnWidthPreset.Custom;
/// <summary>
/// Stores the width preset and refreshes the board.
/// </summary>
/// <param name="value">The new selection.</param>
partial void OnSelectedPresetChanged(OptionItem? value)
{
OnPropertyChanged(nameof(IsCustom));
if (value is not null)
{
_config.Display.ColumnWidth.Preset = (ColumnWidthPreset)value.Value;
_refresh();
}
}
/// <summary>
/// Stores the manual width and refreshes the board when the custom preset is active.
/// </summary>
/// <param name="value">The new pixel count.</param>
partial void OnCustomPixelsChanged(decimal? value)
{
if (value is null)
{
return;
}
int pixels = (int)value.Value;
_config.Display.ColumnWidth.CustomPixels = pixels;
if (IsCustom)
{
_refresh();
}
}
/// <summary>
/// Stores the card order and refreshes the board.
/// </summary>
/// <param name="value">The new selection.</param>
partial void OnSelectedCardSortChanged(OptionItem? value)
{
if (value is not null)
{
_config.Display.CardSort = (SortOption)value.Value;
_refresh();
}
}
/// <summary>
/// Stores the column order and refreshes the board.
/// </summary>
/// <param name="value">The new selection.</param>
partial void OnSelectedColumnSortChanged(OptionItem? value)
{
if (value is not null)
{
_config.Display.ColumnSort = (SortOption)value.Value;
_refresh();
}
}
/// <summary>
/// Builds the localized eight-way ordering option list.
/// </summary>
/// <returns>The ordering options.</returns>
private static IReadOnlyList<OptionItem> BuildSortOptions() =>
[
new OptionItem(SortOption.IdAscending, Resources.Sort_IdAsc),
new OptionItem(SortOption.IdDescending, Resources.Sort_IdDesc),
new OptionItem(SortOption.CreatedAscending, Resources.Sort_CreatedAsc),
new OptionItem(SortOption.CreatedDescending, Resources.Sort_CreatedDesc),
new OptionItem(SortOption.UpdatedAscending, Resources.Sort_UpdatedAsc),
new OptionItem(SortOption.UpdatedDescending, Resources.Sort_UpdatedDesc),
new OptionItem(SortOption.TitleAscending, Resources.Sort_TitleAsc),
new OptionItem(SortOption.TitleDescending, Resources.Sort_TitleDesc),
];
/// <summary>
/// Finds the option matching a stored sort value.
/// </summary>
/// <param name="options">The option list.</param>
/// <param name="value">The stored value.</param>
/// <returns>The matching option, or the first option.</returns>
private static OptionItem FindSort(IReadOnlyList<OptionItem> options, SortOption value) =>
options.FirstOrDefault(option => (SortOption)option.Value == value) ?? options[0];
}
@@ -0,0 +1,62 @@
using Avalonia.Media;
using YKanBan.Models;
namespace YKanBan.ViewModels.Board;
/// <summary>
/// A read-only colored tag badge shown on a card: the tag name over a
/// background taken directly from the tag color (user data, never themed) and
/// an automatically contrasting text color.
/// </summary>
public sealed class TagBadgeViewModel
{
/// <summary>
/// Initializes a badge from a tag.
/// </summary>
/// <param name="tag">The tag to render.</param>
public TagBadgeViewModel(TagModel tag)
{
Tag = tag;
Name = tag.Name;
Color background = Color.FromRgb(tag.Color.R, tag.Color.G, tag.Color.B);
Background = new SolidColorBrush(background);
Foreground = new SolidColorBrush(ContrastingTextColor(background));
}
/// <summary>
/// Gets the underlying tag model.
/// </summary>
public TagModel Tag { get; }
/// <summary>
/// Gets the tag name.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the tooltip text: the tag description when present, otherwise
/// <see langword="null"/> so no tooltip is shown.
/// </summary>
public string? ToolTipText => Tag.Description.Length > 0 ? Tag.Description : null;
/// <summary>
/// Gets the badge background brush.
/// </summary>
public IBrush Background { get; }
/// <summary>
/// Gets the badge text brush.
/// </summary>
public IBrush Foreground { get; }
/// <summary>
/// Picks black or white text for readability against the given background.
/// </summary>
/// <param name="background">The badge background color.</param>
/// <returns>The contrasting text color.</returns>
private static Color ContrastingTextColor(Color background)
{
double luminance = (0.299 * background.R + 0.587 * background.G + 0.114 * background.B) / 255.0;
return luminance > 0.6 ? Colors.Black : Colors.White;
}
}
@@ -0,0 +1,55 @@
using Avalonia.Media;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// One tag assigned to the card being edited, shown with a colored badge and a
/// remove action that only changes the in-memory assignment (the actual write
/// happens on confirm).
/// </summary>
public sealed partial class AssignedTagViewModel
{
private readonly CardEditorViewModel _editor;
private readonly TagBadgeViewModel _badge;
/// <summary>
/// Initializes the assigned-tag row.
/// </summary>
/// <param name="editor">The owning card editor.</param>
/// <param name="tag">The assigned tag.</param>
public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag)
{
_editor = editor;
Tag = tag;
_badge = new TagBadgeViewModel(tag);
}
/// <summary>
/// Gets the assigned tag model.
/// </summary>
public TagModel Tag { get; }
/// <summary>
/// Gets the tag name.
/// </summary>
public string Name => _badge.Name;
/// <summary>
/// Gets the badge background brush.
/// </summary>
public IBrush Background => _badge.Background;
/// <summary>
/// Gets the badge text brush.
/// </summary>
public IBrush Foreground => _badge.Foreground;
/// <summary>
/// Removes this tag from the in-memory assignment.
/// </summary>
[RelayCommand]
private void Remove() => _editor.RemoveTag(Tag);
}
@@ -0,0 +1,196 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The add/edit card dialog: title, content and the assigned-tag area, plus
/// read-only id and timestamps for an existing card. The confirm writes the
/// card and its tag assignment as one unit; a confirm with no effective change
/// writes nothing; cancelling never writes.
/// </summary>
public sealed partial class CardEditorViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly long _columnId;
private readonly CardModel? _existing;
/// <summary>
/// Initializes the dialog for a new or existing card.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="columnId">The owning column (used when adding).</param>
/// <param name="existing">The card to edit, or <see langword="null"/> to add one.</param>
public CardEditorViewModel(WorkspaceRepository repository, long columnId, CardModel? existing)
{
_repository = repository;
_columnId = columnId;
_existing = existing;
title = existing?.Title ?? string.Empty;
content = existing?.Content ?? string.Empty;
AssignedTags = new ObservableCollection<AssignedTagViewModel>(
(existing?.Tags ?? []).Select(tag => new AssignedTagViewModel(this, tag)));
if (existing is not null)
{
IdLine = string.Format(Culture, Resources.CardDialog_Id, $"#{existing.Id}");
CreatedLine = string.Format(Culture, Resources.CardDialog_Created, FormatTime(existing.CreatedAt));
ModifiedLine = string.Format(Culture, Resources.CardDialog_Modified, FormatTime(existing.UpdatedAt));
}
}
/// <summary>
/// Gets the interaction that opens the nested tag picker.
/// </summary>
public Interaction<Unit, TagModel?> ShowTagPicker { get; } = new();
/// <summary>
/// Gets the workspace repository (used by the tag picker).
/// </summary>
public WorkspaceRepository Repository => _repository;
/// <summary>
/// Gets a value indicating whether this dialog creates a new card.
/// </summary>
public bool IsNew => _existing is null;
/// <summary>
/// Gets the localized window title.
/// </summary>
public string WindowTitle => IsNew ? Resources.CardDialog_AddTitle : Resources.CardDialog_EditTitle;
/// <summary>
/// Gets the read-only id line, empty for a new card.
/// </summary>
public string IdLine { get; } = string.Empty;
/// <summary>
/// Gets the read-only created line, empty for a new card.
/// </summary>
public string CreatedLine { get; } = string.Empty;
/// <summary>
/// Gets the read-only modified line, empty for a new card.
/// </summary>
public string ModifiedLine { get; } = string.Empty;
/// <summary>
/// Gets the tags currently assigned to the card (in memory until confirmed).
/// </summary>
public ObservableCollection<AssignedTagViewModel> AssignedTags { get; }
/// <summary>
/// Gets or sets the card title.
/// </summary>
[ObservableProperty]
private string title;
/// <summary>
/// Gets or sets the card content.
/// </summary>
[ObservableProperty]
private string content;
/// <summary>
/// Gets or sets the inline error shown when the write fails.
/// </summary>
[ObservableProperty]
private string? errorMessage;
/// <summary>
/// Gets a value indicating whether there are unsaved effective changes.
/// </summary>
public bool HasChanges
{
get
{
if (_existing is null)
{
return Title.Length > 0 || Content.Length > 0 || AssignedTags.Count > 0;
}
bool tagsChanged = !AssignedTags.Select(row => row.Tag.Id).ToHashSet()
.SetEquals(_existing.Tags.Select(tag => tag.Id));
return _existing.Title != Title || _existing.Content != Content || tagsChanged;
}
}
/// <summary>
/// Opens the nested tag picker and adds the chosen tag when it is new to the card.
/// </summary>
[RelayCommand]
private async Task AddTagAsync()
{
TagModel? tag = await ShowTagPicker.Handle(Unit.Value);
if (tag is not null && AssignedTags.All(row => row.Tag.Id != tag.Id))
{
AssignedTags.Add(new AssignedTagViewModel(this, tag));
}
}
/// <summary>
/// Removes a tag from the in-memory assignment.
/// </summary>
/// <param name="tag">The tag to remove.</param>
public void RemoveTag(TagModel tag)
{
AssignedTagViewModel? row = AssignedTags.FirstOrDefault(item => item.Tag.Id == tag.Id);
if (row is not null)
{
AssignedTags.Remove(row);
}
}
/// <summary>
/// Writes the card and its tags when there is an effective change.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
public bool TryAccept()
{
ErrorMessage = null;
// A confirm with no effective change writes nothing and just closes.
if (!HasChanges)
{
return true;
}
try
{
IReadOnlyCollection<long> tagIds = AssignedTags.Select(row => row.Tag.Id).ToList();
if (_existing is null)
{
_repository.AddCard(_columnId, Title, Content, tagIds);
}
else
{
_repository.UpdateCard(_existing.Id, Title, Content, tagIds);
}
return true;
}
catch (SqliteException)
{
ErrorMessage = Resources.CardDialog_Error;
return false;
}
}
/// <summary>
/// Gets the culture used for formatted read-only lines.
/// </summary>
private static CultureInfo Culture => Resources.Culture ?? CultureInfo.CurrentCulture;
/// <summary>
/// Formats a stored timestamp as a local time using the UI language pattern.
/// </summary>
/// <param name="unixSeconds">The stored timestamp.</param>
/// <returns>The formatted local time.</returns>
private static string FormatTime(long unixSeconds) =>
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime().ToString(Resources.CardDialog_TimePattern, Culture);
}
@@ -0,0 +1,85 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The add/edit column dialog: a required title and an optional description.
/// The write happens when the dialog is accepted; a duplicate or empty title
/// keeps the dialog open with an inline error.
/// </summary>
public sealed partial class ColumnEditorViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly ColumnModel? _existing;
/// <summary>
/// Initializes the dialog for a new or existing column.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="existing">The column to edit, or <see langword="null"/> to add one.</param>
public ColumnEditorViewModel(WorkspaceRepository repository, ColumnModel? existing)
{
_repository = repository;
_existing = existing;
title = existing?.Title ?? string.Empty;
description = existing?.Description ?? string.Empty;
}
/// <summary>
/// Gets a value indicating whether this dialog creates a new column.
/// </summary>
public bool IsNew => _existing is null;
/// <summary>
/// Gets the localized window title.
/// </summary>
public string WindowTitle => IsNew ? Resources.ColumnDialog_AddTitle : Resources.ColumnDialog_EditTitle;
/// <summary>
/// Gets or sets the column title.
/// </summary>
[ObservableProperty]
private string title;
/// <summary>
/// Gets or sets the column description.
/// </summary>
[ObservableProperty]
private string description;
/// <summary>
/// Gets or sets the inline error shown when the title is rejected.
/// </summary>
[ObservableProperty]
private string? errorMessage;
/// <summary>
/// Writes the column when accepted.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
public bool TryAccept()
{
ErrorMessage = null;
try
{
if (_existing is null)
{
_repository.AddColumn(Title, Description);
}
else
{
_repository.UpdateColumn(_existing.Id, Title, Description);
}
return true;
}
catch (SqliteException)
{
ErrorMessage = Resources.ColumnDialog_InvalidTitle;
return false;
}
}
}
@@ -0,0 +1,26 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before deleting a card, naming the card id.
/// </summary>
public sealed class DeleteCardConfirmViewModel : ViewModelBase
{
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="cardId">The id of the card to delete.</param>
public DeleteCardConfirmViewModel(long cardId)
{
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Confirm_DeleteCard_Message,
cardId);
}
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,29 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before deleting a column, naming the column and how
/// many cards it will cascade-delete.
/// </summary>
public sealed class DeleteColumnConfirmViewModel : ViewModelBase
{
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="columnTitle">The title of the column to delete.</param>
/// <param name="cardCount">The number of cards in the column.</param>
public DeleteColumnConfirmViewModel(string columnTitle, int cardCount)
{
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Confirm_DeleteColumn_Message,
columnTitle,
cardCount);
}
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,12 @@
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before closing an editor with unsaved changes.
/// </summary>
public sealed class DiscardEditConfirmViewModel : ViewModelBase
{
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
public string Message => Resources.Confirm_Discard_Message;
}
@@ -0,0 +1,40 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Models;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The move-card dialog: choose one of the other columns as the destination.
/// </summary>
public sealed partial class MoveCardViewModel : ViewModelBase
{
/// <summary>
/// Initializes the dialog with the candidate destination columns.
/// </summary>
/// <param name="columns">All columns; the card's current column is excluded.</param>
/// <param name="currentColumnId">The card's current column id.</param>
public MoveCardViewModel(IReadOnlyList<ColumnModel> columns, long currentColumnId)
{
Targets = columns
.Where(column => column.Id != currentColumnId)
.Select(column => new OptionItem(column.Id, column.Title))
.ToList();
selectedTarget = Targets.FirstOrDefault();
}
/// <summary>
/// Gets the candidate destination columns.
/// </summary>
public IReadOnlyList<OptionItem> Targets { get; }
/// <summary>
/// Gets or sets the selected destination column.
/// </summary>
[ObservableProperty]
private OptionItem? selectedTarget;
/// <summary>
/// Gets a value indicating whether there is anywhere to move the card.
/// </summary>
public bool CanMove => Targets.Count > 0;
}
@@ -0,0 +1,43 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The nested tag picker opened from the card editor: choose one tag that is
/// not already assigned to the card. When no candidate remains the picker
/// shows a message and cannot be confirmed.
/// </summary>
public sealed partial class TagPickerViewModel : ViewModelBase
{
/// <summary>
/// Initializes the picker with the tags not yet assigned to the card.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="assignedTagIds">The tag ids already assigned to the card.</param>
public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection<long> assignedTagIds)
{
Targets = repository.GetTags()
.Where(tag => !assignedTagIds.Contains(tag.Id))
.Select(tag => new TagBadgeViewModel(tag))
.ToList();
selectedTarget = Targets.FirstOrDefault();
}
/// <summary>
/// Gets the assignable tags.
/// </summary>
public IReadOnlyList<TagBadgeViewModel> Targets { get; }
/// <summary>
/// Gets or sets the selected tag.
/// </summary>
[ObservableProperty]
private TagBadgeViewModel? selectedTarget;
/// <summary>
/// Gets a value indicating whether there is a tag to add.
/// </summary>
public bool CanAdd => Targets.Count > 0;
}
+2 -1
View File
@@ -98,7 +98,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenWorkspace(string folderPath) private void OpenWorkspace(string folderPath)
{ {
WorkspaceSession session = _services.OpenWorkspace(folderPath); WorkspaceSession session = _services.OpenWorkspace(folderPath);
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout); Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}"; Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
} }
@@ -118,6 +118,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{ {
WorkspaceInitializer.Initialize(page.FolderPath); WorkspaceInitializer.Initialize(page.FolderPath);
WorkspacePreset.AddPresetColumns(page.FolderPath); WorkspacePreset.AddPresetColumns(page.FolderPath);
WorkspacePreset.AddPresetTags(page.FolderPath);
OpenWorkspace(page.FolderPath); OpenWorkspace(page.FolderPath);
} }
catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException)
+11 -2
View File
@@ -1,12 +1,13 @@
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using YKanBan.Services;
using YKanBan.Storage.Workspace; using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels; namespace YKanBan.ViewModels;
/// <summary> /// <summary>
/// The normal workspace view: the top display area (name, path and the export, /// The normal workspace view: the top display area (name, path and the export,
/// settings and about buttons) plus the tags/board tabs. The tab contents are /// settings and about buttons) plus the board and tags tabs.
/// filled in by the board and tag features.
/// </summary> /// </summary>
public sealed partial class WorkspaceViewModel : ViewModelBase public sealed partial class WorkspaceViewModel : ViewModelBase
{ {
@@ -16,10 +17,12 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// <summary> /// <summary>
/// Initializes the view for an open workspace. /// Initializes the view for an open workspace.
/// </summary> /// </summary>
/// <param name="services">The process composition root.</param>
/// <param name="session">The open workspace session.</param> /// <param name="session">The open workspace session.</param>
/// <param name="showSettings">The interaction that opens the settings dialog.</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="showAbout">The interaction that opens the about dialog.</param>
public WorkspaceViewModel( public WorkspaceViewModel(
AppServices services,
WorkspaceSession session, WorkspaceSession session,
Interaction<Unit> showSettings, Interaction<Unit> showSettings,
Interaction<Unit> showAbout) Interaction<Unit> showAbout)
@@ -29,6 +32,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
WorkspacePath = session.FolderPath; WorkspacePath = session.FolderPath;
_showSettings = showSettings; _showSettings = showSettings;
_showAbout = showAbout; _showAbout = showAbout;
Board = new BoardViewModel(services, session);
} }
/// <summary> /// <summary>
@@ -46,6 +50,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// </summary> /// </summary>
public string WorkspacePath { get; } public string WorkspacePath { get; }
/// <summary>
/// Gets the board tab view model.
/// </summary>
public BoardViewModel Board { get; }
/// <summary> /// <summary>
/// Opens the settings dialog through the registered interaction. /// Opens the settings dialog through the registered interaction.
/// </summary> /// </summary>
+114
View File
@@ -0,0 +1,114 @@
<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">
<Grid RowDefinitions="Auto,*">
<!-- Toolbar: search, add column and the combined display options. -->
<Grid Grid.Row="0" Margin="8" ColumnDefinitions="*,Auto,Auto,Auto,Auto">
<TextBox Grid.Column="0" Text="{Binding SearchText}" VerticalAlignment="Center"
Watermark="{x:Static lang:Resources.Board_SearchPlaceholder}"/>
<Button Grid.Column="1" Margin="4,0,0,0" Command="{Binding ClearSearchCommand}"
ToolTip.Tip="{x:Static lang:Resources.Board_ClearSearch}">
<icons:PackIconLucide Kind="X" Width="16" Height="16"/>
</Button>
<Button Grid.Column="2" Margin="4,0,0,0" Command="{Binding SearchCommand}"
ToolTip.Tip="{x:Static lang:Resources.Board_Search}">
<icons:PackIconLucide Kind="Search" Width="16" Height="16"/>
</Button>
<Button Grid.Column="3" Margin="12,0,0,0" Command="{Binding AddNewColumnCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
</StackPanel>
</Button>
<Button Grid.Column="4" Margin="4,0,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="SlidersHorizontal" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_Display}"/>
</StackPanel>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight">
<StackPanel Width="280" Spacing="12" Margin="4">
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/>
<ComboBox ItemsSource="{Binding Display.Presets}"
SelectedItem="{Binding Display.SelectedPreset}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8"
IsVisible="{Binding Display.IsCustom}">
<TextBlock Text="{x:Static lang:Resources.WidthDialog_Pixels}"
VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding Display.CustomPixels}"
Minimum="160" Maximum="720" Width="120"/>
</StackPanel>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Board_SortCard}"/>
<ComboBox ItemsSource="{Binding Display.SortOptions}"
SelectedItem="{Binding Display.SelectedCardSort}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Board_SortColumn}"/>
<ComboBox ItemsSource="{Binding Display.SortOptions}"
SelectedItem="{Binding Display.SelectedColumnSort}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
<!-- Board surface: columns horizontally, each scrolling its cards vertically. -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Columns}" Margin="8">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="12"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="1" Text="{x:Static lang:Resources.Board_NoColumns}"
IsVisible="{Binding HasNoColumns}"
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</UserControl>
+148
View File
@@ -0,0 +1,148 @@
using Avalonia.Controls;
using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs;
namespace YKanBan.Views.Board;
public partial class BoardView : UserControl
{
private bool _registered;
public BoardView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is BoardViewModel board && !_registered)
{
_registered = true;
board.AddColumn.RegisterHandler(_ => EditColumnAsync(board, null));
board.EditColumn.RegisterHandler(column => EditColumnAsync(board, column));
board.DeleteColumn.RegisterHandler(column => DeleteColumnAsync(board, column));
board.AddCard.RegisterHandler(column => EditCardAsync(board, column, null));
board.EditCard.RegisterHandler(card => EditCardAsync(board, null, card));
board.DeleteCard.RegisterHandler(card => DeleteCardAsync(board, card));
board.MoveCard.RegisterHandler(card => MoveCardAsync(board, card));
// The search button is present in the shell; searching arrives with the search feature.
board.RunSearch.RegisterHandler(_ => Task.CompletedTask);
}
}
/// <summary>Gets the window that owns this board, used as the dialog owner.</summary>
private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
/// <summary>Shows the add/edit column dialog and reloads on success.</summary>
private async Task EditColumnAsync(BoardViewModel board, ColumnViewModel? column)
{
if (OwningWindow is not Window owner)
{
return;
}
var dialog = new ColumnEditorDialog
{
DataContext = new ColumnEditorViewModel(board.Repository, column?.Model),
};
if (await dialog.ShowDialog<bool>(owner))
{
board.Reload();
}
}
/// <summary>Confirms and deletes a column, then reloads.</summary>
private async Task DeleteColumnAsync(BoardViewModel board, ColumnViewModel column)
{
if (OwningWindow is not Window owner)
{
return;
}
bool confirmed = true;
if (board.Config.Confirmations.DeleteColumn)
{
var dialog = new DeleteColumnConfirmDialog
{
DataContext = new DeleteColumnConfirmViewModel(column.Title, column.CardCount),
};
confirmed = await dialog.ShowDialog<bool>(owner);
}
if (!confirmed)
{
return;
}
board.Repository.DeleteColumn(column.Id);
board.Reload();
}
/// <summary>Shows the add/edit card dialog and reloads on success.</summary>
private async Task EditCardAsync(BoardViewModel board, ColumnViewModel? column, CardViewModel? card)
{
if (OwningWindow is not Window owner)
{
return;
}
long columnId = card?.ColumnId ?? column!.Id;
var dialog = new CardEditorDialog
{
DataContext = new CardEditorViewModel(board.Repository, columnId, card?.Model),
};
if (await dialog.ShowDialog<bool>(owner))
{
board.Reload();
}
}
/// <summary>Confirms and deletes a card, then reloads.</summary>
private async Task DeleteCardAsync(BoardViewModel board, CardViewModel card)
{
if (OwningWindow is not Window owner)
{
return;
}
bool confirmed = true;
if (board.Config.Confirmations.DeleteCard)
{
var dialog = new DeleteCardConfirmDialog
{
DataContext = new DeleteCardConfirmViewModel(card.Id),
};
confirmed = await dialog.ShowDialog<bool>(owner);
}
if (!confirmed)
{
return;
}
board.Repository.DeleteCard(card.Id);
board.Reload();
}
/// <summary>Shows the move-card dialog and reloads when a destination is chosen.</summary>
private async Task MoveCardAsync(BoardViewModel board, CardViewModel card)
{
if (OwningWindow is not Window owner)
{
return;
}
var dialog = new MoveCardDialog
{
DataContext = new MoveCardViewModel(board.Repository.GetColumns(), card.ColumnId),
};
if (await dialog.ShowDialog<long?>(owner) is long targetColumnId)
{
board.Repository.MoveCard(card.Id, targetColumnId);
board.Reload();
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Board"
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="320" d:DesignHeight="120"
x:Class="YKanBan.Views.Board.CardView"
x:DataType="vm:CardViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1" CornerRadius="6" Padding="8"
Cursor="Hand" Tapped="OnCardTapped">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Row 1: id, title and the corner menu. -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding IdText}" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding TitleText}" IsVisible="{Binding HasTitle}"
VerticalAlignment="Center" Margin="4,0"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding TitleText}"/>
<Button Grid.Column="2" Padding="4,2" VerticalAlignment="Center"
Tapped="OnMenuButtonTapped">
<icons:PackIconLucide Kind="EllipsisVertical" Width="16" Height="16"/>
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Card_Move}" Command="{Binding MoveCommand}"/>
<MenuItem Header="{x:Static lang:Resources.Card_Delete}" Command="{Binding DeleteCommand}"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<!-- Row 2: content preview. -->
<TextBlock Grid.Row="1" Text="{Binding PreviewContent}" IsVisible="{Binding HasContent}"
TextWrapping="Wrap" FontSize="12" Opacity="0.85" Margin="0,4,0,0"/>
<!-- Row 3: tag badges. -->
<ItemsControl Grid.Row="2" ItemsSource="{Binding TagBadges}" IsVisible="{Binding HasTags}"
Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagBadgeViewModel">
<Border Background="{Binding Background}" CornerRadius="8"
Padding="6,2" Margin="0,0,4,4"
ToolTip.Tip="{Binding ToolTipText}">
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}" FontSize="11"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Border>
</UserControl>
+25
View File
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Input;
using YKanBan.ViewModels.Board;
namespace YKanBan.Views.Board;
public partial class CardView : UserControl
{
public CardView()
{
InitializeComponent();
}
/// <summary>Clicking the card body opens the card editor.</summary>
private void OnCardTapped(object? sender, TappedEventArgs e)
{
if (DataContext is CardViewModel card)
{
card.EditCommand.Execute(null);
}
}
/// <summary>Stops the corner menu button's tap from bubbling to the card body.</summary>
private void OnMenuButtonTapped(object? sender, TappedEventArgs e) => e.Handled = true;
}
+55
View File
@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Board"
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="320" d:DesignHeight="600"
x:Class="YKanBan.Views.Board.ColumnView"
x:DataType="vm:ColumnViewModel">
<Border Width="{Binding Width}" MinWidth="160" MaxWidth="720"
Background="{DynamicResource BoardColumnBackgroundBrush}"
BorderBrush="{DynamicResource BoardColumnBorderBrush}"
BorderThickness="1" CornerRadius="8" VerticalAlignment="Stretch">
<Grid RowDefinitions="Auto,*" Margin="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding ToolTipText}"/>
<TextBlock Text="{Binding CardCount}" Opacity="0.6" VerticalAlignment="Center"/>
</StackPanel>
<Button Grid.Column="1" Margin="4,0,0,0" Padding="4,2"
Command="{Binding AddCardCommand}"
ToolTip.Tip="{x:Static lang:Resources.Column_AddCard}">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
</Button>
<Button Grid.Column="2" Margin="2,0,0,0" Padding="4,2">
<icons:PackIconLucide Kind="EllipsisVertical" Width="16" Height="16"/>
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Column_Edit}" Command="{Binding EditCommand}"/>
<MenuItem Header="{x:Static lang:Resources.Column_Delete}" Command="{Binding DeleteCommand}"/>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="0,8,0,0">
<ItemsControl ItemsSource="{Binding Cards}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Board;
public partial class ColumnView : UserControl
{
public ColumnView()
{
InitializeComponent();
}
}
@@ -0,0 +1,74 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
xmlns:conv="using:Avalonia.Data.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="560"
x:Class="YKanBan.Views.Dialogs.CardEditorDialog"
x:DataType="vm:CardEditorViewModel"
Title="{Binding WindowTitle}"
Width="520" Height="560" MinWidth="520" MinHeight="560" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6">
<TextBlock Text="{x:Static lang:Resources.CardDialog_Title}"/>
<TextBox Text="{Binding Title}"/>
<TextBlock Text="{x:Static lang:Resources.CardDialog_Content}" Margin="0,6,0,0"/>
<TextBox Text="{Binding Content}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Height="160"/>
<TextBlock Text="{x:Static lang:Resources.CardDialog_Tags}" Margin="0,6,0,0"/>
<ItemsControl ItemsSource="{Binding AssignedTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AssignedTagViewModel">
<Border Background="{Binding Background}" CornerRadius="8"
Padding="6,2" Margin="0,0,4,4">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding Name}" Foreground="{Binding Foreground}"
FontSize="11" VerticalAlignment="Center"/>
<Button Command="{Binding RemoveCommand}" Padding="0"
Background="Transparent" BorderThickness="0">
<icons:PackIconLucide Kind="X" Width="10" Height="10"
Foreground="{Binding Foreground}"/>
</Button>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="{x:Static lang:Resources.CardDialog_AddTag}"
Command="{Binding AddTagCommand}" HorizontalAlignment="Left"/>
<StackPanel Spacing="2" IsVisible="{Binding !IsNew}" Opacity="0.7" Margin="0,8,0,0">
<TextBlock Text="{Binding IdLine}"/>
<TextBlock Text="{Binding CreatedLine}"/>
<TextBlock Text="{Binding ModifiedLine}"/>
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}"
IsVisible="{Binding ErrorMessage, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" MinWidth="90"
IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,99 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using YKanBan.Models;
using YKanBan.ViewModels;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class CardEditorDialog : Window
{
private bool _allowClose;
public CardEditorDialog()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is CardEditorViewModel viewModel)
{
viewModel.ShowTagPicker.RegisterHandler(ShowTagPickerAsync);
}
}
/// <summary>Shows the nested tag picker, owned by this dialog.</summary>
private async Task<TagModel?> ShowTagPickerAsync(Unit input)
{
if (DataContext is not CardEditorViewModel viewModel)
{
return null;
}
var dialog = new TagPickerDialog
{
DataContext = new TagPickerViewModel(
viewModel.Repository,
viewModel.AssignedTags.Select(row => row.Tag.Id).ToList()),
};
return await dialog.ShowDialog<TagModel?>(this);
}
/// <summary>Writes the card and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept())
{
_allowClose = true;
Close(true);
}
}
/// <summary>Requests a close, confirming the discard first when needed.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => _ = RequestCloseAsync();
/// <summary>Intercepts the window close button to run the discard confirmation.</summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
base.OnClosing(e);
if (_allowClose)
{
return;
}
if (HasUnsavedChanges && DiscardConfirmEnabled)
{
e.Cancel = true;
Dispatcher.UIThread.Post(() => _ = RequestCloseAsync());
}
else
{
_allowClose = true;
}
}
/// <summary>Confirms the discard and closes, or stays open when declined.</summary>
private async Task RequestCloseAsync()
{
if (HasUnsavedChanges && DiscardConfirmEnabled)
{
var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() };
if (!await dialog.ShowDialog<bool>(this))
{
return;
}
}
_allowClose = true;
Close(false);
}
/// <summary>Gets whether the editor has unsaved changes.</summary>
private bool HasUnsavedChanges => DataContext is CardEditorViewModel viewModel && viewModel.HasChanges;
/// <summary>Gets whether the discard confirmation setting is enabled.</summary>
private static bool DiscardConfirmEnabled => App.Services?.Config.Confirmations.DiscardEdit == true;
}
@@ -0,0 +1,36 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:conv="using:Avalonia.Data.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="300"
x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog"
x:DataType="vm:ColumnEditorViewModel"
Title="{Binding WindowTitle}"
Width="420" Height="300" MinWidth="420" MinHeight="300" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock Text="{x:Static lang:Resources.ColumnDialog_Title}"/>
<TextBox Text="{Binding Title}"/>
<TextBlock Text="{x:Static lang:Resources.ColumnDialog_Description}" Margin="0,6,0,0"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Height="80"/>
<TextBlock Text="{Binding ErrorMessage}"
IsVisible="{Binding ErrorMessage, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class ColumnEditorDialog : Window
{
public ColumnEditorDialog()
{
InitializeComponent();
}
/// <summary>Writes the column and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept())
{
Close(true);
}
}
/// <summary>Closes without writing.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.DeleteCardConfirmDialog"
x:DataType="vm:DeleteCardConfirmViewModel"
Title="{x:Static lang:Resources.Confirm_DeleteCard_Title}"
Width="420" Height="200" MinWidth="420" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class DeleteCardConfirmDialog : Window
{
public DeleteCardConfirmDialog()
{
InitializeComponent();
}
/// <summary>Confirms the deletion.</summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.DeleteColumnConfirmDialog"
x:DataType="vm:DeleteColumnConfirmViewModel"
Title="{x:Static lang:Resources.Confirm_DeleteColumn_Title}"
Width="420" Height="200" MinWidth="420" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class DeleteColumnConfirmDialog : Window
{
public DeleteColumnConfirmDialog()
{
InitializeComponent();
}
/// <summary>Confirms the deletion.</summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.DiscardEditConfirmDialog"
x:DataType="vm:DiscardEditConfirmViewModel"
Title="{x:Static lang:Resources.Confirm_Discard_Title}"
Width="420" Height="200" MinWidth="420" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class DiscardEditConfirmDialog : Window
{
public DiscardEditConfirmDialog()
{
InitializeComponent();
}
/// <summary>Confirms discarding the changes.</summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Keeps editing.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:vmBase="using:YKanBan.ViewModels"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="380" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.MoveCardDialog"
x:DataType="vm:MoveCardViewModel"
Title="{x:Static lang:Resources.MoveDialog_Title}"
Width="380" Height="200" MinWidth="380" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock Text="{x:Static lang:Resources.MoveDialog_Target}"/>
<ComboBox ItemsSource="{Binding Targets}" SelectedItem="{Binding SelectedTarget}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{x:Static lang:Resources.MoveDialog_NoTargets}"
IsVisible="{Binding !CanMove}" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick"
MinWidth="90" IsDefault="True" IsEnabled="{Binding CanMove}"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class MoveCardDialog : Window
{
public MoveCardDialog()
{
InitializeComponent();
}
/// <summary>Closes with the selected destination column id.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null)
{
Close((long)viewModel.SelectedTarget.Value);
}
}
/// <summary>Closes without moving.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -0,0 +1,42 @@
<Window 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:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="380" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.TagPickerDialog"
x:DataType="vm:TagPickerViewModel"
Title="{x:Static lang:Resources.TagDialog_Title}"
Width="380" Height="200" MinWidth="380" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock Text="{x:Static lang:Resources.TagDialog_Target}"/>
<ComboBox ItemsSource="{Binding Targets}" SelectedItem="{Binding SelectedTarget}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="board:TagBadgeViewModel">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Width="14" Height="14" CornerRadius="3" Background="{Binding Background}"/>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{x:Static lang:Resources.TagDialog_NoTargets}"
IsVisible="{Binding !CanAdd}" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick"
MinWidth="90" IsDefault="True" IsEnabled="{Binding CanAdd}"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class TagPickerDialog : Window
{
public TagPickerDialog()
{
InitializeComponent();
}
/// <summary>Closes with the selected tag.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge)
{
Close(badge.Tag);
}
}
/// <summary>Closes without picking a tag.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
}
+4 -1
View File
@@ -3,6 +3,7 @@
xmlns:vm="using:YKanBan.ViewModels" xmlns:vm="using:YKanBan.ViewModels"
xmlns:lang="using:YKanBan" xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide" xmlns:icons="using:IconPacks.Avalonia.Lucide"
xmlns:board="using:YKanBan.Views.Board"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
@@ -50,7 +51,9 @@
<!-- Tags and board tabs; their contents arrive with the corresponding features. --> <!-- Tags and board tabs; their contents arrive with the corresponding features. -->
<TabControl Grid.Row="1" Margin="8"> <TabControl Grid.Row="1" Margin="8">
<TabItem Header="{x:Static lang:Resources.Tab_Board}"/> <TabItem Header="{x:Static lang:Resources.Tab_Board}">
<board:BoardView DataContext="{Binding Board}"/>
</TabItem>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/> <TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
</TabControl> </TabControl>