diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
index 55b1915..fe45e21 100644
--- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
+++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
@@ -21,9 +21,10 @@ public class AppConfigStoreTests
Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
Assert.AreEqual(AppConfig.DefaultLanguage, config.Language);
Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
- Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
- Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
- Assert.AreEqual(320, config.ColumnWidth.CustomPixels);
+ Assert.AreEqual(SortOption.IdAscending, config.Display.CardSort);
+ Assert.AreEqual(SortOption.IdAscending, config.Display.ColumnSort);
+ Assert.AreEqual(ColumnWidthPreset.Standard, config.Display.ColumnWidth.Preset);
+ Assert.AreEqual(320, config.Display.ColumnWidth.CustomPixels);
Assert.IsTrue(config.Confirmations.DeleteCard);
Assert.IsTrue(config.Confirmations.DeleteColumn);
Assert.IsTrue(config.Confirmations.DeleteTag);
@@ -66,8 +67,11 @@ public class AppConfigStoreTests
"version": {{AppConfig.CurrentFormatVersion}},
"language": "zh-Hans",
"theme": "dark",
- "sort": { "card": "title" },
- "column-width": { "preset": "custom", "custom-pixels": 400 },
+ "display": {
+ "column-width": { "preset": "custom", "custom-pixels": 400 },
+ "card-sort": "title-asc",
+ "column-sort": "id-desc"
+ },
"confirmations": {
"delete-card": false,
"delete-column": false,
@@ -82,9 +86,10 @@ public class AppConfigStoreTests
Assert.AreEqual("zh-Hans", config.Language);
Assert.AreEqual(ThemeOption.Dark, config.Theme);
- Assert.AreEqual(CardSortOption.Title, config.Sort.Card);
- Assert.AreEqual(ColumnWidthPreset.Custom, config.ColumnWidth.Preset);
- Assert.AreEqual(400, config.ColumnWidth.CustomPixels);
+ Assert.AreEqual(SortOption.TitleAscending, config.Display.CardSort);
+ Assert.AreEqual(SortOption.IdDescending, config.Display.ColumnSort);
+ Assert.AreEqual(ColumnWidthPreset.Custom, config.Display.ColumnWidth.Preset);
+ Assert.AreEqual(400, config.Display.ColumnWidth.CustomPixels);
Assert.IsFalse(config.Confirmations.DeleteCard);
Assert.IsFalse(config.Confirmations.DeleteColumn);
Assert.IsFalse(config.Confirmations.DeleteTag);
@@ -101,10 +106,10 @@ public class AppConfigStoreTests
AppConfig config = store.Load();
- Assert.IsNotNull(config.Sort);
- Assert.IsNotNull(config.ColumnWidth);
+ Assert.IsNotNull(config.Display);
+ Assert.IsNotNull(config.Display.ColumnWidth);
Assert.IsNotNull(config.Confirmations);
- Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
+ Assert.AreEqual(ColumnWidthPreset.Standard, config.Display.ColumnWidth.Preset);
}
[TestMethod]
@@ -117,8 +122,12 @@ public class AppConfigStoreTests
var config = new AppConfig
{
Theme = ThemeOption.FollowSystem,
- Sort = new SortSettings { Card = CardSortOption.UpdatedAt },
- ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 },
+ Display = new DisplaySettings
+ {
+ CardSort = SortOption.UpdatedDescending,
+ ColumnSort = SortOption.CreatedAscending,
+ ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 },
+ },
Confirmations = new ConfirmationSettings { DeleteColumn = false },
};
store.Save(config);
@@ -126,9 +135,12 @@ public class AppConfigStoreTests
string json = File.ReadAllText(path);
StringAssert.Contains(json, "\"delete-column\"");
StringAssert.Contains(json, "\"discard-edit\"");
+ StringAssert.Contains(json, "\"card-sort\"");
+ StringAssert.Contains(json, "\"column-sort\"");
StringAssert.Contains(json, "\"custom-pixels\"");
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.
Assert.IsFalse(json.Contains("deleteColumn", StringComparison.Ordinal));
Assert.IsFalse(json.Contains("CustomPixels", StringComparison.Ordinal));
@@ -145,8 +157,12 @@ public class AppConfigStoreTests
{
Language = "zh-Hans",
Theme = ThemeOption.Light,
- Sort = new SortSettings { Card = CardSortOption.CreatedAt },
- ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
+ Display = new DisplaySettings
+ {
+ CardSort = SortOption.CreatedDescending,
+ ColumnSort = SortOption.TitleAscending,
+ ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
+ },
Confirmations = new ConfirmationSettings { DiscardEdit = false },
};
store.Save(config);
@@ -154,8 +170,9 @@ public class AppConfigStoreTests
AppConfig reloaded = store.Load();
Assert.AreEqual(config.Language, reloaded.Language);
Assert.AreEqual(config.Theme, reloaded.Theme);
- Assert.AreEqual(config.Sort.Card, reloaded.Sort.Card);
- Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset);
+ Assert.AreEqual(config.Display.CardSort, reloaded.Display.CardSort);
+ 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);
}
}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
index 813ddc7..eff247b 100644
--- a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
+++ b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
@@ -6,34 +6,43 @@ using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
///
-/// Verifies that the preset column titles follow the UI language active when
-/// they are added and then live as ordinary data. These tests mutate the
-/// global , so they are marked do-not-parallelize.
+/// 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 global
+/// , so they are marked do-not-parallelize.
///
[TestClass]
[DoNotParallelize]
public class WorkspacePresetTests
{
[TestMethod]
- public void AddPresetColumnsStoresEnglishTitlesUnderEnglishCulture()
+ public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("en");
- string[] expected =
- [
- Resources.PresetColumn_Todo,
- Resources.PresetColumn_InProgress,
- Resources.PresetColumn_Done,
- ];
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(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
{
@@ -42,25 +51,29 @@ public class WorkspacePresetTests
}
[TestMethod]
- public void AddPresetColumnsStoresChineseTitlesUnderChineseCulture()
+ public void AddPresetColumnsStoresChineseTextUnderChineseCulture()
{
CultureInfo? original = Resources.Culture;
try
{
Resources.Culture = new CultureInfo("zh-Hans");
- string[] expected =
- [
- Resources.PresetColumn_Todo,
- Resources.PresetColumn_InProgress,
- Resources.PresetColumn_Done,
- ];
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(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
{
@@ -69,27 +82,74 @@ public class WorkspacePresetTests
}
[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;
try
{
Resources.Culture = new CultureInfo("en");
- string[] expected =
- [
- Resources.PresetColumn_Todo,
- Resources.PresetColumn_InProgress,
- Resources.PresetColumn_Done,
- ];
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
WorkspacePreset.AddPresetColumns(directory.FullPath);
+ WorkspacePreset.AddPresetTags(directory.FullPath);
// Switching the language after adding the presets must not rewrite stored data.
Resources.Culture = new CultureInfo("zh-Hans");
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
{
diff --git a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
index f7ad51f..72db4dc 100644
--- a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
+++ b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
@@ -87,6 +87,44 @@ public static class SqliteTestHelper
return titles;
}
+ ///
+ /// Returns the descriptions of all columns, ordered by id ascending.
+ ///
+ /// An open database connection.
+ /// The column descriptions in id order.
+ public static List 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();
+ while (reader.Read())
+ {
+ descriptions.Add(reader.GetString(0));
+ }
+ return descriptions;
+ }
+
+ ///
+ /// Returns all tags as name/color/description tuples, ordered by id ascending.
+ ///
+ /// An open database connection.
+ /// The tags in id order.
+ 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;
+ }
+
///
/// Opens a workspace database with the real workspace migration list.
///
diff --git a/YKanBan/App.axaml b/YKanBan/App.axaml
index 1f67adf..93c3bc9 100644
--- a/YKanBan/App.axaml
+++ b/YKanBan/App.axaml
@@ -26,10 +26,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
index 7150648..0ee6a7b 100644
--- a/YKanBan/Assets/Locales/Resources.resx
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -25,6 +25,9 @@
Cancel
+
+ Delete
+
Export
@@ -40,6 +43,171 @@
Board
+
+ Search…
+
+
+ Search
+
+
+ Clear
+
+
+ Add Column
+
+
+ Column Width
+
+
+ Card Sort
+
+
+ Column Sort
+
+
+ Display
+
+
+ No columns yet.
+
+
+ ID Ascending
+
+
+ ID Descending
+
+
+ Created Ascending
+
+
+ Created Descending
+
+
+ Modified Ascending
+
+
+ Modified Descending
+
+
+ Title Ascending
+
+
+ Title Descending
+
+
+ New Card
+
+
+ Edit
+
+
+ Delete
+
+
+ Delete
+
+
+ Move to Column…
+
+
+ New Column
+
+
+ Edit Column
+
+
+ Title
+
+
+ Description
+
+
+ The title is empty or already used.
+
+
+ New Card
+
+
+ Edit Card
+
+
+ Title
+
+
+ Content
+
+
+ Tags
+
+
+ Add Tag
+
+
+ ID: {0}
+
+
+ Created: {0}
+
+
+ Modified: {0}
+
+
+ yyyy-MM-dd HH:mm
+
+
+ Could not save the card.
+
+
+ Add Tag
+
+
+ Tag
+
+
+ There are no more tags to add.
+
+
+ Move Card
+
+
+ Target Column
+
+
+ There is nowhere to move this card.
+
+
+ Narrow
+
+
+ Standard
+
+
+ Wide
+
+
+ Custom
+
+
+ Pixels (160–720)
+
+
+ Delete Column
+
+
+ Delete column "{0}" and its {1} card(s)?
+
+
+ Delete Card
+
+
+ Delete card #{0}?
+
+
+ Discard Changes
+
+
+ Discard the unsaved changes?
+
Invalid arguments
@@ -62,7 +230,7 @@
The folder does not contain a .ykanban workspace: {0}
- Initialize workspace
+ Initialize Workspace
Workspace locked
@@ -95,7 +263,7 @@
Dark
- Follow system
+ Follow System
Confirmations
@@ -130,12 +298,69 @@
To Do
+
+ Tasks that are planned but not started.
+
In Progress
+
+ Tasks that are currently being worked on.
+
Done
+
+ Tasks that have been completed.
+
+
+ bug
+
+
+ Something isn't working
+
+
+ documentation
+
+
+ Improvements or additions to documentation
+
+
+ duplicate
+
+
+ This issue or pull request already exists
+
+
+ enhancement
+
+
+ New feature or request
+
+
+ help-wanted
+
+
+ Extra attention is needed
+
+
+ invalid
+
+
+ This doesn't seem right
+
+
+ question
+
+
+ Further information is requested
+
+
+ wontfix
+
+
+ This will not be worked on
+
{0}-export-{1:yyyyMMdd-HHmmss}.md
diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
index 1abc7d7..804e2bc 100644
--- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -25,6 +25,9 @@
取消
+
+ 删除
+
导出
@@ -40,6 +43,171 @@
看板
+
+ 搜索…
+
+
+ 搜索
+
+
+ 清空
+
+
+ 新增列
+
+
+ 列宽
+
+
+ 卡片排序
+
+
+ 列排序
+
+
+ 显示
+
+
+ 还没有列。
+
+
+ ID 正序
+
+
+ ID 倒序
+
+
+ 创建时间正序
+
+
+ 创建时间倒序
+
+
+ 修改时间正序
+
+
+ 修改时间倒序
+
+
+ 标题正序
+
+
+ 标题倒序
+
+
+ 新建卡片
+
+
+ 编辑
+
+
+ 删除
+
+
+ 删除
+
+
+ 移动到列…
+
+
+ 新增列
+
+
+ 编辑列
+
+
+ 标题
+
+
+ 描述
+
+
+ 标题为空或已被使用。
+
+
+ 新增卡片
+
+
+ 编辑卡片
+
+
+ 标题
+
+
+ 内容
+
+
+ 标签
+
+
+ 添加标签
+
+
+ 编号:{0}
+
+
+ 创建:{0}
+
+
+ 修改:{0}
+
+
+ yyyy年M月d日 HH:mm
+
+
+ 无法保存卡片。
+
+
+ 添加标签
+
+
+ 标签
+
+
+ 没有可添加的标签。
+
+
+ 移动卡片
+
+
+ 目标列
+
+
+ 没有可移动到的目标列。
+
+
+ 窄
+
+
+ 标准
+
+
+ 宽
+
+
+ 自定义
+
+
+ 像素(160–720)
+
+
+ 删除列
+
+
+ 删除列“{0}”及其 {1} 张卡片?
+
+
+ 删除卡片
+
+
+ 删除卡片 #{0}?
+
+
+ 放弃修改
+
+
+ 放弃未保存的修改?
+
参数错误
@@ -130,12 +298,69 @@
待办
+
+ 已计划但尚未开始的任务。
+
进行中
+
+ 正在处理中的任务。
+
已完成
+
+ 已完成的任务。
+
+
+ 缺陷
+
+
+ 功能没有正常工作
+
+
+ 文档
+
+
+ 文档的改进或补充
+
+
+ 重复
+
+
+ 该问题或请求已存在
+
+
+ 增强
+
+
+ 新功能或请求
+
+
+ 需要帮助
+
+
+ 需要额外关注
+
+
+ 无效
+
+
+ 似乎不正确
+
+
+ 疑问
+
+
+ 需要进一步信息
+
+
+ 不予处理
+
+
+ 不会被处理
+
{0}-导出-{1:yyyyMMdd-HHmmss}.md
diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs
index 4b1ed52..347360f 100644
--- a/YKanBan/Storage/AppData/AppConfig.cs
+++ b/YKanBan/Storage/AppData/AppConfig.cs
@@ -4,26 +4,42 @@ using Newtonsoft.Json;
namespace YKanBan.Storage.AppData;
///
-/// Card ordering options. Every order falls back to id ascending as the final
-/// tie-breaker.
+/// Display ordering options, used for both cards and columns. Except for the id
+/// order, every option falls back to id ascending as the final tie-breaker.
///
-public enum CardSortOption
+public enum SortOption
{
- /// Primary key ascending (default).
- [EnumMember(Value = "id")]
- Id,
+ /// Primary key ascending.
+ [EnumMember(Value = "id-asc")]
+ IdAscending,
- /// Creation time, oldest first.
- [EnumMember(Value = "created-at")]
- CreatedAt,
+ /// Primary key descending.
+ [EnumMember(Value = "id-desc")]
+ IdDescending,
- /// Last modification time, most recently modified first.
- [EnumMember(Value = "updated-at")]
- UpdatedAt,
+ /// Creation time ascending.
+ [EnumMember(Value = "created-asc")]
+ CreatedAscending,
- /// Title by code points (BINARY collation); empty titles sort first.
- [EnumMember(Value = "title")]
- Title,
+ /// Creation time descending.
+ [EnumMember(Value = "created-desc")]
+ CreatedDescending,
+
+ /// Last modification time ascending.
+ [EnumMember(Value = "updated-asc")]
+ UpdatedAscending,
+
+ /// Last modification time descending.
+ [EnumMember(Value = "updated-desc")]
+ UpdatedDescending,
+
+ /// Title by code points (BINARY collation) ascending.
+ [EnumMember(Value = "title-asc")]
+ TitleAscending,
+
+ /// Title by code points (BINARY collation) descending.
+ [EnumMember(Value = "title-desc")]
+ TitleDescending,
}
///
@@ -66,18 +82,6 @@ public enum ThemeOption
FollowSystem,
}
-///
-/// Card ordering settings. The chosen order is global, not per workspace.
-///
-public sealed class SortSettings
-{
- ///
- /// Gets or sets the order used by the board.
- ///
- [JsonProperty("card")]
- public CardSortOption Card { get; set; } = CardSortOption.Id;
-}
-
///
/// Board column width settings, applied globally.
///
@@ -97,6 +101,31 @@ public sealed class ColumnWidthSettings
public int CustomPixels { get; set; } = 320;
}
+///
+/// Everything that controls how the board is displayed: the column width and
+/// the card and column ordering.
+///
+public sealed class DisplaySettings
+{
+ ///
+ /// Gets or sets the board column width settings.
+ ///
+ [JsonProperty("column-width")]
+ public ColumnWidthSettings ColumnWidth { get; set; } = new();
+
+ ///
+ /// Gets or sets the order of cards within their column.
+ ///
+ [JsonProperty("card-sort")]
+ public SortOption CardSort { get; set; } = SortOption.IdAscending;
+
+ ///
+ /// Gets or sets the left-to-right order of the columns.
+ ///
+ [JsonProperty("column-sort")]
+ public SortOption ColumnSort { get; set; } = SortOption.IdAscending;
+}
+
///
/// Confirmation-dialog toggles for the destructive actions.
///
@@ -164,16 +193,10 @@ public sealed class AppConfig
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
///
- /// Gets or sets the card ordering settings.
+ /// Gets or sets the board display settings (width and ordering).
///
- [JsonProperty("sort")]
- public SortSettings Sort { get; set; } = new();
-
- ///
- /// Gets or sets the board column width settings.
- ///
- [JsonProperty("column-width")]
- public ColumnWidthSettings ColumnWidth { get; set; } = new();
+ [JsonProperty("display")]
+ public DisplaySettings Display { get; set; } = new();
///
/// Gets or sets the confirmation-dialog settings.
diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs
index 97e10ab..0ae201c 100644
--- a/YKanBan/Storage/AppData/AppConfigStore.cs
+++ b/YKanBan/Storage/AppData/AppConfigStore.cs
@@ -56,8 +56,7 @@ public sealed class AppConfigStore
}
// Older or hand-edited files may miss whole sections; each falls back to its defaults.
- config.Sort ??= new SortSettings();
- config.ColumnWidth ??= new ColumnWidthSettings();
+ config.Display ??= new DisplaySettings();
config.Confirmations ??= new ConfirmationSettings();
return config;
}
diff --git a/YKanBan/Storage/Workspace/WorkspacePreset.cs b/YKanBan/Storage/Workspace/WorkspacePreset.cs
index db27551..65c0a70 100644
--- a/YKanBan/Storage/Workspace/WorkspacePreset.cs
+++ b/YKanBan/Storage/Workspace/WorkspacePreset.cs
@@ -3,16 +3,16 @@ using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace;
///
-/// Adds the initial workspace content. The three preset column titles are read
-/// straight from the ResX resources in the UI language active when they are
-/// added; they then become ordinary data that no longer follows language
-/// switches.
+/// Adds the initial workspace content. The preset column titles/descriptions and
+/// tag names/descriptions are read straight from the ResX resources in the UI
+/// language active when they are added; they then become ordinary data that no
+/// longer follows language switches.
///
public static class WorkspacePreset
{
///
/// 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.
///
/// The initialized workspace folder.
/// The workspace database does not exist or a title collides with an existing column.
@@ -23,28 +23,70 @@ public static class WorkspacePreset
long now = UnixTime.Now;
- // Preset titles are data at creation time: whatever the current language says gets stored.
- string[] titles =
+ // Preset titles and descriptions are data at creation time: whatever the current language says gets stored.
+ (string Title, string Description)[] columns =
[
- Resources.PresetColumn_Todo,
- Resources.PresetColumn_InProgress,
- Resources.PresetColumn_Done,
+ (Resources.PresetColumn_Todo, Resources.PresetColumn_Todo_Description),
+ (Resources.PresetColumn_InProgress, Resources.PresetColumn_InProgress_Description),
+ (Resources.PresetColumn_Done, Resources.PresetColumn_Done_Description),
];
// Insert all three atomically so a partially populated workspace can never exist.
using var transaction = connection.BeginTransaction();
- foreach (string title in titles)
+ foreach ((string title, string description) in columns)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
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("$description", description);
command.Parameters.AddWithValue("$now", now);
command.ExecuteNonQuery();
}
transaction.Commit();
}
+
+ ///
+ /// Inserts the preset tag set (names, colors and descriptions in the current
+ /// language) in a single transaction.
+ ///
+ /// The initialized workspace folder.
+ /// The workspace database does not exist or a tag name collides.
+ 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();
+ }
}
diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs
new file mode 100644
index 0000000..d71eb45
--- /dev/null
+++ b/YKanBan/ViewModels/Board/BoardViewModel.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed partial class BoardViewModel : ViewModelBase
+{
+ private readonly AppServices _services;
+ private readonly WorkspaceRepository _repository;
+
+ ///
+ /// Initializes the board, loading its current content.
+ ///
+ /// The process composition root (configuration access).
+ /// The open workspace session.
+ public BoardViewModel(AppServices services, WorkspaceSession session)
+ {
+ _services = services;
+ _repository = session.Repository;
+ Display = new DisplaySettingsViewModel(_services.Config, Reload);
+
+ Reload();
+ }
+
+ ///
+ /// Gets the workspace repository used for all board data access.
+ ///
+ public WorkspaceRepository Repository => _repository;
+
+ ///
+ /// Gets the live application configuration.
+ ///
+ public AppConfig Config => _services.Config;
+
+ ///
+ /// Gets the display options (column width, card sort, column sort).
+ ///
+ public DisplaySettingsViewModel Display { get; }
+
+ ///
+ /// Gets the columns shown by the board, in display order.
+ ///
+ public ObservableCollection Columns { get; } = [];
+
+ ///
+ /// Gets or sets the search box text. The board does not filter on change;
+ /// the search runs only when the search button is used.
+ ///
+ [ObservableProperty]
+ private string searchText = string.Empty;
+
+ ///
+ /// Gets the rendered column width in pixels, applied globally.
+ ///
+ [ObservableProperty]
+ private double columnWidth;
+
+ ///
+ /// Gets a value indicating whether the board has any columns.
+ ///
+ public bool HasColumns => Columns.Count > 0;
+
+ ///
+ /// Gets a value indicating whether the board is empty.
+ ///
+ public bool HasNoColumns => Columns.Count == 0;
+
+ #region Interactions
+
+ /// Gets the interaction that opens the new-column editor.
+ public Interaction AddColumn { get; } = new();
+
+ /// Gets the interaction that opens the column editor.
+ public Interaction EditColumn { get; } = new();
+
+ /// Gets the interaction that confirms and deletes a column.
+ public Interaction DeleteColumn { get; } = new();
+
+ /// Gets the interaction that opens the new-card editor.
+ public Interaction AddCard { get; } = new();
+
+ /// Gets the interaction that opens the card editor.
+ public Interaction EditCard { get; } = new();
+
+ /// Gets the interaction that confirms and deletes a card.
+ public Interaction DeleteCard { get; } = new();
+
+ /// Gets the interaction that opens the move-card dialog.
+ public Interaction MoveCard { get; } = new();
+
+ /// Gets the interaction that runs the current search.
+ public Interaction RunSearch { get; } = new();
+
+ #endregion
+
+ #region Commands
+
+ ///
+ /// Opens the new-column editor.
+ ///
+ [RelayCommand]
+ private Task AddNewColumnAsync() => AddColumn.Handle(Unit.Value);
+
+ ///
+ /// Runs the current search (wired up with the search feature).
+ ///
+ [RelayCommand]
+ private Task SearchAsync() => RunSearch.Handle(Unit.Value);
+
+ ///
+ /// Clears the search box.
+ ///
+ [RelayCommand]
+ private void ClearSearch() => SearchText = string.Empty;
+
+ #endregion
+
+ #region Requests from columns and cards
+
+ ///
+ /// Requests the new-card editor for the given column.
+ ///
+ /// The target column.
+ /// A task completing after the editor closes.
+ public Task RequestAddCardAsync(ColumnViewModel column) => AddCard.Handle(column);
+
+ ///
+ /// Requests the editor for the given card.
+ ///
+ /// The card to edit.
+ /// A task completing after the editor closes.
+ public Task RequestEditCardAsync(CardViewModel card) => EditCard.Handle(card);
+
+ ///
+ /// Requests deletion of the given card.
+ ///
+ /// The card to delete.
+ /// A task completing after confirmation.
+ public Task RequestDeleteCardAsync(CardViewModel card) => DeleteCard.Handle(card);
+
+ ///
+ /// Requests moving the given card to another column.
+ ///
+ /// The card to move.
+ /// A task completing after the move.
+ public Task RequestMoveCardAsync(CardViewModel card) => MoveCard.Handle(card);
+
+ ///
+ /// Requests the editor for the given column.
+ ///
+ /// The column to edit.
+ /// A task completing after the editor closes.
+ public Task RequestEditColumnAsync(ColumnViewModel column) => EditColumn.Handle(column);
+
+ ///
+ /// Requests deletion of the given column.
+ ///
+ /// The column to delete.
+ /// A task completing after confirmation.
+ public Task RequestDeleteColumnAsync(ColumnViewModel column) => DeleteColumn.Handle(column);
+
+ #endregion
+
+ #region Loading and ordering
+
+ ///
+ /// Reloads all columns and cards from the repository and rebuilds the board.
+ ///
+ public void Reload()
+ {
+ IReadOnlyList columns = _repository.GetColumns();
+ IReadOnlyList cards = _repository.GetCards();
+
+ // Group cards by column once so each column can be built independently.
+ Dictionary> cardsByColumn = cards
+ .GroupBy(card => card.ColumnId)
+ .ToDictionary(group => group.Key, group => group.ToList());
+
+ IEnumerable 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 columnCards = cardsByColumn.TryGetValue(column.Id, out List? 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));
+ }
+
+ ///
+ /// Recomputes the global column width and pushes it to every column.
+ ///
+ public void ApplyColumnWidth()
+ {
+ ColumnWidth = ColumnWidthPixels(_services.Config.Display.ColumnWidth);
+ foreach (ColumnViewModel column in Columns)
+ {
+ column.Width = ColumnWidth;
+ }
+ }
+
+ ///
+ /// Resolves a column width setting to its pixel value.
+ ///
+ /// The configured column width.
+ /// The width in pixels.
+ public static double ColumnWidthPixels(ColumnWidthSettings settings) => settings.Preset switch
+ {
+ ColumnWidthPreset.Narrow => 240,
+ ColumnWidthPreset.Wide => 480,
+ ColumnWidthPreset.Custom => settings.CustomPixels,
+ _ => 320,
+ };
+
+ ///
+ /// Orders cards by the selected option, tie-breaking by id ascending.
+ ///
+ /// The cards to order.
+ /// The selected order.
+ /// The ordered cards.
+ private static IEnumerable SortCards(IEnumerable 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),
+ };
+
+ ///
+ /// Orders columns by the selected option, tie-breaking by id ascending.
+ ///
+ /// The columns to order.
+ /// The selected order.
+ /// The ordered columns.
+ private static IEnumerable SortColumns(IEnumerable 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
+}
diff --git a/YKanBan/ViewModels/Board/CardViewModel.cs b/YKanBan/ViewModels/Board/CardViewModel.cs
new file mode 100644
index 0000000..e54863c
--- /dev/null
+++ b/YKanBan/ViewModels/Board/CardViewModel.cs
@@ -0,0 +1,105 @@
+using CommunityToolkit.Mvvm.Input;
+using YKanBan.Models;
+
+namespace YKanBan.ViewModels.Board;
+
+///
+/// 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.
+///
+public sealed partial class CardViewModel : ViewModelBase
+{
+ /// Maximum number of content characters shown in the card preview.
+ private const int PreviewLength = 200;
+
+ private readonly BoardViewModel _board;
+
+ ///
+ /// Initializes the card view from its model.
+ ///
+ /// The owning board view model.
+ /// The card model to display.
+ public CardViewModel(BoardViewModel board, CardModel card)
+ {
+ _board = board;
+ Model = card;
+ TagBadges = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList();
+ }
+
+ ///
+ /// Gets the underlying card model.
+ ///
+ public CardModel Model { get; }
+
+ ///
+ /// Gets the card id.
+ ///
+ public long Id => Model.Id;
+
+ ///
+ /// Gets the owning column id.
+ ///
+ public long ColumnId => Model.ColumnId;
+
+ ///
+ /// Gets the tag badges shown under the content.
+ ///
+ public IReadOnlyList TagBadges { get; }
+
+ ///
+ /// Gets the bold id marker, for example "#61".
+ ///
+ public string IdText => $"#{Model.Id}";
+
+ ///
+ /// Gets the card title, empty when the card has no title.
+ ///
+ public string TitleText => Model.Title;
+
+ ///
+ /// Gets a value indicating whether the card has a title to show.
+ ///
+ public bool HasTitle => Model.Title.Length > 0;
+
+ ///
+ /// Gets a value indicating whether the card has content to preview.
+ ///
+ public bool HasContent => Model.Content.Length > 0;
+
+ ///
+ /// Gets the content preview, collapsed to one line and truncated.
+ ///
+ public string PreviewContent
+ {
+ get
+ {
+ string singleLine = Model.Content.Replace("\r", " ").Replace("\n", " ");
+ return singleLine.Length <= PreviewLength ? singleLine : singleLine[..PreviewLength] + "…";
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the card has any tags.
+ ///
+ public bool HasTags => TagBadges.Count > 0;
+
+ ///
+ /// Opens the card editor.
+ ///
+ [RelayCommand]
+ private void Edit() => _ = _board.RequestEditCardAsync(this);
+
+ ///
+ /// Asks the board to delete this card.
+ ///
+ [RelayCommand]
+ private void Delete() => _ = _board.RequestDeleteCardAsync(this);
+
+ ///
+ /// Asks the board to move this card to another column.
+ ///
+ [RelayCommand]
+ private void Move() => _ = _board.RequestMoveCardAsync(this);
+}
diff --git a/YKanBan/ViewModels/Board/ColumnViewModel.cs b/YKanBan/ViewModels/Board/ColumnViewModel.cs
new file mode 100644
index 0000000..123bd07
--- /dev/null
+++ b/YKanBan/ViewModels/Board/ColumnViewModel.cs
@@ -0,0 +1,89 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using YKanBan.Models;
+
+namespace YKanBan.ViewModels.Board;
+
+///
+/// One board column: a header with its title, card count and actions, plus the
+/// vertically stacked cards.
+///
+public sealed partial class ColumnViewModel : ViewModelBase
+{
+ private readonly BoardViewModel _board;
+
+ ///
+ /// Initializes the column view from its model and cards.
+ ///
+ /// The owning board view model.
+ /// The column model.
+ /// The already sorted cards belonging to this column.
+ public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable cards)
+ {
+ _board = board;
+ Model = column;
+ Cards = new ObservableCollection(cards);
+ width = board.ColumnWidth;
+ }
+
+ ///
+ /// Gets the underlying column model.
+ ///
+ public ColumnModel Model { get; }
+
+ ///
+ /// Gets the column id.
+ ///
+ public long Id => Model.Id;
+
+ ///
+ /// Gets the column title.
+ ///
+ public string Title => Model.Title;
+
+ ///
+ /// Gets the column description.
+ ///
+ public string Description => Model.Description;
+
+ ///
+ /// Gets the cards in this column, already in display order.
+ ///
+ public ObservableCollection Cards { get; }
+
+ ///
+ /// Gets the number of cards in this column.
+ ///
+ public int CardCount => Cards.Count;
+
+ ///
+ /// Gets the tooltip text: the column description when present, otherwise
+ /// so no tooltip is shown.
+ ///
+ public string? ToolTipText => Model.Description.Length > 0 ? Model.Description : null;
+
+ ///
+ /// Gets or sets the rendered column width in pixels, applied globally.
+ ///
+ [ObservableProperty]
+ private double width;
+
+ ///
+ /// Opens the editor for a new card in this column.
+ ///
+ [RelayCommand]
+ private void AddCard() => _ = _board.RequestAddCardAsync(this);
+
+ ///
+ /// Opens the editor for this column.
+ ///
+ [RelayCommand]
+ private void Edit() => _ = _board.RequestEditColumnAsync(this);
+
+ ///
+ /// Asks the board to delete this column.
+ ///
+ [RelayCommand]
+ private void Delete() => _ = _board.RequestDeleteColumnAsync(this);
+}
diff --git a/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs b/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs
new file mode 100644
index 0000000..6eb882e
--- /dev/null
+++ b/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs
@@ -0,0 +1,170 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.ViewModels.Board;
+
+///
+/// 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.
+///
+public sealed partial class DisplaySettingsViewModel : ViewModelBase
+{
+ /// Smallest accepted manual width.
+ public const int MinimumPixels = 160;
+
+ /// Largest accepted manual width.
+ public const int MaximumPixels = 720;
+
+ private readonly AppConfig _config;
+ private readonly Action _refresh;
+
+ ///
+ /// Initializes the display options from the configuration.
+ ///
+ /// The live application configuration.
+ /// Invoked after every change to refresh the board.
+ 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);
+ }
+
+ ///
+ /// Gets the width preset choices.
+ ///
+ public IReadOnlyList Presets { get; }
+
+ ///
+ /// Gets the ordering choices (shared by cards and columns).
+ ///
+ public IReadOnlyList SortOptions { get; }
+
+ ///
+ /// Gets or sets the selected width preset.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedPreset;
+
+ ///
+ /// Gets or sets the manual pixel width used by the custom preset.
+ ///
+ [ObservableProperty]
+ private decimal? customPixels;
+
+ ///
+ /// Gets or sets the selected card order.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedCardSort;
+
+ ///
+ /// Gets or sets the selected column order.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedColumnSort;
+
+ ///
+ /// Gets a value indicating whether the custom preset is selected.
+ ///
+ public bool IsCustom => SelectedPreset is not null && (ColumnWidthPreset)SelectedPreset.Value == ColumnWidthPreset.Custom;
+
+ ///
+ /// Stores the width preset and refreshes the board.
+ ///
+ /// The new selection.
+ partial void OnSelectedPresetChanged(OptionItem? value)
+ {
+ OnPropertyChanged(nameof(IsCustom));
+ if (value is not null)
+ {
+ _config.Display.ColumnWidth.Preset = (ColumnWidthPreset)value.Value;
+ _refresh();
+ }
+ }
+
+ ///
+ /// Stores the manual width and refreshes the board when the custom preset is active.
+ ///
+ /// The new pixel count.
+ partial void OnCustomPixelsChanged(decimal? value)
+ {
+ if (value is null)
+ {
+ return;
+ }
+
+ int pixels = (int)value.Value;
+ _config.Display.ColumnWidth.CustomPixels = pixels;
+ if (IsCustom)
+ {
+ _refresh();
+ }
+ }
+
+ ///
+ /// Stores the card order and refreshes the board.
+ ///
+ /// The new selection.
+ partial void OnSelectedCardSortChanged(OptionItem? value)
+ {
+ if (value is not null)
+ {
+ _config.Display.CardSort = (SortOption)value.Value;
+ _refresh();
+ }
+ }
+
+ ///
+ /// Stores the column order and refreshes the board.
+ ///
+ /// The new selection.
+ partial void OnSelectedColumnSortChanged(OptionItem? value)
+ {
+ if (value is not null)
+ {
+ _config.Display.ColumnSort = (SortOption)value.Value;
+ _refresh();
+ }
+ }
+
+ ///
+ /// Builds the localized eight-way ordering option list.
+ ///
+ /// The ordering options.
+ private static IReadOnlyList 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),
+ ];
+
+ ///
+ /// Finds the option matching a stored sort value.
+ ///
+ /// The option list.
+ /// The stored value.
+ /// The matching option, or the first option.
+ private static OptionItem FindSort(IReadOnlyList options, SortOption value) =>
+ options.FirstOrDefault(option => (SortOption)option.Value == value) ?? options[0];
+}
diff --git a/YKanBan/ViewModels/Board/TagBadgeViewModel.cs b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs
new file mode 100644
index 0000000..2f8f06e
--- /dev/null
+++ b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs
@@ -0,0 +1,62 @@
+using Avalonia.Media;
+using YKanBan.Models;
+
+namespace YKanBan.ViewModels.Board;
+
+///
+/// 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.
+///
+public sealed class TagBadgeViewModel
+{
+ ///
+ /// Initializes a badge from a tag.
+ ///
+ /// The tag to render.
+ 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));
+ }
+
+ ///
+ /// Gets the underlying tag model.
+ ///
+ public TagModel Tag { get; }
+
+ ///
+ /// Gets the tag name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the tooltip text: the tag description when present, otherwise
+ /// so no tooltip is shown.
+ ///
+ public string? ToolTipText => Tag.Description.Length > 0 ? Tag.Description : null;
+
+ ///
+ /// Gets the badge background brush.
+ ///
+ public IBrush Background { get; }
+
+ ///
+ /// Gets the badge text brush.
+ ///
+ public IBrush Foreground { get; }
+
+ ///
+ /// Picks black or white text for readability against the given background.
+ ///
+ /// The badge background color.
+ /// The contrasting text color.
+ 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;
+ }
+}
diff --git a/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs b/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs
new file mode 100644
index 0000000..a640b89
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs
@@ -0,0 +1,55 @@
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.Input;
+using YKanBan.Models;
+using YKanBan.ViewModels.Board;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// 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).
+///
+public sealed partial class AssignedTagViewModel
+{
+ private readonly CardEditorViewModel _editor;
+ private readonly TagBadgeViewModel _badge;
+
+ ///
+ /// Initializes the assigned-tag row.
+ ///
+ /// The owning card editor.
+ /// The assigned tag.
+ public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag)
+ {
+ _editor = editor;
+ Tag = tag;
+ _badge = new TagBadgeViewModel(tag);
+ }
+
+ ///
+ /// Gets the assigned tag model.
+ ///
+ public TagModel Tag { get; }
+
+ ///
+ /// Gets the tag name.
+ ///
+ public string Name => _badge.Name;
+
+ ///
+ /// Gets the badge background brush.
+ ///
+ public IBrush Background => _badge.Background;
+
+ ///
+ /// Gets the badge text brush.
+ ///
+ public IBrush Foreground => _badge.Foreground;
+
+ ///
+ /// Removes this tag from the in-memory assignment.
+ ///
+ [RelayCommand]
+ private void Remove() => _editor.RemoveTag(Tag);
+}
diff --git a/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs
new file mode 100644
index 0000000..e5856da
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed partial class CardEditorViewModel : ViewModelBase
+{
+ private readonly WorkspaceRepository _repository;
+ private readonly long _columnId;
+ private readonly CardModel? _existing;
+
+ ///
+ /// Initializes the dialog for a new or existing card.
+ ///
+ /// The workspace repository.
+ /// The owning column (used when adding).
+ /// The card to edit, or to add one.
+ 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(
+ (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));
+ }
+ }
+
+ ///
+ /// Gets the interaction that opens the nested tag picker.
+ ///
+ public Interaction ShowTagPicker { get; } = new();
+
+ ///
+ /// Gets the workspace repository (used by the tag picker).
+ ///
+ public WorkspaceRepository Repository => _repository;
+
+ ///
+ /// Gets a value indicating whether this dialog creates a new card.
+ ///
+ public bool IsNew => _existing is null;
+
+ ///
+ /// Gets the localized window title.
+ ///
+ public string WindowTitle => IsNew ? Resources.CardDialog_AddTitle : Resources.CardDialog_EditTitle;
+
+ ///
+ /// Gets the read-only id line, empty for a new card.
+ ///
+ public string IdLine { get; } = string.Empty;
+
+ ///
+ /// Gets the read-only created line, empty for a new card.
+ ///
+ public string CreatedLine { get; } = string.Empty;
+
+ ///
+ /// Gets the read-only modified line, empty for a new card.
+ ///
+ public string ModifiedLine { get; } = string.Empty;
+
+ ///
+ /// Gets the tags currently assigned to the card (in memory until confirmed).
+ ///
+ public ObservableCollection AssignedTags { get; }
+
+ ///
+ /// Gets or sets the card title.
+ ///
+ [ObservableProperty]
+ private string title;
+
+ ///
+ /// Gets or sets the card content.
+ ///
+ [ObservableProperty]
+ private string content;
+
+ ///
+ /// Gets or sets the inline error shown when the write fails.
+ ///
+ [ObservableProperty]
+ private string? errorMessage;
+
+ ///
+ /// Gets a value indicating whether there are unsaved effective changes.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Opens the nested tag picker and adds the chosen tag when it is new to the card.
+ ///
+ [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));
+ }
+ }
+
+ ///
+ /// Removes a tag from the in-memory assignment.
+ ///
+ /// The tag to remove.
+ public void RemoveTag(TagModel tag)
+ {
+ AssignedTagViewModel? row = AssignedTags.FirstOrDefault(item => item.Tag.Id == tag.Id);
+ if (row is not null)
+ {
+ AssignedTags.Remove(row);
+ }
+ }
+
+ ///
+ /// Writes the card and its tags when there is an effective change.
+ ///
+ /// when the dialog may close.
+ public bool TryAccept()
+ {
+ ErrorMessage = null;
+
+ // A confirm with no effective change writes nothing and just closes.
+ if (!HasChanges)
+ {
+ return true;
+ }
+
+ try
+ {
+ IReadOnlyCollection 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;
+ }
+ }
+
+ ///
+ /// Gets the culture used for formatted read-only lines.
+ ///
+ private static CultureInfo Culture => Resources.Culture ?? CultureInfo.CurrentCulture;
+
+ ///
+ /// Formats a stored timestamp as a local time using the UI language pattern.
+ ///
+ /// The stored timestamp.
+ /// The formatted local time.
+ private static string FormatTime(long unixSeconds) =>
+ DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime().ToString(Resources.CardDialog_TimePattern, Culture);
+}
diff --git a/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs
new file mode 100644
index 0000000..f5d5438
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs
@@ -0,0 +1,85 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Microsoft.Data.Sqlite;
+using YKanBan.Models;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// 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.
+///
+public sealed partial class ColumnEditorViewModel : ViewModelBase
+{
+ private readonly WorkspaceRepository _repository;
+ private readonly ColumnModel? _existing;
+
+ ///
+ /// Initializes the dialog for a new or existing column.
+ ///
+ /// The workspace repository.
+ /// The column to edit, or to add one.
+ public ColumnEditorViewModel(WorkspaceRepository repository, ColumnModel? existing)
+ {
+ _repository = repository;
+ _existing = existing;
+ title = existing?.Title ?? string.Empty;
+ description = existing?.Description ?? string.Empty;
+ }
+
+ ///
+ /// Gets a value indicating whether this dialog creates a new column.
+ ///
+ public bool IsNew => _existing is null;
+
+ ///
+ /// Gets the localized window title.
+ ///
+ public string WindowTitle => IsNew ? Resources.ColumnDialog_AddTitle : Resources.ColumnDialog_EditTitle;
+
+ ///
+ /// Gets or sets the column title.
+ ///
+ [ObservableProperty]
+ private string title;
+
+ ///
+ /// Gets or sets the column description.
+ ///
+ [ObservableProperty]
+ private string description;
+
+ ///
+ /// Gets or sets the inline error shown when the title is rejected.
+ ///
+ [ObservableProperty]
+ private string? errorMessage;
+
+ ///
+ /// Writes the column when accepted.
+ ///
+ /// when the dialog may close.
+ 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;
+ }
+ }
+}
diff --git a/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs
new file mode 100644
index 0000000..dfb4d0a
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs
@@ -0,0 +1,26 @@
+using System.Globalization;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The confirmation shown before deleting a card, naming the card id.
+///
+public sealed class DeleteCardConfirmViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the confirmation.
+ ///
+ /// The id of the card to delete.
+ public DeleteCardConfirmViewModel(long cardId)
+ {
+ Message = string.Format(
+ Resources.Culture ?? CultureInfo.CurrentCulture,
+ Resources.Confirm_DeleteCard_Message,
+ cardId);
+ }
+
+ ///
+ /// Gets the localized confirmation message.
+ ///
+ public string Message { get; }
+}
diff --git a/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs
new file mode 100644
index 0000000..ba771b5
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The confirmation shown before deleting a column, naming the column and how
+/// many cards it will cascade-delete.
+///
+public sealed class DeleteColumnConfirmViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the confirmation.
+ ///
+ /// The title of the column to delete.
+ /// The number of cards in the column.
+ public DeleteColumnConfirmViewModel(string columnTitle, int cardCount)
+ {
+ Message = string.Format(
+ Resources.Culture ?? CultureInfo.CurrentCulture,
+ Resources.Confirm_DeleteColumn_Message,
+ columnTitle,
+ cardCount);
+ }
+
+ ///
+ /// Gets the localized confirmation message.
+ ///
+ public string Message { get; }
+}
diff --git a/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs
new file mode 100644
index 0000000..247c7e3
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs
@@ -0,0 +1,12 @@
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The confirmation shown before closing an editor with unsaved changes.
+///
+public sealed class DiscardEditConfirmViewModel : ViewModelBase
+{
+ ///
+ /// Gets the localized confirmation message.
+ ///
+ public string Message => Resources.Confirm_Discard_Message;
+}
diff --git a/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs b/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs
new file mode 100644
index 0000000..1cfd6a4
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs
@@ -0,0 +1,40 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Models;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The move-card dialog: choose one of the other columns as the destination.
+///
+public sealed partial class MoveCardViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the dialog with the candidate destination columns.
+ ///
+ /// All columns; the card's current column is excluded.
+ /// The card's current column id.
+ public MoveCardViewModel(IReadOnlyList columns, long currentColumnId)
+ {
+ Targets = columns
+ .Where(column => column.Id != currentColumnId)
+ .Select(column => new OptionItem(column.Id, column.Title))
+ .ToList();
+ selectedTarget = Targets.FirstOrDefault();
+ }
+
+ ///
+ /// Gets the candidate destination columns.
+ ///
+ public IReadOnlyList Targets { get; }
+
+ ///
+ /// Gets or sets the selected destination column.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedTarget;
+
+ ///
+ /// Gets a value indicating whether there is anywhere to move the card.
+ ///
+ public bool CanMove => Targets.Count > 0;
+}
diff --git a/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs
new file mode 100644
index 0000000..d288db7
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs
@@ -0,0 +1,43 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Storage.Workspace;
+using YKanBan.ViewModels.Board;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// 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.
+///
+public sealed partial class TagPickerViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the picker with the tags not yet assigned to the card.
+ ///
+ /// The workspace repository.
+ /// The tag ids already assigned to the card.
+ public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection assignedTagIds)
+ {
+ Targets = repository.GetTags()
+ .Where(tag => !assignedTagIds.Contains(tag.Id))
+ .Select(tag => new TagBadgeViewModel(tag))
+ .ToList();
+ selectedTarget = Targets.FirstOrDefault();
+ }
+
+ ///
+ /// Gets the assignable tags.
+ ///
+ public IReadOnlyList Targets { get; }
+
+ ///
+ /// Gets or sets the selected tag.
+ ///
+ [ObservableProperty]
+ private TagBadgeViewModel? selectedTarget;
+
+ ///
+ /// Gets a value indicating whether there is a tag to add.
+ ///
+ public bool CanAdd => Targets.Count > 0;
+}
diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs
index 9f1f1d3..03f00d3 100644
--- a/YKanBan/ViewModels/MainWindowViewModel.cs
+++ b/YKanBan/ViewModels/MainWindowViewModel.cs
@@ -98,7 +98,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private void OpenWorkspace(string folderPath)
{
WorkspaceSession session = _services.OpenWorkspace(folderPath);
- Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout);
+ Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
}
@@ -118,6 +118,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
WorkspaceInitializer.Initialize(page.FolderPath);
WorkspacePreset.AddPresetColumns(page.FolderPath);
+ WorkspacePreset.AddPresetTags(page.FolderPath);
OpenWorkspace(page.FolderPath);
}
catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException)
diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs
index 570dda0..5dc58ea 100644
--- a/YKanBan/ViewModels/WorkspaceViewModel.cs
+++ b/YKanBan/ViewModels/WorkspaceViewModel.cs
@@ -1,12 +1,13 @@
using CommunityToolkit.Mvvm.Input;
+using YKanBan.Services;
using YKanBan.Storage.Workspace;
+using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels;
///
/// 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
-/// filled in by the board and tag features.
+/// settings and about buttons) plus the board and tags tabs.
///
public sealed partial class WorkspaceViewModel : ViewModelBase
{
@@ -16,10 +17,12 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
///
/// Initializes the view for an open workspace.
///
+ /// The process composition root.
/// The open workspace session.
/// The interaction that opens the settings dialog.
/// The interaction that opens the about dialog.
public WorkspaceViewModel(
+ AppServices services,
WorkspaceSession session,
Interaction showSettings,
Interaction showAbout)
@@ -29,6 +32,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
WorkspacePath = session.FolderPath;
_showSettings = showSettings;
_showAbout = showAbout;
+ Board = new BoardViewModel(services, session);
}
///
@@ -46,6 +50,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
///
public string WorkspacePath { get; }
+ ///
+ /// Gets the board tab view model.
+ ///
+ public BoardViewModel Board { get; }
+
///
/// Opens the settings dialog through the registered interaction.
///
diff --git a/YKanBan/Views/Board/BoardView.axaml b/YKanBan/Views/Board/BoardView.axaml
new file mode 100644
index 0000000..b0546bb
--- /dev/null
+++ b/YKanBan/Views/Board/BoardView.axaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Board/BoardView.axaml.cs b/YKanBan/Views/Board/BoardView.axaml.cs
new file mode 100644
index 0000000..7a58203
--- /dev/null
+++ b/YKanBan/Views/Board/BoardView.axaml.cs
@@ -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);
+ }
+ }
+
+ /// Gets the window that owns this board, used as the dialog owner.
+ private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
+
+ /// Shows the add/edit column dialog and reloads on success.
+ 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(owner))
+ {
+ board.Reload();
+ }
+ }
+
+ /// Confirms and deletes a column, then reloads.
+ 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(owner);
+ }
+
+ if (!confirmed)
+ {
+ return;
+ }
+
+ board.Repository.DeleteColumn(column.Id);
+ board.Reload();
+ }
+
+ /// Shows the add/edit card dialog and reloads on success.
+ 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(owner))
+ {
+ board.Reload();
+ }
+ }
+
+ /// Confirms and deletes a card, then reloads.
+ 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(owner);
+ }
+
+ if (!confirmed)
+ {
+ return;
+ }
+
+ board.Repository.DeleteCard(card.Id);
+ board.Reload();
+ }
+
+ /// Shows the move-card dialog and reloads when a destination is chosen.
+ 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(owner) is long targetColumnId)
+ {
+ board.Repository.MoveCard(card.Id, targetColumnId);
+ board.Reload();
+ }
+ }
+}
diff --git a/YKanBan/Views/Board/CardView.axaml b/YKanBan/Views/Board/CardView.axaml
new file mode 100644
index 0000000..61ec5bb
--- /dev/null
+++ b/YKanBan/Views/Board/CardView.axaml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Board/CardView.axaml.cs b/YKanBan/Views/Board/CardView.axaml.cs
new file mode 100644
index 0000000..07dcfc0
--- /dev/null
+++ b/YKanBan/Views/Board/CardView.axaml.cs
@@ -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();
+ }
+
+ /// Clicking the card body opens the card editor.
+ private void OnCardTapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is CardViewModel card)
+ {
+ card.EditCommand.Execute(null);
+ }
+ }
+
+ /// Stops the corner menu button's tap from bubbling to the card body.
+ private void OnMenuButtonTapped(object? sender, TappedEventArgs e) => e.Handled = true;
+}
diff --git a/YKanBan/Views/Board/ColumnView.axaml b/YKanBan/Views/Board/ColumnView.axaml
new file mode 100644
index 0000000..d1b4ebb
--- /dev/null
+++ b/YKanBan/Views/Board/ColumnView.axaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Board/ColumnView.axaml.cs b/YKanBan/Views/Board/ColumnView.axaml.cs
new file mode 100644
index 0000000..110f6a6
--- /dev/null
+++ b/YKanBan/Views/Board/ColumnView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Board;
+
+public partial class ColumnView : UserControl
+{
+ public ColumnView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/Dialogs/CardEditorDialog.axaml b/YKanBan/Views/Dialogs/CardEditorDialog.axaml
new file mode 100644
index 0000000..6fa449b
--- /dev/null
+++ b/YKanBan/Views/Dialogs/CardEditorDialog.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs
new file mode 100644
index 0000000..f317f1c
--- /dev/null
+++ b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs
@@ -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);
+ }
+ }
+
+ /// Shows the nested tag picker, owned by this dialog.
+ private async Task 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(this);
+ }
+
+ /// Writes the card and closes when accepted.
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept())
+ {
+ _allowClose = true;
+ Close(true);
+ }
+ }
+
+ /// Requests a close, confirming the discard first when needed.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => _ = RequestCloseAsync();
+
+ /// Intercepts the window close button to run the discard confirmation.
+ 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;
+ }
+ }
+
+ /// Confirms the discard and closes, or stays open when declined.
+ private async Task RequestCloseAsync()
+ {
+ if (HasUnsavedChanges && DiscardConfirmEnabled)
+ {
+ var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() };
+ if (!await dialog.ShowDialog(this))
+ {
+ return;
+ }
+ }
+
+ _allowClose = true;
+ Close(false);
+ }
+
+ /// Gets whether the editor has unsaved changes.
+ private bool HasUnsavedChanges => DataContext is CardEditorViewModel viewModel && viewModel.HasChanges;
+
+ /// Gets whether the discard confirmation setting is enabled.
+ private static bool DiscardConfirmEnabled => App.Services?.Config.Confirmations.DiscardEdit == true;
+}
diff --git a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml
new file mode 100644
index 0000000..06f8d9e
--- /dev/null
+++ b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs
new file mode 100644
index 0000000..315bd60
--- /dev/null
+++ b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs
@@ -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();
+ }
+
+ /// Writes the column and closes when accepted.
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept())
+ {
+ Close(true);
+ }
+ }
+
+ /// Closes without writing.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml
new file mode 100644
index 0000000..81fb09b
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs
new file mode 100644
index 0000000..a49ea73
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class DeleteCardConfirmDialog : Window
+{
+ public DeleteCardConfirmDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// Confirms the deletion.
+ private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
+
+ /// Cancels the deletion.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml
new file mode 100644
index 0000000..f0e83ce
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs
new file mode 100644
index 0000000..4fea518
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class DeleteColumnConfirmDialog : Window
+{
+ public DeleteColumnConfirmDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// Confirms the deletion.
+ private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
+
+ /// Cancels the deletion.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml
new file mode 100644
index 0000000..a9b1822
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs
new file mode 100644
index 0000000..caa6a36
--- /dev/null
+++ b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class DiscardEditConfirmDialog : Window
+{
+ public DiscardEditConfirmDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// Confirms discarding the changes.
+ private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
+
+ /// Keeps editing.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/Dialogs/MoveCardDialog.axaml b/YKanBan/Views/Dialogs/MoveCardDialog.axaml
new file mode 100644
index 0000000..f0d08c9
--- /dev/null
+++ b/YKanBan/Views/Dialogs/MoveCardDialog.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs b/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs
new file mode 100644
index 0000000..ad68989
--- /dev/null
+++ b/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs
@@ -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();
+ }
+
+ /// Closes with the selected destination column id.
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null)
+ {
+ Close((long)viewModel.SelectedTarget.Value);
+ }
+ }
+
+ /// Closes without moving.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/YKanBan/Views/Dialogs/TagPickerDialog.axaml b/YKanBan/Views/Dialogs/TagPickerDialog.axaml
new file mode 100644
index 0000000..40ba965
--- /dev/null
+++ b/YKanBan/Views/Dialogs/TagPickerDialog.axaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs b/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs
new file mode 100644
index 0000000..c0343dc
--- /dev/null
+++ b/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs
@@ -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();
+ }
+
+ /// Closes with the selected tag.
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge)
+ {
+ Close(badge.Tag);
+ }
+ }
+
+ /// Closes without picking a tag.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/YKanBan/Views/WorkspaceView.axaml b/YKanBan/Views/WorkspaceView.axaml
index 99f5d46..b9a6a57 100644
--- a/YKanBan/Views/WorkspaceView.axaml
+++ b/YKanBan/Views/WorkspaceView.axaml
@@ -3,6 +3,7 @@
xmlns:vm="using:YKanBan.ViewModels"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
+ xmlns:board="using:YKanBan.Views.Board"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
@@ -50,7 +51,9 @@
-
+
+
+