From 830f1153fdd028681bdcf8ebcf4cb8d71dace812 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Sat, 3 Oct 2026 21:18:54 +0800 Subject: [PATCH] feat: add board interface with cards, columns and dialogs --- .../Storage/AppData/AppConfigStoreTests.cs | 53 ++-- .../Storage/Workspace/WorkspacePresetTests.cs | 114 ++++++-- .../TestUtilities/SqliteTestHelper.cs | 38 +++ YKanBan/App.axaml | 10 + YKanBan/Assets/Locales/Resources.resx | 229 ++++++++++++++- YKanBan/Assets/Locales/Resources.zh-Hans.resx | 225 +++++++++++++++ YKanBan/Storage/AppData/AppConfig.cs | 95 +++--- YKanBan/Storage/AppData/AppConfigStore.cs | 3 +- YKanBan/Storage/Workspace/WorkspacePreset.cs | 66 ++++- YKanBan/ViewModels/Board/BoardViewModel.cs | 271 ++++++++++++++++++ YKanBan/ViewModels/Board/CardViewModel.cs | 105 +++++++ YKanBan/ViewModels/Board/ColumnViewModel.cs | 89 ++++++ .../Board/DisplaySettingsViewModel.cs | 170 +++++++++++ YKanBan/ViewModels/Board/TagBadgeViewModel.cs | 62 ++++ .../Dialogs/AssignedTagViewModel.cs | 55 ++++ .../ViewModels/Dialogs/CardEditorViewModel.cs | 196 +++++++++++++ .../Dialogs/ColumnEditorViewModel.cs | 85 ++++++ .../Dialogs/DeleteCardConfirmViewModel.cs | 26 ++ .../Dialogs/DeleteColumnConfirmViewModel.cs | 29 ++ .../Dialogs/DiscardEditConfirmViewModel.cs | 12 + .../ViewModels/Dialogs/MoveCardViewModel.cs | 40 +++ .../ViewModels/Dialogs/TagPickerViewModel.cs | 43 +++ YKanBan/ViewModels/MainWindowViewModel.cs | 3 +- YKanBan/ViewModels/WorkspaceViewModel.cs | 13 +- YKanBan/Views/Board/BoardView.axaml | 114 ++++++++ YKanBan/Views/Board/BoardView.axaml.cs | 148 ++++++++++ YKanBan/Views/Board/CardView.axaml | 63 ++++ YKanBan/Views/Board/CardView.axaml.cs | 25 ++ YKanBan/Views/Board/ColumnView.axaml | 55 ++++ YKanBan/Views/Board/ColumnView.axaml.cs | 11 + YKanBan/Views/Dialogs/CardEditorDialog.axaml | 74 +++++ .../Views/Dialogs/CardEditorDialog.axaml.cs | 99 +++++++ .../Views/Dialogs/ColumnEditorDialog.axaml | 36 +++ .../Views/Dialogs/ColumnEditorDialog.axaml.cs | 25 ++ .../Dialogs/DeleteCardConfirmDialog.axaml | 24 ++ .../Dialogs/DeleteCardConfirmDialog.axaml.cs | 18 ++ .../Dialogs/DeleteColumnConfirmDialog.axaml | 24 ++ .../DeleteColumnConfirmDialog.axaml.cs | 18 ++ .../Dialogs/DiscardEditConfirmDialog.axaml | 24 ++ .../Dialogs/DiscardEditConfirmDialog.axaml.cs | 18 ++ YKanBan/Views/Dialogs/MoveCardDialog.axaml | 39 +++ YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs | 25 ++ YKanBan/Views/Dialogs/TagPickerDialog.axaml | 42 +++ .../Views/Dialogs/TagPickerDialog.axaml.cs | 25 ++ YKanBan/Views/WorkspaceView.axaml | 5 +- 45 files changed, 2843 insertions(+), 101 deletions(-) create mode 100644 YKanBan/ViewModels/Board/BoardViewModel.cs create mode 100644 YKanBan/ViewModels/Board/CardViewModel.cs create mode 100644 YKanBan/ViewModels/Board/ColumnViewModel.cs create mode 100644 YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs create mode 100644 YKanBan/ViewModels/Board/TagBadgeViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs create mode 100644 YKanBan/Views/Board/BoardView.axaml create mode 100644 YKanBan/Views/Board/BoardView.axaml.cs create mode 100644 YKanBan/Views/Board/CardView.axaml create mode 100644 YKanBan/Views/Board/CardView.axaml.cs create mode 100644 YKanBan/Views/Board/ColumnView.axaml create mode 100644 YKanBan/Views/Board/ColumnView.axaml.cs create mode 100644 YKanBan/Views/Dialogs/CardEditorDialog.axaml create mode 100644 YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/ColumnEditorDialog.axaml create mode 100644 YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml create mode 100644 YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml create mode 100644 YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml create mode 100644 YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/MoveCardDialog.axaml create mode 100644 YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/TagPickerDialog.axaml create mode 100644 YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +