diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs index 0771973..da0fdeb 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -1,5 +1,7 @@ using Microsoft.Data.Sqlite; using YKanBan.Models; +using YKanBan.Search; +using YKanBan.Storage; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -253,8 +255,87 @@ public class WorkspaceRepositoryTests public void AddTagWithDuplicateNameIsRejected() { using var fixture = new RepositoryFixture(); - Assert.ThrowsExactly(() => - fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), "")); + DuplicateNameException exception = Assert.ThrowsExactly(() => + fixture.Repository.AddTag("BUG", RgbColor.Parse("#123456"), "")); + Assert.AreEqual(NamedEntity.Tag, exception.Entity); + Assert.AreEqual("BUG", exception.Name); + Assert.AreEqual(2, fixture.Repository.GetTags().Count); + } + + [TestMethod] + public void AddTagWithEmptyNameIsRejected() + { + using var fixture = new RepositoryFixture(); + NameRequiredException exception = Assert.ThrowsExactly(() => + fixture.Repository.AddTag("", RgbColor.Parse("#123456"), "")); + Assert.AreEqual(NamedEntity.Tag, exception.Entity); + } + + [TestMethod] + public void UpdateTagToExistingNameIsRejectedAndKeepsTheRow() + { + using var fixture = new RepositoryFixture(); + Assert.ThrowsExactly(() => + fixture.Repository.UpdateTag(fixture.UiTagId, "Bug", RgbColor.Parse("#00ff00"), "")); + Assert.AreEqual("ui", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.UiTagId).Name); + } + + [TestMethod] + public void AddColumnWithDuplicateTitleIsRejectedCaseInsensitively() + { + using var fixture = new RepositoryFixture(); + DuplicateNameException exception = Assert.ThrowsExactly(() => + fixture.Repository.AddColumn("COL", "")); + Assert.AreEqual(NamedEntity.Column, exception.Entity); + Assert.AreEqual(1, fixture.Repository.GetColumns().Count); + } + + [TestMethod] + public void AddColumnWithEmptyTitleIsRejected() + { + using var fixture = new RepositoryFixture(); + NameRequiredException exception = Assert.ThrowsExactly(() => + fixture.Repository.AddColumn("", "description")); + Assert.AreEqual(NamedEntity.Column, exception.Entity); + Assert.AreEqual(1, fixture.Repository.GetColumns().Count); + } + + [TestMethod] + public void UpdateColumnConstraintViolationsAreTyped() + { + using var fixture = new RepositoryFixture(); + ColumnModel other = fixture.Repository.AddColumn("Other", ""); + + Assert.ThrowsExactly(() => fixture.Repository.UpdateColumn(other.Id, "col", "")); + Assert.ThrowsExactly(() => fixture.Repository.UpdateColumn(other.Id, "", "")); + Assert.AreEqual("Other", fixture.Repository.GetColumn(other.Id)!.Title); + } + + [TestMethod] + public void GetCardsWithFilterReturnsMatchesWithAllOfTheirTags() + { + using var fixture = new RepositoryFixture(); + fixture.Repository.AddCard(fixture.ColumnId, "Other", "", []); + + // Filtering by one tag must still load both tags of the matching card. + CompiledSearch filter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("tag:ui")!); + IReadOnlyList cards = fixture.Repository.GetCards(filter); + + Assert.AreEqual(1, cards.Count); + Assert.AreEqual(fixture.CardId, cards[0].Id); + CollectionAssert.AreEqual(new[] { "bug", "ui" }, cards[0].Tags.Select(tag => tag.Name).ToArray()); + Assert.AreEqual(2, fixture.Repository.GetCards(null).Count); + } + + [TestMethod] + public void CountCardsCountsPerColumn() + { + using var fixture = new RepositoryFixture(); + ColumnModel empty = fixture.Repository.AddColumn("Empty", ""); + fixture.Repository.AddCard(fixture.ColumnId, "", "", []); + + Assert.AreEqual(2L, fixture.Repository.CountCards(fixture.ColumnId)); + Assert.AreEqual(0L, fixture.Repository.CountCards(empty.Id)); } [TestMethod] diff --git a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs new file mode 100644 index 0000000..5958e89 --- /dev/null +++ b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs @@ -0,0 +1,393 @@ +using YKanBan.Models; +using YKanBan.Search; +using YKanBan.Storage; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; +using YKanBan.ViewModels.Board; +using YKanBan.ViewModels.Dialogs; + +namespace YKanBan.Tests.ViewModels; + +/// +/// View-model tests of the board and its dialogs against a real temporary +/// workspace, with a scripted dialog service standing in for the modal windows. +/// +[TestClass] +public class BoardViewModelTests +{ + /// + /// Records every dialog shown and lets a test script answer it. The result + /// is whatever the dialog closed with; a dialog the script leaves open + /// counts as dismissed. + /// + private sealed class ScriptedDialogService : IDialogService + { + public List Shown { get; } = []; + + public Func Script { get; set; } = _ => Task.CompletedTask; + + public async Task ShowAsync(DialogViewModelBase dialog) + { + Shown.Add(dialog); + bool? result = null; + dialog.CloseRequested += (_, closed) => result ??= closed; + await Script(dialog); + return result ?? false; + } + } + + /// Answers every confirmation dialog with the given choice. + private static Func AnswerConfirmations(bool confirm) => dialog => + { + if (dialog is ConfirmDialogViewModel confirmation) + { + if (confirm) + { + confirmation.ConfirmCommand.Execute(null); + return Task.CompletedTask; + } + return confirmation.CancelCommand.ExecuteAsync(null); + } + return Task.CompletedTask; + }; + + private sealed class Fixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public WorkspaceRepository Repository { get; } + + public AppConfig Config { get; } = new(); + + public ScriptedDialogService Dialogs { get; } = new(); + + public Fixture() + { + WorkspaceInitializer.Initialize(Directory.FullPath); + Repository = new WorkspaceRepository(Directory.FullPath); + } + + public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs); + + public void Dispose() + { + Repository.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void RefreshListsColumnsInIdOrderAndSortsCardsWithinEachColumn() + { + using var fixture = new Fixture(); + ColumnModel first = fixture.Repository.AddColumn("A", ""); + ColumnModel second = fixture.Repository.AddColumn("B", ""); + fixture.Repository.AddCard(first.Id, "zeta", "", []); + fixture.Repository.AddCard(first.Id, "alpha", "", []); + fixture.Repository.AddCard(second.Id, "x", "", []); + + fixture.Config.Sort.Card = CardSortOption.Title; + BoardViewModel board = fixture.CreateBoard(); + + CollectionAssert.AreEqual(new[] { "A", "B" }, board.Columns.Select(column => column.Title).ToArray()); + CollectionAssert.AreEqual(new[] { "alpha", "zeta" }, board.Columns[0].Cards.Select(card => card.Title).ToArray()); + Assert.AreEqual(1, board.Columns[1].CardCount); + } + + [TestMethod] + public void ChangingTheSortPersistsItToTheConfigurationAndResorts() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "b", "", []); + fixture.Repository.AddCard(column.Id, "a", "", []); + BoardViewModel board = fixture.CreateBoard(); + CollectionAssert.AreEqual(new[] { "b", "a" }, board.Columns[0].Cards.Select(card => card.Title).ToArray()); + + board.SelectedSort = board.SortOptions.Single(option => (CardSortOption)option.Value == CardSortOption.Title); + + Assert.AreEqual(CardSortOption.Title, fixture.Config.Sort.Card); + CollectionAssert.AreEqual(new[] { "a", "b" }, board.Columns[0].Cards.Select(card => card.Title).ToArray()); + } + + [TestMethod] + public async Task WritesRefreshWithTheActiveFilterAndKeepEmptyColumns() + { + using var fixture = new Fixture(); + ColumnModel todo = fixture.Repository.AddColumn("Todo", ""); + ColumnModel done = fixture.Repository.AddColumn("Done", ""); + CardModel match = fixture.Repository.AddCard(todo.Id, "match me", "", []); + fixture.Repository.AddCard(todo.Id, "other", "", []); + + BoardViewModel board = fixture.CreateBoard(); + board.ActiveFilter = SearchSqlCompiler.Compile(SearchQueryParser.Parse("match")!); + board.Refresh(); + Assert.AreEqual(1, board.Columns[0].CardCount); + + // A move is a write: the board re-queries with the active filter. + CardViewModel card = board.Columns[0].Cards.Single(); + MoveTargetViewModel target = card.MoveTargets.Single(entry => entry.ColumnId == done.Id); + Assert.IsFalse(card.MoveTargets.Single(entry => entry.ColumnId == todo.Id).IsEnabled); + await target.Command.ExecuteAsync(target.ColumnId); + + Assert.AreEqual(2, board.Columns.Count, "Columns emptied by the filter stay on the board."); + Assert.AreEqual(0, board.Columns[0].CardCount); + Assert.AreEqual(match.Id, board.Columns[1].Cards.Single().Card.Id); + } + + [TestMethod] + public async Task DeclinedColumnDeletionKeepsTheColumnAndShowsItsCardCount() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "", "", []); + fixture.Repository.AddCard(column.Id, "", "", []); + BoardViewModel board = fixture.CreateBoard(); + fixture.Dialogs.Script = AnswerConfirmations(false); + + await board.Columns[0].DeleteCommand.ExecuteAsync(null); + + var confirmation = (ConfirmDialogViewModel)fixture.Dialogs.Shown.Single(); + StringAssert.Contains(confirmation.Message, "2"); + Assert.AreEqual(1, fixture.Repository.GetColumns().Count); + } + + [TestMethod] + public async Task ColumnDeletionWithoutConfirmationDeletesImmediately() + { + using var fixture = new Fixture(); + fixture.Repository.AddColumn("A", ""); + fixture.Config.Confirmations.DeleteColumn = false; + BoardViewModel board = fixture.CreateBoard(); + + await board.Columns[0].DeleteCommand.ExecuteAsync(null); + + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + Assert.AreEqual(0, board.Columns.Count); + } + + [TestMethod] + public async Task ConfirmedCardDeletionDeletesTheCard() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "gone", "", []); + BoardViewModel board = fixture.CreateBoard(); + fixture.Dialogs.Script = AnswerConfirmations(true); + + await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null); + + Assert.AreEqual(0, board.Columns[0].CardCount); + Assert.AreEqual(0, fixture.Repository.GetCards().Count); + } + + [TestMethod] + public async Task DuplicateColumnTitleIsReportedInsideTheDialog() + { + using var fixture = new Fixture(); + fixture.Repository.AddColumn("Todo", ""); + BoardViewModel board = fixture.CreateBoard(); + ColumnEditorDialogViewModel? editor = null; + bool closed = false; + fixture.Dialogs.Script = dialog => + { + editor = (ColumnEditorDialogViewModel)dialog; + editor.CloseRequested += (_, _) => closed = true; + editor.ColumnTitle = "TODO"; + editor.ConfirmCommand.Execute(null); + return Task.CompletedTask; + }; + + await board.AddColumnCommand.ExecuteAsync(null); + + Assert.IsNotNull(editor); + Assert.IsFalse(closed, "The dialog stays open on a constraint violation."); + Assert.IsTrue(editor.HasError); + StringAssert.Contains(editor.ErrorMessage, "TODO"); + Assert.AreEqual(1, fixture.Repository.GetColumns().Count); + } + + [TestMethod] + public async Task NewCardIsWrittenWithItsTagsOnConfirmation() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(255, 0, 0), ""); + BoardViewModel board = fixture.CreateBoard(); + fixture.Dialogs.Script = async dialog => + { + switch (dialog) + { + case CardEditorDialogViewModel editor: + editor.CardTitle = "Fix it"; + editor.Content = "Details"; + await editor.AddTagCommand.ExecuteAsync(null); + editor.ConfirmCommand.Execute(null); + break; + case TagPickerDialogViewModel picker: + picker.PickCommand.Execute(picker.FilteredTags.Single()); + break; + } + }; + + await board.Columns[0].AddCardCommand.ExecuteAsync(null); + + CardModel card = fixture.Repository.GetCards().Single(); + Assert.AreEqual("Fix it", card.Title); + Assert.AreEqual("Details", card.Content); + Assert.AreEqual(bug.Id, card.Tags.Single().Id); + Assert.AreEqual(card.Id, board.Columns[0].Cards.Single().Card.Id); + } + + [TestMethod] + public void UnchangedEditConfirmationWritesNothing() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + CardModel card = fixture.Repository.AddCard(column.Id, "t", "c", []); + using (var connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath)) + { + SqliteTestHelper.Exec(connection, "UPDATE cards SET updated_at = 100;"); + } + card = fixture.Repository.GetCard(card.Id)!; + + var editor = CardEditorDialogViewModel.ForExistingCard(fixture.Repository, fixture.Dialogs, card, confirmDiscard: true); + editor.CardTitle = "changed"; + editor.CardTitle = "t"; + Assert.IsFalse(editor.IsDirty); + editor.ConfirmCommand.Execute(null); + + Assert.AreEqual(100, fixture.Repository.GetCard(card.Id)!.UpdatedAt); + } + + [TestMethod] + public async Task DirtyCancelAsksAndStaysOpenWhenDeclined() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true); + bool? closedWith = null; + editor.CloseRequested += (_, result) => closedWith = result; + editor.Content = "draft"; + + fixture.Dialogs.Script = AnswerConfirmations(false); + await editor.RequestCancelAsync(); + Assert.IsNull(closedWith, "Declining the discard keeps the editor open."); + + fixture.Dialogs.Script = AnswerConfirmations(true); + await editor.RequestCancelAsync(); + Assert.IsFalse(closedWith); + Assert.AreEqual(0, fixture.Repository.GetCards().Count); + } + + [TestMethod] + public async Task CleanCancelClosesWithoutAsking() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: true); + bool? closedWith = null; + editor.CloseRequested += (_, result) => closedWith = result; + + await editor.RequestCancelAsync(); + + Assert.IsFalse(closedWith); + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + } + + [TestMethod] + public async Task TagCreatedInThePickerSurvivesACancelledCardEdit() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + var editor = CardEditorDialogViewModel.ForNewCard(fixture.Repository, fixture.Dialogs, column.Id, confirmDiscard: false); + fixture.Dialogs.Script = dialog => + { + if (dialog is TagPickerDialogViewModel picker) + { + picker.NewName = "fresh"; + picker.NewColor = "#123456"; + picker.CreateCommand.Execute(null); + } + return Task.CompletedTask; + }; + + await editor.AddTagCommand.ExecuteAsync(null); + Assert.AreEqual("fresh", editor.Tags.Single().Name); + await editor.RequestCancelAsync(); + + Assert.AreEqual("fresh", fixture.Repository.GetTags().Single().Name); + Assert.AreEqual(0, fixture.Repository.GetCards().Count); + } + + [TestMethod] + public void TagPickerReportsInvalidColorAndDuplicateNameWithoutClosing() + { + using var fixture = new Fixture(); + fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + var picker = new TagPickerDialogViewModel(fixture.Repository, []); + bool closed = false; + picker.CloseRequested += (_, _) => closed = true; + + picker.NewName = "new"; + picker.NewColor = "red"; + picker.CreateCommand.Execute(null); + Assert.AreEqual(YKanBan.Resources.Error_Tag_InvalidColor, picker.ErrorMessage); + + picker.NewName = "BUG"; + picker.NewColor = "#ff0000"; + picker.CreateCommand.Execute(null); + StringAssert.Contains(picker.ErrorMessage, "BUG"); + + Assert.IsFalse(closed); + Assert.AreEqual(1, fixture.Repository.GetTags().Count); + } + + [TestMethod] + public void TagPickerHidesAssignedTagsAndFiltersCaseInsensitively() + { + using var fixture = new Fixture(); + TagModel bug = fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), ""); + fixture.Repository.AddTag("UI", new RgbColor(1, 2, 3), ""); + fixture.Repository.AddTag("Build", new RgbColor(1, 2, 3), ""); + var picker = new TagPickerDialogViewModel(fixture.Repository, [bug.Id]); + + picker.FilterText = "bu"; + CollectionAssert.AreEqual(new[] { "Build" }, picker.FilteredTags.Select(tag => tag.Name).ToArray()); + + picker.FilterText = "zzz"; + Assert.IsTrue(picker.NoMatches); + } + + [TestMethod] + public void ColumnWidthDialogCommitsPresetsAndClampsCustomPixels() + { + var settings = new ColumnWidthSettings(); + var dialog = new ColumnWidthDialogViewModel(settings) { IsStandard = false, IsCustom = true, CustomPixels = 5000 }; + dialog.ConfirmCommand.Execute(null); + Assert.AreEqual(ColumnWidthPreset.Custom, settings.Preset); + Assert.AreEqual(AppConfig.MaxColumnPixels, settings.CustomPixels); + Assert.AreEqual(AppConfig.MaxColumnPixels, ColumnWidths.Resolve(settings)); + + dialog = new ColumnWidthDialogViewModel(settings) { IsCustom = false, IsWide = true }; + dialog.ConfirmCommand.Execute(null); + Assert.AreEqual(ColumnWidthPreset.Wide, settings.Preset); + Assert.AreEqual(ColumnWidths.Wide, ColumnWidths.Resolve(settings)); + } + + [TestMethod] + public void CardHeaderAndExcerptFollowTheDisplayRules() + { + var untitled = new CardModel(61, 1, "", "", 0, 0, []); + var titled = untitled with { Title = "Foo bar" }; + Assert.AreEqual("#61", CardViewModel.FormatHeader(untitled)); + Assert.AreEqual("#61 Foo bar", CardViewModel.FormatHeader(titled)); + + Assert.AreEqual("short", CardViewModel.MakeExcerpt(" short \n")); + string longContent = string.Concat(Enumerable.Repeat("😀", CardViewModel.ExcerptLength + 5)); + string excerpt = CardViewModel.MakeExcerpt(longContent); + Assert.IsTrue(excerpt.EndsWith('…')); + Assert.AreEqual((CardViewModel.ExcerptLength * 2) + 1, excerpt.Length, "Surrogate pairs are kept whole."); + } +} diff --git a/YKanBan/App.axaml b/YKanBan/App.axaml index 1f67adf..d35fef0 100644 --- a/YKanBan/App.axaml +++ b/YKanBan/App.axaml @@ -26,10 +26,20 @@ + + + + + + + + + + diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs index dae177c..c6fe85b 100644 --- a/YKanBan/App.axaml.cs +++ b/YKanBan/App.axaml.cs @@ -58,12 +58,15 @@ public partial class App : Application : FlowDirection.LeftToRight; ApplyTheme(Services.Config.Theme); - var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []); - desktop.MainWindow = new MainWindow + var dialogs = new DialogService(); + var viewModel = new MainWindowViewModel(Services, dialogs, desktop.Args ?? []); + var mainWindow = new MainWindow { DataContext = viewModel, FlowDirection = UiFlowDirection, }; + dialogs.Attach(mainWindow); + desktop.MainWindow = mainWindow; } base.OnFrameworkInitializationCompleted(); diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index af49c83..04ddc8f 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -184,4 +184,160 @@ | Name | Color | Description | Usage | + + Delete + + + Discard + + + yyyy-MM-dd HH:mm + + + New column + + + Column width + + + Card order + + + New card + + + By id + + + Recently modified + + + By title + + + More + + + Edit + + + Delete + + + Move to column + + + Title + + + Description + + + Content + + + Tags + + + Name + + + #RRGGBB + + + New column + + + Edit column + + + Column width + + + Narrow ({0} px) + + + Standard ({0} px) + + + Wide ({0} px) + + + Custom + + + {0}–{1} px + + + New card + + + Edit card + + + No title + + + Created {0} + + + Modified {0} + + + Add tag + + + Remove tag + + + Add tag + + + Filter tags + + + No matching tags + + + New tag + + + Create and add + + + Confirm + + + Delete card "{0}"? + + + Delete column "{0}" and its {1} card(s)? + + + Discard the unsaved changes? + + + The column title is required. + + + A column named "{0}" already exists (names are case-insensitive). + + + The tag name is required. + + + A tag named "{0}" already exists (names are case-insensitive). + + + The color must be written as #RRGGBB. + + + Saving failed: {0} + + + Operation failed + + + The change could not be written to the workspace. The board shows the stored state. + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 83e7641..88e44a6 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -184,4 +184,160 @@ | 名称 | 颜色 | 描述 | 使用数 | + + 删除 + + + 丢弃 + + + yyyy年M月d日 HH:mm + + + 新建列 + + + 列宽 + + + 卡片排序 + + + 新建卡片 + + + 按 ID + + + 最近修改 + + + 按标题 + + + 更多 + + + 编辑 + + + 删除 + + + 移动到列 + + + 标题 + + + 描述 + + + 内容 + + + 标签 + + + 名称 + + + #RRGGBB + + + 新建列 + + + 编辑列 + + + 列宽 + + + 窄({0} px) + + + 标准({0} px) + + + 宽({0} px) + + + 自定义 + + + {0}–{1} px + + + 新建卡片 + + + 编辑卡片 + + + 无标题 + + + 创建于 {0} + + + 修改于 {0} + + + 添加标签 + + + 移除标签 + + + 添加标签 + + + 筛选标签 + + + 没有匹配的标签 + + + 新建标签 + + + 新建并添加 + + + 确认 + + + 删除卡片“{0}”? + + + 删除列“{0}”及其中的 {1} 张卡片? + + + 放弃未保存的更改? + + + 列标题不能为空。 + + + 已存在名为“{0}”的列(名称不区分大小写)。 + + + 标签名称不能为空。 + + + 已存在名为“{0}”的标签(名称不区分大小写)。 + + + 颜色须为 #RRGGBB 格式。 + + + 保存失败:{0} + + + 操作失败 + + + 更改未能写入工作区,看板显示的是已保存的状态。 + diff --git a/YKanBan/Storage/StorageExceptions.cs b/YKanBan/Storage/StorageExceptions.cs index 4e6b69d..de0b738 100644 --- a/YKanBan/Storage/StorageExceptions.cs +++ b/YKanBan/Storage/StorageExceptions.cs @@ -136,3 +136,69 @@ public sealed class SchemaVersionException : YKanBanException SupportedVersion = supportedVersion; } } + +/// +/// The entities whose name/title is constrained by the schema to be non-empty +/// and table-unique. +/// +public enum NamedEntity +{ + /// A board column (constraint on columns.title). + Column, + + /// A tag (constraint on tags.name). + Tag, +} + +/// +/// Signals that a column title or tag name was empty, violating the schema's +/// CHECK constraint. Nothing was written. +/// +public sealed class NameRequiredException : YKanBanException +{ + /// + /// Gets the entity whose name was empty. + /// + public NamedEntity Entity { get; } + + /// + /// Initializes the exception for an empty name. + /// + /// The entity whose name was empty. + /// The underlying constraint violation. + public NameRequiredException(NamedEntity entity, Exception innerException) + : base($"The {entity.ToString().ToLowerInvariant()} name must not be empty.", innerException) + { + Entity = entity; + } +} + +/// +/// Signals that a column title or tag name already exists (compared with ASCII +/// case folding), violating the schema's UNIQUE constraint. Nothing was written. +/// +public sealed class DuplicateNameException : YKanBanException +{ + /// + /// Gets the entity whose name collided. + /// + public NamedEntity Entity { get; } + + /// + /// Gets the rejected name. + /// + public string Name { get; } + + /// + /// Initializes the exception for a name collision. + /// + /// The entity whose name collided. + /// The rejected name. + /// The underlying constraint violation. + public DuplicateNameException(NamedEntity entity, string name, Exception innerException) + : base($"A {entity.ToString().ToLowerInvariant()} named '{name}' already exists.", innerException) + { + Entity = entity; + Name = name; + } +} diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs index 479296c..60bfd3d 100644 --- a/YKanBan/Storage/Workspace/WorkspaceRepository.cs +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using YKanBan.Models; +using YKanBan.Search; namespace YKanBan.Storage.Workspace; @@ -71,14 +72,15 @@ public sealed class WorkspaceRepository : IDisposable /// The non-empty, table-unique title. /// The optional description. /// The created column. - /// The title is empty or not unique. + /// The title is empty. + /// The title is not unique. public ColumnModel AddColumn(string title, string description) { long now = UnixTime.Now; using var transaction = _connection.BeginTransaction(); - long id = ExecuteInsert(transaction, + long id = MapNameConstraint(NamedEntity.Column, title, () => ExecuteInsert(transaction, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);", - ("$title", title), ("$description", description), ("$now", now)); + ("$title", title), ("$description", description), ("$now", now))); transaction.Commit(); return new ColumnModel(id, title, description, now, now); @@ -93,7 +95,8 @@ public sealed class WorkspaceRepository : IDisposable /// The new description. /// The updated column, or the unchanged one for a no-op. /// The column does not exist. - /// The new title is empty or not unique. + /// The new title is empty. + /// The new title is not unique. public ColumnModel UpdateColumn(long columnId, string title, string description) { ColumnModel existing = GetColumn(columnId) @@ -112,7 +115,7 @@ public sealed class WorkspaceRepository : IDisposable command.Parameters.AddWithValue("$description", description); command.Parameters.AddWithValue("$now", now); command.Parameters.AddWithValue("$id", columnId); - command.ExecuteNonQuery(); + MapNameConstraint(NamedEntity.Column, title, command.ExecuteNonQuery); return new ColumnModel(columnId, title, description, existing.CreatedAt, now); } @@ -132,16 +135,34 @@ public sealed class WorkspaceRepository : IDisposable /// Returns all cards with their assigned tags loaded, ordered by id ascending. /// /// All cards in id order. - public IReadOnlyList GetCards() + public IReadOnlyList GetCards() => GetCards(null); + + /// + /// Returns the cards matching a compiled search, with all of their assigned + /// tags loaded (the filter selects cards, it never trims their tag lists), + /// ordered by id ascending. + /// + /// The compiled search, or for every card. + /// The matching cards in id order. + public IReadOnlyList GetCards(CompiledSearch? filter) { - using var command = CreateCommand(""" + string where = filter is null ? "" : $"WHERE {filter.Predicate}"; + using var command = CreateCommand($""" SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, t.id, t.name, t.color, t.description FROM cards c LEFT JOIN card_tags ct ON ct.card_id = c.id LEFT JOIN tags t ON t.id = ct.tag_id - ORDER BY c.id ASC; + {where} + ORDER BY c.id ASC, t.id ASC; """); + if (filter is not null) + { + foreach ((string name, object value) in filter.Parameters) + { + command.Parameters.AddWithValue(name, value); + } + } using var reader = command.ExecuteReader(); // Single grouped pass: one output card per distinct card id, with its tags accumulated alongside. @@ -186,7 +207,7 @@ public sealed class WorkspaceRepository : IDisposable LEFT JOIN card_tags ct ON ct.card_id = c.id LEFT JOIN tags t ON t.id = ct.tag_id WHERE c.id = $id - ORDER BY c.id ASC; + ORDER BY t.id ASC; """); command.Parameters.AddWithValue("$id", cardId); using var reader = command.ExecuteReader(); @@ -302,6 +323,18 @@ public sealed class WorkspaceRepository : IDisposable return command.ExecuteNonQuery() > 0; } + /// + /// Counts the cards of a column, regardless of any search filter. + /// + /// The column id. + /// The number of cards in the column. + public long CountCards(long columnId) + { + using var command = CreateCommand("SELECT COUNT(*) FROM cards WHERE column_id = $id;"); + command.Parameters.AddWithValue("$id", columnId); + return (long)command.ExecuteScalar()!; + } + /// /// Deletes a card; the DDL cascade removes its tag links. /// @@ -361,13 +394,14 @@ public sealed class WorkspaceRepository : IDisposable /// The tag color. /// The optional description. /// The created tag. - /// The name is empty or not unique. + /// The name is empty. + /// The name is not unique. public TagModel AddTag(string name, RgbColor color, string description) { using var transaction = _connection.BeginTransaction(); - long id = ExecuteInsert(transaction, + long id = MapNameConstraint(NamedEntity.Tag, name, () => ExecuteInsert(transaction, "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);", - ("$name", name), ("$color", color.ToString()), ("$description", description)); + ("$name", name), ("$color", color.ToString()), ("$description", description))); transaction.Commit(); return new TagModel(id, name, color, description); @@ -382,7 +416,8 @@ public sealed class WorkspaceRepository : IDisposable /// The new description. /// The updated tag, or the unchanged one for a no-op. /// The tag does not exist. - /// The new name is empty or not unique. + /// The new name is empty. + /// The new name is not unique. public TagModel UpdateTag(long tagId, string name, RgbColor color, string description) { TagModel existing = GetTag(tagId) @@ -400,7 +435,7 @@ public sealed class WorkspaceRepository : IDisposable command.Parameters.AddWithValue("$color", color.ToString()); command.Parameters.AddWithValue("$description", description); command.Parameters.AddWithValue("$id", tagId); - command.ExecuteNonQuery(); + MapNameConstraint(NamedEntity.Tag, name, command.ExecuteNonQuery); return new TagModel(tagId, name, color, description); } @@ -416,6 +451,40 @@ public sealed class WorkspaceRepository : IDisposable #region Helpers + /// SQLite extended result code of a failed CHECK constraint. + private const int SqliteConstraintCheck = 275; + + /// SQLite extended result code of a failed UNIQUE constraint. + private const int SqliteConstraintUnique = 2067; + + /// + /// Runs a write against columns.title or tags.name and translates the + /// schema's non-empty (CHECK) and uniqueness (UNIQUE) violations into typed + /// exceptions that the UI maps to localized messages. + /// + /// The write's result type. + /// The entity being written. + /// The name or title being written. + /// The write to run. + /// The write's result. + /// The name is empty. + /// The name is not unique. + private static T MapNameConstraint(NamedEntity entity, string name, Func write) + { + try + { + return write(); + } + catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintCheck) + { + throw new NameRequiredException(entity, exception); + } + catch (SqliteException exception) when (exception.SqliteExtendedErrorCode == SqliteConstraintUnique) + { + throw new DuplicateNameException(entity, name, exception); + } + } + /// /// Returns the tag with the given id. /// diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs new file mode 100644 index 0000000..5509b64 --- /dev/null +++ b/YKanBan/ViewModels/Board/BoardViewModel.cs @@ -0,0 +1,270 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.Search; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Dialogs; + +namespace YKanBan.ViewModels.Board; + +/// +/// The board tab: the toolbar (new column, column width, card sort) and the +/// columns laid out left to right, each listing its cards top to bottom. +/// +/// Every write is followed by , which re-queries with the +/// most recently successful search expression (), not +/// with whatever the search box currently holds: cards that no longer match +/// disappear, new cards that do not match are not shown, and empty columns stay. +/// +public sealed partial class BoardViewModel : ViewModelBase +{ + private readonly WorkspaceRepository _repository; + private readonly AppConfig _config; + private readonly IDialogService _dialogs; + + /// + /// Initializes the board and loads it. + /// + /// The workspace repository. + /// The live application configuration (sort, column width, confirmations). + /// The modal dialog service. + public BoardViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs) + { + _repository = repository; + _config = config; + _dialogs = dialogs; + + SortOptions = + [ + new OptionItem(CardSortOption.Id, Resources.Sort_Id), + new OptionItem(CardSortOption.UpdatedAt, Resources.Sort_UpdatedAt), + new OptionItem(CardSortOption.Title, Resources.Sort_Title), + ]; + selectedSort = SortOptions.FirstOrDefault(option => (CardSortOption)option.Value == config.Sort.Card) + ?? SortOptions[0]; + columnWidth = ColumnWidths.Resolve(config.ColumnWidth); + + Refresh(); + } + + /// + /// Gets the columns in id order. + /// + public ObservableCollection Columns { get; } = []; + + /// + /// Gets the card sort options shown in the combobox. + /// + public IReadOnlyList SortOptions { get; } + + /// + /// Gets the column models in id order, as of the last refresh. + /// + public IReadOnlyList ColumnModels { get; private set; } = []; + + /// + /// Gets or sets the most recently successful search; shows every card. + /// + public CompiledSearch? ActiveFilter { get; set; } + + /// + /// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board. + /// + [ObservableProperty] + private OptionItem selectedSort; + + /// + /// Gets the effective column width in pixels. + /// + [ObservableProperty] + private int columnWidth; + + /// + /// Reloads the columns and the cards matching . + /// + public void Refresh() + { + IReadOnlyList columns = _repository.GetColumns(); + ILookup cardsByColumn = _repository.GetCards(ActiveFilter).ToLookup(card => card.ColumnId); + var sort = (CardSortOption)SelectedSort.Value; + + ColumnModels = columns; + Columns.Clear(); + foreach (ColumnModel column in columns) + { + Columns.Add(new ColumnViewModel(this, column, CardOrdering.Sort(cardsByColumn[column.Id], sort))); + } + } + + /// + /// Opens the new-column dialog. + /// + /// A task completing when the dialog closes. + [RelayCommand] + private async Task AddColumnAsync() + { + var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description)); + if (await _dialogs.ShowAsync(dialog)) + { + Refresh(); + } + } + + /// + /// Opens the column width dialog and applies the choice. + /// + /// A task completing when the dialog closes. + [RelayCommand] + private async Task AdjustColumnWidthAsync() + { + if (await _dialogs.ShowAsync(new ColumnWidthDialogViewModel(_config.ColumnWidth))) + { + ColumnWidth = ColumnWidths.Resolve(_config.ColumnWidth); + } + } + + /// + /// Opens the edit-column dialog. + /// + /// The column to edit. + /// A task completing when the dialog closes. + internal async Task EditColumnAsync(ColumnModel column) + { + var dialog = new ColumnEditorDialogViewModel( + column, (title, description) => _repository.UpdateColumn(column.Id, title, description)); + if (await _dialogs.ShowAsync(dialog)) + { + Refresh(); + } + } + + /// + /// Deletes a column with its cards, asking first when that confirmation is enabled. + /// + /// The column to delete. + /// A task completing when the column is deleted or the deletion was declined. + internal async Task DeleteColumnAsync(ColumnModel column) + { + await RunWriteAsync(async () => + { + if (_config.Confirmations.DeleteColumn) + { + long count = _repository.CountCards(column.Id); + string message = string.Format(Resources.Culture, Resources.Confirm_DeleteColumn, column.Title, count); + if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete)) + { + return false; + } + } + _repository.DeleteColumn(column.Id); + return true; + }); + } + + /// + /// Opens the card editor for a new card in a column. + /// + /// The column receiving the card. + /// A task completing when the editor closes. + internal async Task AddCardAsync(long columnId) + { + var editor = CardEditorDialogViewModel.ForNewCard( + _repository, _dialogs, columnId, _config.Confirmations.DiscardEdit); + await _dialogs.ShowAsync(editor); + + // Refresh even after a cancel: the nested picker may have created tags. + Refresh(); + } + + /// + /// Opens the card editor for an existing card, reloaded from the database. + /// + /// The card to edit. + /// A task completing when the editor closes. + internal async Task EditCardAsync(long cardId) + { + CardModel? card = _repository.GetCard(cardId); + if (card is null) + { + Refresh(); + return; + } + + var editor = CardEditorDialogViewModel.ForExistingCard( + _repository, _dialogs, card, _config.Confirmations.DiscardEdit); + await _dialogs.ShowAsync(editor); + Refresh(); + } + + /// + /// Deletes a card, asking first when that confirmation is enabled. + /// + /// The card to delete. + /// A task completing when the card is deleted or the deletion was declined. + internal async Task DeleteCardAsync(CardModel card) + { + await RunWriteAsync(async () => + { + if (_config.Confirmations.DeleteCard) + { + string message = string.Format(Resources.Culture, Resources.Confirm_DeleteCard, CardViewModel.FormatHeader(card)); + if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete)) + { + return false; + } + } + _repository.DeleteCard(card.Id); + return true; + }); + } + + /// + /// Moves a card to another column. + /// + /// The card to move. + /// The target column. + /// A task completing when the card is moved. + internal Task MoveCardAsync(long cardId, long columnId) => + RunWriteAsync(() => + { + _repository.MoveCard(cardId, columnId); + return Task.FromResult(true); + }); + + /// + /// Runs an immediate write and refreshes after it; an unexpected failure is + /// reported in a message dialog and the board is refreshed to the stored state. + /// + /// The write; returns when the user declined it. + /// A task completing after the refresh. + private async Task RunWriteAsync(Func> write) + { + try + { + if (!await write()) + { + return; + } + } + catch (Exception exception) + { + Debug.WriteLine(exception); + await _dialogs.ShowMessageAsync( + Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message); + } + Refresh(); + } + + /// + /// Persists the sort option and re-sorts the board. + /// + /// The new sort option. + partial void OnSelectedSortChanged(OptionItem value) + { + _config.Sort.Card = (CardSortOption)value.Value; + Refresh(); + } +} diff --git a/YKanBan/ViewModels/Board/CardViewModel.cs b/YKanBan/ViewModels/Board/CardViewModel.cs new file mode 100644 index 0000000..3739d1a --- /dev/null +++ b/YKanBan/ViewModels/Board/CardViewModel.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; + +namespace YKanBan.ViewModels.Board; + +/// +/// One card in a column list: "#id" in bold on the same line as the title, the +/// content cut to a fixed length, and the tag badges below. No timestamps. +/// Clicking the card opens the editor; its menu deletes or moves it. +/// +public sealed partial class CardViewModel : ViewModelBase +{ + /// + /// The number of text elements of content shown on the card. + /// + public const int ExcerptLength = 200; + + private readonly BoardViewModel _board; + + /// + /// Initializes the card. + /// + /// The owning board. + /// The card shown. + public CardViewModel(BoardViewModel board, CardModel card) + { + _board = board; + Card = card; + IdText = $"#{card.Id.ToString(CultureInfo.InvariantCulture)}"; + Excerpt = MakeExcerpt(card.Content); + Tags = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList(); + } + + /// + /// Gets the card shown. + /// + public CardModel Card { get; } + + /// + /// Gets the "#id" text. + /// + public string IdText { get; } + + /// + /// Gets the title; empty when the card has none. + /// + public string Title => Card.Title; + + /// + /// Gets the title as it follows "#id" on the header line: a space and the + /// title, or nothing when the card has no title. + /// + public string TitleSuffix => Card.Title.Length == 0 ? "" : " " + Card.Title; + + /// + /// Gets the truncated content. + /// + public string Excerpt { get; } + + /// + /// Gets whether there is content to show. + /// + public bool HasExcerpt => Excerpt.Length > 0; + + /// + /// Gets the tag badges. + /// + public IReadOnlyList Tags { get; } + + /// + /// Gets whether the card has tags. + /// + public bool HasTags => Tags.Count > 0; + + /// + /// Gets the "move to column" targets; built when the menu asks for them. + /// The card's current column is listed but disabled. + /// + public IReadOnlyList MoveTargets => + _board.ColumnModels + .Select(column => new MoveTargetViewModel(column.Title, column.Id != Card.ColumnId, MoveToCommand, column.Id)) + .ToList(); + + /// + /// Formats the "#id title" header text of a card ("#id" alone without a title). + /// + /// The card. + /// The header text. + public static string FormatHeader(CardModel card) => + card.Title.Length == 0 + ? $"#{card.Id.ToString(CultureInfo.InvariantCulture)}" + : $"#{card.Id.ToString(CultureInfo.InvariantCulture)} {card.Title}"; + + /// + /// Cuts content to text elements, appending an + /// ellipsis when something was cut. Surrogate pairs and combining sequences + /// are never split. + /// + /// The card content. + /// The excerpt. + public static string MakeExcerpt(string content) + { + string trimmed = content.Trim(); + var info = new StringInfo(trimmed); + return info.LengthInTextElements <= ExcerptLength + ? trimmed + : info.SubstringByTextElements(0, ExcerptLength).TrimEnd() + "…"; + } + + /// + /// Opens the card editor. + /// + /// A task completing when the editor closes. + [RelayCommand] + private Task EditAsync() => _board.EditCardAsync(Card.Id); + + /// + /// Deletes the card. + /// + /// A task completing when the card is deleted or the deletion was declined. + [RelayCommand] + private Task DeleteAsync() => _board.DeleteCardAsync(Card); + + /// + /// Moves the card to another column. + /// + /// The target column. + /// A task completing when the card is moved. + [RelayCommand] + private Task MoveToAsync(long columnId) => _board.MoveCardAsync(Card.Id, columnId); +} + +/// +/// An entry of a card's "move to column" submenu. +/// +/// The column title. +/// Whether the entry can be picked (false for the card's own column). +/// The move command. +/// The target column, passed to . +public sealed record MoveTargetViewModel(string Title, bool IsEnabled, IAsyncRelayCommand Command, long ColumnId); diff --git a/YKanBan/ViewModels/Board/ColumnViewModel.cs b/YKanBan/ViewModels/Board/ColumnViewModel.cs new file mode 100644 index 0000000..873928e --- /dev/null +++ b/YKanBan/ViewModels/Board/ColumnViewModel.cs @@ -0,0 +1,77 @@ +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; + +namespace YKanBan.ViewModels.Board; + +/// +/// One board column: its header (title, card count, description, the new-card +/// button and the edit/delete menu) and its virtualized card list. +/// +public sealed partial class ColumnViewModel : ViewModelBase +{ + private readonly BoardViewModel _board; + + /// + /// Initializes the column. + /// + /// The owning board. + /// The column shown. + /// The column's visible cards, in display order. + public ColumnViewModel(BoardViewModel board, ColumnModel column, IReadOnlyList cards) + { + _board = board; + Column = column; + Cards = cards.Select(card => new CardViewModel(board, card)).ToList(); + } + + /// + /// Gets the column shown. + /// + public ColumnModel Column { get; } + + /// + /// Gets the column title. + /// + public string Title => Column.Title; + + /// + /// Gets the column description. + /// + public string Description => Column.Description; + + /// + /// Gets whether the column has a description to show. + /// + public bool HasDescription => Column.Description.Length > 0; + + /// + /// Gets the number of visible cards. + /// + public int CardCount => Cards.Count; + + /// + /// Gets the visible cards in display order. + /// + public IReadOnlyList Cards { get; } + + /// + /// Opens the card editor for a new card in this column. + /// + /// A task completing when the editor closes. + [RelayCommand] + private Task AddCardAsync() => _board.AddCardAsync(Column.Id); + + /// + /// Opens the edit-column dialog. + /// + /// A task completing when the dialog closes. + [RelayCommand] + private Task EditAsync() => _board.EditColumnAsync(Column); + + /// + /// Deletes the column with its cards. + /// + /// A task completing when the column is deleted or the deletion was declined. + [RelayCommand] + private Task DeleteAsync() => _board.DeleteColumnAsync(Column); +} diff --git a/YKanBan/ViewModels/Board/TagBadgeViewModel.cs b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs new file mode 100644 index 0000000..e72c46a --- /dev/null +++ b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs @@ -0,0 +1,57 @@ +using Avalonia.Media; +using Avalonia.Media.Immutable; +using YKanBan.Models; + +namespace YKanBan.ViewModels.Board; + +/// +/// A colored tag badge (color + name). Tag colors are user data: they are shown +/// as the literal #RRGGBB value in both themes, with a black or white text color +/// chosen for contrast. +/// +public sealed class TagBadgeViewModel : ViewModelBase +{ + /// + /// Initializes the badge. + /// + /// The tag shown. + public TagBadgeViewModel(TagModel tag) + { + Tag = tag; + Background = new ImmutableSolidColorBrush(Color.FromRgb(tag.Color.R, tag.Color.G, tag.Color.B)); + Foreground = IsLight(tag.Color) ? Brushes.Black : Brushes.White; + } + + /// + /// Gets the tag shown. + /// + public TagModel Tag { get; } + + /// + /// Gets the tag name. + /// + public string Name => Tag.Name; + + /// + /// Gets the tag description. + /// + public string Description => Tag.Description; + + /// + /// Gets the badge fill: the tag's own color. + /// + public IBrush Background { get; } + + /// + /// Gets the text color contrasting with . + /// + public IBrush Foreground { get; } + + /// + /// Decides whether dark text reads better on a color, by its relative luminance. + /// + /// The background color. + /// for light colors. + public static bool IsLight(RgbColor color) => + (0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B) > 150; +} diff --git a/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs new file mode 100644 index 0000000..59d1b07 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/CardEditorDialogViewModel.cs @@ -0,0 +1,228 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Board; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The card editor — one unit of work. Title, content and the tag assignment +/// are edited in memory and written together in a single transaction on +/// confirmation; cancelling writes nothing about the card or its assignment. +/// Confirming an existing card without effective changes writes nothing and +/// keeps its timestamps. The only immediate write is the creation of a new tag +/// in the nested tag picker (see ). +/// +public sealed partial class CardEditorDialogViewModel : DialogViewModelBase +{ + private readonly WorkspaceRepository _repository; + private readonly IDialogService _dialogs; + private readonly CardModel? _existing; + private readonly long _columnId; + private readonly bool _confirmDiscard; + private readonly string _initialTitle; + private readonly string _initialContent; + private readonly HashSet _initialTagIds; + + /// + /// Initializes the editor for a new card in a column. + /// + /// The workspace repository. + /// The dialog service, for the nested tag picker and the discard confirmation. + /// The column receiving the new card. + /// Whether discarding changes asks for confirmation. + /// The editor. + public static CardEditorDialogViewModel ForNewCard( + WorkspaceRepository repository, IDialogService dialogs, long columnId, bool confirmDiscard) => + new(repository, dialogs, null, columnId, confirmDiscard); + + /// + /// Initializes the editor for an existing card. + /// + /// The workspace repository. + /// The dialog service, for the nested tag picker and the discard confirmation. + /// The card to edit. + /// Whether discarding changes asks for confirmation. + /// The editor. + public static CardEditorDialogViewModel ForExistingCard( + WorkspaceRepository repository, IDialogService dialogs, CardModel card, bool confirmDiscard) => + new(repository, dialogs, card, card.ColumnId, confirmDiscard); + + private CardEditorDialogViewModel( + WorkspaceRepository repository, IDialogService dialogs, CardModel? existing, long columnId, bool confirmDiscard) + { + _repository = repository; + _dialogs = dialogs; + _existing = existing; + _columnId = columnId; + _confirmDiscard = confirmDiscard; + _initialTitle = existing?.Title ?? ""; + _initialContent = existing?.Content ?? ""; + _initialTagIds = existing?.Tags.Select(tag => tag.Id).ToHashSet() ?? []; + + cardTitle = _initialTitle; + content = _initialContent; + Tags = new ObservableCollection( + existing?.Tags.Select(tag => new TagBadgeViewModel(tag)) ?? []); + + if (existing is null) + { + Title = Resources.CardEditor_TitleNew; + } + else + { + CultureInfo? culture = Resources.Culture; + Title = Resources.CardEditor_TitleEdit; + IdText = $"#{existing.Id.ToString(CultureInfo.InvariantCulture)}"; + CreatedText = string.Format(culture, Resources.CardEditor_Created, FormatTime(existing.CreatedAt)); + ModifiedText = string.Format(culture, Resources.CardEditor_Modified, FormatTime(existing.UpdatedAt)); + } + } + + /// + /// Gets the localized window title. + /// + public string Title { get; } + + /// + /// Gets whether the card already exists (and thus has an id and timestamps to show). + /// + public bool IsExisting => _existing is not null; + + /// + /// Gets the read-only "#id" text of an existing card. + /// + public string? IdText { get; } + + /// + /// Gets the read-only creation time line of an existing card. + /// + public string? CreatedText { get; } + + /// + /// Gets the read-only modification time line of an existing card. + /// + public string? ModifiedText { get; } + + /// + /// Gets the tags assigned in this edit session, in assignment order. + /// + public ObservableCollection Tags { get; } + + /// + /// Gets the card written on confirmation; until then. + /// + public CardModel? SavedCard { get; private set; } + + /// + /// Gets or sets the card title; the empty string means "no title". + /// + [ObservableProperty] + private string cardTitle; + + /// + /// Gets or sets the multiline card content. + /// + [ObservableProperty] + private string content; + + /// + /// Gets the localized error of the last confirmation, or . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + /// + /// Gets whether an error is shown. + /// + public bool HasError => ErrorMessage is not null; + + /// + /// Gets whether the in-memory state differs from the state the editor opened with. + /// + public bool IsDirty => + CardTitle != _initialTitle + || Content != _initialContent + || !_initialTagIds.SetEquals(Tags.Select(tag => tag.Tag.Id)); + + /// + /// Opens the nested tag picker and assigns the picked (or newly created) tag. + /// + /// A task completing when the picker closes. + [RelayCommand] + private async Task AddTagAsync() + { + var picker = new TagPickerDialogViewModel(_repository, Tags.Select(tag => tag.Tag.Id).ToHashSet()); + if (await _dialogs.ShowAsync(picker) && picker.PickedTag is { } picked) + { + Tags.Add(new TagBadgeViewModel(picked)); + } + } + + /// + /// Removes a tag from the in-memory assignment. + /// + /// The tag to remove. + [RelayCommand] + private void RemoveTag(TagBadgeViewModel tag) => Tags.Remove(tag); + + /// + /// Writes the card in one transaction and closes; an unchanged existing card + /// is not written at all. + /// + [RelayCommand] + private void Confirm() + { + if (_existing is not null && !IsDirty) + { + SavedCard = _existing; + Close(true); + return; + } + + long[] tagIds = Tags.Select(tag => tag.Tag.Id).ToArray(); + try + { + SavedCard = _existing is null + ? _repository.AddCard(_columnId, CardTitle, Content, tagIds) + : _repository.UpdateCard(_existing.Id, CardTitle, Content, tagIds); + } + catch (Exception exception) + { + ErrorMessage = WriteErrorMessages.Describe(exception); + return; + } + Close(true); + } + + /// + /// Closes without writing the card, asking first when there are unsaved + /// changes and the discard confirmation is enabled. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); + + /// + public override async Task RequestCancelAsync() + { + if (_confirmDiscard && IsDirty + && !await _dialogs.ConfirmAsync(Resources.Confirm_DiscardEdit, Resources.Common_Discard)) + { + return; + } + Close(false); + } + + /// + /// Formats a Unix-seconds timestamp in local time with the language's pattern. + /// + /// The timestamp. + /// The formatted time. + private static string FormatTime(long unixSeconds) => + DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime() + .ToString(Resources.Common_TimePattern, Resources.Culture); +} diff --git a/YKanBan/ViewModels/Dialogs/ColumnEditorDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/ColumnEditorDialogViewModel.cs new file mode 100644 index 0000000..f3dcc2e --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/ColumnEditorDialogViewModel.cs @@ -0,0 +1,82 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The new-column / edit-column dialog: a required title and an optional +/// description. Confirming writes through the supplied save callback; a +/// schema-constraint violation (empty or duplicate title) is shown inside the +/// dialog, which stays open with the user's input intact. +/// +public sealed partial class ColumnEditorDialogViewModel : DialogViewModelBase +{ + private readonly Action _save; + + /// + /// Initializes the dialog. + /// + /// The column being edited, or for a new column. + /// Writes the title and description; throws the repository's typed exceptions. + public ColumnEditorDialogViewModel(ColumnModel? existing, Action save) + { + _save = save; + Title = existing is null ? Resources.ColumnEditor_TitleNew : Resources.ColumnEditor_TitleEdit; + columnTitle = existing?.Title ?? ""; + description = existing?.Description ?? ""; + } + + /// + /// Gets the localized window title. + /// + public string Title { get; } + + /// + /// Gets or sets the column title being edited (not trimmed). + /// + [ObservableProperty] + private string columnTitle; + + /// + /// Gets or sets the column description being edited. + /// + [ObservableProperty] + private string description; + + /// + /// Gets the localized constraint error of the last confirmation, or . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + /// + /// Gets whether an error is shown. + /// + public bool HasError => ErrorMessage is not null; + + /// + /// Writes the column; closes on success, otherwise shows the constraint error. + /// + [RelayCommand] + private void Confirm() + { + try + { + _save(ColumnTitle, Description); + } + catch (Exception exception) + { + ErrorMessage = WriteErrorMessages.Describe(exception); + return; + } + Close(true); + } + + /// + /// Closes without writing. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); +} diff --git a/YKanBan/ViewModels/Dialogs/ColumnWidthDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/ColumnWidthDialogViewModel.cs new file mode 100644 index 0000000..7e4ca53 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/ColumnWidthDialogViewModel.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Storage.AppData; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The column width dialog: one of the narrow / standard / wide presets or a +/// manual pixel width (160–720). The choice is global and is committed to the +/// live configuration on confirmation only. +/// +public sealed partial class ColumnWidthDialogViewModel : DialogViewModelBase +{ + private readonly ColumnWidthSettings _settings; + + /// + /// Initializes the dialog from the live settings. + /// + /// The live column width settings. + public ColumnWidthDialogViewModel(ColumnWidthSettings settings) + { + _settings = settings; + CultureInfo? culture = Resources.Culture; + NarrowLabel = string.Format(culture, Resources.ColumnWidth_Narrow, ColumnWidths.Narrow); + StandardLabel = string.Format(culture, Resources.ColumnWidth_Standard, ColumnWidths.Standard); + WideLabel = string.Format(culture, Resources.ColumnWidth_Wide, ColumnWidths.Wide); + RangeLabel = string.Format(culture, Resources.ColumnWidth_Range, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels); + + isNarrow = settings.Preset == ColumnWidthPreset.Narrow; + isStandard = settings.Preset == ColumnWidthPreset.Standard; + isWide = settings.Preset == ColumnWidthPreset.Wide; + isCustom = settings.Preset == ColumnWidthPreset.Custom; + customPixels = settings.CustomPixels; + } + + /// Gets the localized narrow preset label. + public string NarrowLabel { get; } + + /// Gets the localized standard preset label. + public string StandardLabel { get; } + + /// Gets the localized wide preset label. + public string WideLabel { get; } + + /// Gets the localized accepted pixel range. + public string RangeLabel { get; } + + /// Gets the smallest accepted custom width. + public int MinPixels => AppConfig.MinColumnPixels; + + /// Gets the largest accepted custom width. + public int MaxPixels => AppConfig.MaxColumnPixels; + + /// Gets or sets whether the narrow preset is selected. + [ObservableProperty] + private bool isNarrow; + + /// Gets or sets whether the standard preset is selected. + [ObservableProperty] + private bool isStandard; + + /// Gets or sets whether the wide preset is selected. + [ObservableProperty] + private bool isWide; + + /// Gets or sets whether the manual width is selected. + [ObservableProperty] + private bool isCustom; + + /// Gets or sets the manual width in pixels; while the box is empty. + [ObservableProperty] + private decimal? customPixels; + + /// + /// Commits the selection to the live settings and closes. + /// + [RelayCommand] + private void Confirm() + { + if (IsCustom) + { + int pixels = (int)Math.Clamp(CustomPixels ?? _settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels); + _settings.Preset = ColumnWidthPreset.Custom; + _settings.CustomPixels = pixels; + } + else + { + _settings.Preset = IsNarrow ? ColumnWidthPreset.Narrow + : IsWide ? ColumnWidthPreset.Wide + : ColumnWidthPreset.Standard; + } + Close(true); + } + + /// + /// Closes without changing anything. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); +} + +/// +/// Pixel widths of the column width presets. +/// +public static class ColumnWidths +{ + /// The narrow preset width. + public const int Narrow = 240; + + /// The standard (default) preset width. + public const int Standard = 320; + + /// The wide preset width. + public const int Wide = 480; + + /// + /// Resolves the effective column width of the settings. + /// + /// The column width settings. + /// The width in pixels. + public static int Resolve(ColumnWidthSettings settings) => settings.Preset switch + { + ColumnWidthPreset.Narrow => Narrow, + ColumnWidthPreset.Wide => Wide, + ColumnWidthPreset.Custom => Math.Clamp(settings.CustomPixels, AppConfig.MinColumnPixels, AppConfig.MaxColumnPixels), + _ => Standard, + }; +} diff --git a/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs new file mode 100644 index 0000000..0c15aed --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs @@ -0,0 +1,43 @@ +using CommunityToolkit.Mvvm.Input; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// A yes/no confirmation: a message, a confirming button with an action +/// specific label (for example "Delete") and a cancel button. +/// +public sealed partial class ConfirmDialogViewModel : DialogViewModelBase +{ + /// + /// Initializes the confirmation. + /// + /// The localized question. + /// The localized label of the confirming button. + public ConfirmDialogViewModel(string message, string confirmLabel) + { + Message = message; + ConfirmLabel = confirmLabel; + } + + /// + /// Gets the localized question. + /// + public string Message { get; } + + /// + /// Gets the localized label of the confirming button. + /// + public string ConfirmLabel { get; } + + /// + /// Closes with a positive result. + /// + [RelayCommand] + private void Confirm() => Close(true); + + /// + /// Closes with a negative result. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); +} diff --git a/YKanBan/ViewModels/Dialogs/DialogViewModelBase.cs b/YKanBan/ViewModels/Dialogs/DialogViewModelBase.cs new file mode 100644 index 0000000..63cf15a --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/DialogViewModelBase.cs @@ -0,0 +1,33 @@ +namespace YKanBan.ViewModels.Dialogs; + +/// +/// Base class for modal dialog view models shown through . +/// The dialog decides when it closes: it raises +/// with its result, and a close attempted by the window itself (title-bar close +/// button, Escape) is routed to so that the +/// dialog can veto it, for example to ask before discarding edits. +/// +public abstract class DialogViewModelBase : ViewModelBase +{ + /// + /// Raised when the dialog wants to close; the argument is the dialog result. + /// + public event EventHandler? CloseRequested; + + /// + /// Handles a cancel request coming from the window (or a cancel button). + /// The default closes immediately with a negative result. + /// + /// A task completing once the request has been handled. + public virtual Task RequestCancelAsync() + { + Close(false); + return Task.CompletedTask; + } + + /// + /// Asks the hosting window to close with the given result. + /// + /// The dialog result. + protected void Close(bool result) => CloseRequested?.Invoke(this, result); +} diff --git a/YKanBan/ViewModels/Dialogs/IDialogService.cs b/YKanBan/ViewModels/Dialogs/IDialogService.cs new file mode 100644 index 0000000..f73ad88 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/IDialogService.cs @@ -0,0 +1,43 @@ +namespace YKanBan.ViewModels.Dialogs; + +/// +/// Opens modal dialogs on behalf of view models, which never reference views. +/// Every dialog is owned by the top-most open window, so a dialog opened from +/// inside another dialog (nested modal) is centered on and blocks that dialog. +/// +public interface IDialogService +{ + /// + /// Shows a dialog modally and waits until it closes. + /// + /// The dialog view model; its window is resolved by naming convention. + /// The dialog result: when it was accepted. + Task ShowAsync(DialogViewModelBase dialog); +} + +/// +/// Convenience wrappers for the plain confirmation and message dialogs. +/// +public static class DialogServiceExtensions +{ + /// + /// Asks a yes/no question. + /// + /// The dialog service. + /// The localized question. + /// The localized label of the confirming button. + /// when the user confirmed. + public static Task ConfirmAsync(this IDialogService dialogs, string message, string confirmLabel) => + dialogs.ShowAsync(new ConfirmDialogViewModel(message, confirmLabel)); + + /// + /// Shows an informational message with an optional read-only details block. + /// + /// The dialog service. + /// The localized window title. + /// The localized message. + /// Untranslated diagnostic details, or . + /// A task completing when the dialog closes. + public static Task ShowMessageAsync(this IDialogService dialogs, string title, string message, string? details = null) => + dialogs.ShowAsync(new MessageDialogViewModel(title, message, details)); +} diff --git a/YKanBan/ViewModels/Dialogs/MessageDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/MessageDialogViewModel.cs new file mode 100644 index 0000000..c9aa2ef --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/MessageDialogViewModel.cs @@ -0,0 +1,49 @@ +using CommunityToolkit.Mvvm.Input; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// A purely informational dialog with a single OK button: a localized message +/// plus optional untranslated, copyable diagnostic details. +/// +public sealed partial class MessageDialogViewModel : DialogViewModelBase +{ + /// + /// Initializes the message. + /// + /// The localized window title. + /// The localized message. + /// Untranslated diagnostic details, or . + public MessageDialogViewModel(string title, string message, string? details) + { + Title = title; + Message = message; + Details = details; + } + + /// + /// Gets the localized window title. + /// + public string Title { get; } + + /// + /// Gets the localized message. + /// + public string Message { get; } + + /// + /// Gets the diagnostic details, or when there are none. + /// + public string? Details { get; } + + /// + /// Gets whether a details block is shown. + /// + public bool HasDetails => !string.IsNullOrEmpty(Details); + + /// + /// Closes the dialog. + /// + [RelayCommand] + private void Ok() => Close(true); +} diff --git a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs new file mode 100644 index 0000000..170508b --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs @@ -0,0 +1,185 @@ +using System.Collections.ObjectModel; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Board; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The nested "add tag" modal of the card editor: a filterable list of the +/// existing tags not yet on the card, plus a new-tag entry. Picking a tag closes +/// the dialog with it as . +/// +/// Exception to the edit-dialog unit of work: a tag created here is written to +/// the tags table immediately (tags are global entities), so it survives even +/// when the outer card edit is cancelled — only the assignment is discarded. +/// +public sealed partial class TagPickerDialogViewModel : DialogViewModelBase +{ + /// + /// Default colors proposed for new tags, cycled by the number of existing tags. + /// + private static readonly RgbColor[] Palette = + [ + new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04), + new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7), + ]; + + private readonly WorkspaceRepository _repository; + private readonly IReadOnlyList _available; + + /// + /// Initializes the picker. + /// + /// The workspace repository; new tags are written through it immediately. + /// The tags already on the card, which are not offered again. + public TagPickerDialogViewModel(WorkspaceRepository repository, IReadOnlyCollection assignedTagIds) + { + _repository = repository; + IReadOnlyList all = repository.GetTags(); + _available = all + .Where(tag => !assignedTagIds.Contains(tag.Id)) + .Select(tag => new TagBadgeViewModel(tag)) + .ToList(); + FilteredTags = new ObservableCollection(_available); + + newColor = Palette[all.Count % Palette.Length].ToString(); + UpdateColorPreview(); + } + + /// + /// Gets the tag the user picked or created, once the dialog closed positively. + /// + public TagModel? PickedTag { get; private set; } + + /// + /// Gets the available tags matching . + /// + public ObservableCollection FilteredTags { get; } + + /// + /// Gets whether no available tag matches the filter. + /// + public bool NoMatches => FilteredTags.Count == 0; + + /// + /// Gets or sets the list filter: a case-insensitive substring of the tag name. + /// + [ObservableProperty] + private string filterText = ""; + + /// + /// Gets or sets the name of the tag to create (not trimmed). + /// + [ObservableProperty] + private string newName = ""; + + /// + /// Gets or sets the #RRGGBB color text of the tag to create. + /// + [ObservableProperty] + private string newColor; + + /// + /// Gets or sets the description of the tag to create. + /// + [ObservableProperty] + private string newDescription = ""; + + /// + /// Gets the preview of , transparent while it is not valid. + /// + [ObservableProperty] + private IBrush newColorPreview = Brushes.Transparent; + + /// + /// Gets the localized error of the last create attempt, or . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + /// + /// Gets whether an error is shown. + /// + public bool HasError => ErrorMessage is not null; + + /// + /// Picks an existing tag and closes. + /// + /// The picked tag. + [RelayCommand] + private void Pick(TagBadgeViewModel tag) + { + PickedTag = tag.Tag; + Close(true); + } + + /// + /// Creates the new tag right away and closes with it picked; constraint + /// violations and an invalid color keep the dialog open with an error. + /// + [RelayCommand] + private void Create() + { + if (!RgbColor.TryParse(NewColor, out RgbColor color)) + { + ErrorMessage = Resources.Error_Tag_InvalidColor; + return; + } + + try + { + PickedTag = _repository.AddTag(NewName, color, NewDescription); + } + catch (Exception exception) + { + ErrorMessage = WriteErrorMessages.Describe(exception); + return; + } + Close(true); + } + + /// + /// Closes without picking anything. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); + + /// + /// Re-filters the list. + /// + /// The new filter text. + partial void OnFilterTextChanged(string value) + { + string folded = SqliteDatabase.Fold(value); + FilteredTags.Clear(); + foreach (TagBadgeViewModel tag in _available) + { + if (SqliteDatabase.Fold(tag.Name).Contains(folded, StringComparison.Ordinal)) + { + FilteredTags.Add(tag); + } + } + OnPropertyChanged(nameof(NoMatches)); + } + + /// + /// Refreshes the color preview. + /// + /// The new color text. + partial void OnNewColorChanged(string value) => UpdateColorPreview(); + + /// + /// Recomputes from . + /// + private void UpdateColorPreview() => + NewColorPreview = RgbColor.TryParse(NewColor, out RgbColor color) + ? new ImmutableSolidColorBrush(Color.FromRgb(color.R, color.G, color.B)) + : Brushes.Transparent; +} diff --git a/YKanBan/ViewModels/Dialogs/WriteErrorMessages.cs b/YKanBan/ViewModels/Dialogs/WriteErrorMessages.cs new file mode 100644 index 0000000..bddc081 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/WriteErrorMessages.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using YKanBan.Storage; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// Maps a failed dialog write to the localized message shown inside the dialog +/// that attempted it (the dialog stays open and keeps the user's input). The +/// typed schema-constraint exceptions get dedicated messages; anything else +/// gets a generic one carrying the untranslated exception message. +/// +public static class WriteErrorMessages +{ + /// + /// Returns the localized message for a failed write. + /// + /// The exception thrown by the write. + /// The localized message. + public static string Describe(Exception exception) + { + CultureInfo? culture = Resources.Culture; + return exception switch + { + NameRequiredException { Entity: NamedEntity.Column } => Resources.Error_Column_TitleRequired, + NameRequiredException { Entity: NamedEntity.Tag } => Resources.Error_Tag_NameRequired, + DuplicateNameException { Entity: NamedEntity.Column } duplicate => + string.Format(culture, Resources.Error_Column_Duplicate, duplicate.Name), + DuplicateNameException { Entity: NamedEntity.Tag } duplicate => + string.Format(culture, Resources.Error_Tag_Duplicate, duplicate.Name), + _ => string.Format(culture, Resources.Error_SaveFailed, exception.Message), + }; + } +} diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs index 074024d..1c8c62d 100644 --- a/YKanBan/ViewModels/MainWindowViewModel.cs +++ b/YKanBan/ViewModels/MainWindowViewModel.cs @@ -4,6 +4,7 @@ using YKanBan.Launching; using YKanBan.Services; using YKanBan.Storage; using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Dialogs; using YKanBan.ViewModels.ErrorPages; namespace YKanBan.ViewModels; @@ -16,15 +17,18 @@ namespace YKanBan.ViewModels; public sealed partial class MainWindowViewModel : ViewModelBase { private readonly AppServices _services; + private readonly IDialogService _dialogs; /// /// Initializes the view model and resolves the workspace state. /// /// The process composition root. + /// The modal dialog service used by the board. /// The command-line arguments (excluding the executable). - public MainWindowViewModel(AppServices services, IReadOnlyList arguments) + public MainWindowViewModel(AppServices services, IDialogService dialogs, IReadOnlyList arguments) { _services = services; + _dialogs = dialogs; ShowSettings = new Interaction(); ShowAbout = new Interaction(); InitializeWorkspace = new Interaction(); @@ -104,7 +108,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase private void OpenWorkspace(string folderPath) { WorkspaceSession session = _services.OpenWorkspace(folderPath); - Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout); + Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout, _services.Config, _dialogs); Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}"; } diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index 570dda0..cc9d384 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -1,5 +1,8 @@ using CommunityToolkit.Mvvm.Input; +using YKanBan.Storage.AppData; using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Board; +using YKanBan.ViewModels.Dialogs; namespace YKanBan.ViewModels; @@ -19,16 +22,21 @@ public sealed partial class WorkspaceViewModel : ViewModelBase /// The open workspace session. /// The interaction that opens the settings dialog. /// The interaction that opens the about dialog. + /// The live application configuration. + /// The modal dialog service of the board. public WorkspaceViewModel( WorkspaceSession session, Interaction showSettings, - Interaction showAbout) + Interaction showAbout, + AppConfig config, + IDialogService dialogs) { Session = session; WorkspaceName = new DirectoryInfo(session.FolderPath).Name; WorkspacePath = session.FolderPath; _showSettings = showSettings; _showAbout = showAbout; + Board = new BoardViewModel(session.Repository, config, dialogs); } /// @@ -46,6 +54,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase /// public string WorkspacePath { get; } + /// + /// Gets the board tab. + /// + public BoardViewModel Board { get; } + /// /// Opens the settings dialog through the registered interaction. /// diff --git a/YKanBan/Views/Board/BoardView.axaml b/YKanBan/Views/Board/BoardView.axaml new file mode 100644 index 0000000..f349d02 --- /dev/null +++ b/YKanBan/Views/Board/BoardView.axaml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YKanBan/Views/Board/BoardView.axaml.cs b/YKanBan/Views/Board/BoardView.axaml.cs new file mode 100644 index 0000000..bcab524 --- /dev/null +++ b/YKanBan/Views/Board/BoardView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace YKanBan.Views.Board; + +public partial class BoardView : UserControl +{ + public BoardView() + { + InitializeComponent(); + } +} diff --git a/YKanBan/Views/DialogService.cs b/YKanBan/Views/DialogService.cs new file mode 100644 index 0000000..3376051 --- /dev/null +++ b/YKanBan/Views/DialogService.cs @@ -0,0 +1,68 @@ +using Avalonia.Controls; +using YKanBan.ViewModels.Dialogs; +using YKanBan.Views.Dialogs; + +namespace YKanBan.Views; + +/// +/// Shows dialog view models as modal windows. A XxxDialogViewModel in +/// YKanBan.ViewModels.Dialogs is shown by the XxxDialog window in +/// YKanBan.Views.Dialogs. Each dialog is owned by the top-most open +/// window, so nested modals (the tag picker inside the card editor, a discard +/// confirmation above it) center on their direct parent. +/// +public sealed class DialogService : IDialogService +{ + private readonly Stack _owners = new(); + + /// + /// Sets the main window as the bottom of the owner stack. + /// + /// The main window. + public void Attach(Window mainWindow) + { + _owners.Clear(); + _owners.Push(mainWindow); + } + + /// + public async Task ShowAsync(DialogViewModelBase dialog) + { + if (!_owners.TryPeek(out Window? owner)) + { + throw new InvalidOperationException("The dialog service has no main window attached."); + } + + DialogWindow window = CreateWindow(dialog); + window.DataContext = dialog; + + // FlowDirection does not inherit across windows, so every modal gets it explicitly. + window.FlowDirection = App.UiFlowDirection; + + _owners.Push(window); + try + { + return await window.ShowDialog(owner); + } + finally + { + _owners.Pop(); + } + } + + /// + /// Creates the window class matching a dialog view model by naming convention. + /// + /// The dialog view model. + /// The window. + private static DialogWindow CreateWindow(DialogViewModelBase dialog) + { + string viewName = typeof(ConfirmDialog).Namespace + "." + dialog.GetType().Name.Replace("ViewModel", "", StringComparison.Ordinal); + Type? viewType = typeof(DialogService).Assembly.GetType(viewName); + if (viewType is null || !typeof(DialogWindow).IsAssignableFrom(viewType)) + { + throw new InvalidOperationException($"No dialog window '{viewName}' for {dialog.GetType().Name}."); + } + return (DialogWindow)Activator.CreateInstance(viewType)!; + } +} diff --git a/YKanBan/Views/Dialogs/CardEditorDialog.axaml b/YKanBan/Views/Dialogs/CardEditorDialog.axaml new file mode 100644 index 0000000..59d37fb --- /dev/null +++ b/YKanBan/Views/Dialogs/CardEditorDialog.axaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +