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