Initial commit of version 0.2.0
This commit is contained in:
parent
86fc06b877
commit
dfb04462f3
108 changed files with 16167 additions and 80 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Decky.Core.Api;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Api.Requests;
|
||||
|
|
@ -26,23 +27,43 @@ try
|
|||
var boards = await client.GetBoardsAsync();
|
||||
|
||||
Console.WriteLine($"Connected through {client.ApiBaseUri}");
|
||||
Console.WriteLine($"Found {boards.Count} accessible board(s).\n");
|
||||
Console.WriteLine($"Found {boards.Count} board entry/entries.\n");
|
||||
|
||||
var readableBoards = new List<BoardDto>();
|
||||
foreach (var board in boards.OrderBy(board => board.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
var stacks = await client.GetStacksAsync(board.Id);
|
||||
var cardCount = stacks.Sum(stack => stack.Cards.Count);
|
||||
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
|
||||
if (board.Archived || board.DeletedAt != 0)
|
||||
{
|
||||
Console.WriteLine($"[{board.Id}] {board.Title}: skipped ({(board.Archived ? "archived" : "deleted")})");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stacks = await client.GetStacksAsync(board.Id);
|
||||
var cardCount = stacks.Sum(stack => stack.Cards.Count);
|
||||
readableBoards.Add(board);
|
||||
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
|
||||
}
|
||||
catch (DeckApiException exception) when (exception.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
Console.WriteLine($"[{board.Id}] {board.Title}: unavailable (403 Permission denied)");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("\nPASS Authentication, board retrieval, stack retrieval, and card-list retrieval");
|
||||
if (readableBoards.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Nextcloud did not return any boards whose stacks can be read.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nPASS Authentication and read access to {readableBoards.Count} board(s)");
|
||||
Console.WriteLine("\nOptional write tests create and delete one temporary card, but can reorder that card");
|
||||
Console.WriteLine("inside the selected board. Use a disposable board with at least two stacks.");
|
||||
Console.Write("Type WRITE to run mutation compatibility checks, or press Enter to finish: ");
|
||||
|
||||
if (string.Equals(Console.ReadLine(), "WRITE", StringComparison.Ordinal))
|
||||
{
|
||||
var writeTestsPassed = await RunWriteProbeAsync(client, boards);
|
||||
var writeTestsPassed = await RunWriteProbeAsync(client, readableBoards);
|
||||
Environment.ExitCode = writeTestsPassed ? 0 : 2;
|
||||
}
|
||||
else
|
||||
|
|
@ -50,7 +71,8 @@ try
|
|||
Console.WriteLine("\nRead-only API compatibility checks passed.");
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
|
||||
catch (Exception exception) when (
|
||||
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException or InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine($"\nCompatibility check failed: {exception.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
|
|
@ -161,7 +183,7 @@ static async Task<bool> RunWriteProbeAsync(DeckApiClient client, IReadOnlyList<B
|
|||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException)
|
||||
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException or JsonException)
|
||||
{
|
||||
Console.Error.WriteLine($"FAIL Mutation compatibility check: {exception.Message}");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.UI.Xaml;
|
||||
using Decky.App.Services;
|
||||
|
||||
namespace Decky.App;
|
||||
|
||||
|
|
@ -8,13 +9,35 @@ public partial class App : Application
|
|||
|
||||
public App()
|
||||
{
|
||||
UnhandledException += (_, args) => WriteStartupError(args.Exception);
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
try
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
WriteStartupError(exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteStartupError(Exception exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logPath = Path.Combine(Path.GetDirectoryName(AppDataPaths.SettingsFile)!, "startup-error.log");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
|
||||
File.WriteAllText(logPath, $"{DateTimeOffset.Now:O}{Environment.NewLine}{exception}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Startup logging must never replace the original exception.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
<UseWinUI>true</UseWinUI>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<SelfContained>true</SelfContained>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
|
|
|
|||
|
|
@ -82,7 +82,18 @@
|
|||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
VerticalAlignment="Top">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold" />
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Click="NewCardButton_OnClick"
|
||||
ToolTipService.ToolTip="Create card">
|
||||
<FontIcon Glyph="" FontSize="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Cards}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
|
@ -93,6 +104,16 @@
|
|||
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
|
||||
<TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75"
|
||||
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="4">
|
||||
<Button Click="EditCardButton_OnClick"
|
||||
ToolTipService.ToolTip="Edit card">
|
||||
<FontIcon Glyph="" FontSize="12" />
|
||||
</Button>
|
||||
<Button Click="DeleteCardButton_OnClick"
|
||||
ToolTipService.ToolTip="Delete card">
|
||||
<FontIcon Glyph="" FontSize="12" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System.ComponentModel;
|
||||
using Decky.App.Services;
|
||||
using Decky.App.ViewModels;
|
||||
using Decky.App.Views;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Services;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
|
@ -32,6 +34,65 @@ public sealed partial class MainWindow : Window
|
|||
_viewModel.AppPassword = ((PasswordBox)sender).Password;
|
||||
}
|
||||
|
||||
private async void NewCardButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (((Button)sender).DataContext is not StackDto stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var editor = new CardEditorDialog();
|
||||
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
|
||||
if (result is not null)
|
||||
{
|
||||
await _viewModel.CreateCardAsync(stack.Id, result);
|
||||
}
|
||||
}
|
||||
|
||||
private async void EditCardButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (((Button)sender).DataContext is not CardDto card)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullCard = await _viewModel.GetCardDetailsAsync(card);
|
||||
if (fullCard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var editor = new CardEditorDialog(fullCard);
|
||||
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
|
||||
if (result is not null)
|
||||
{
|
||||
await _viewModel.UpdateCardAsync(fullCard, result);
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteCardButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (((Button)sender).DataContext is not CardDto card)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmation = new ContentDialog
|
||||
{
|
||||
XamlRoot = RootGrid.XamlRoot,
|
||||
Title = "Delete card?",
|
||||
Content = $"“{card.Title}” will be deleted from Nextcloud.",
|
||||
PrimaryButtonText = "Delete",
|
||||
CloseButtonText = "Cancel",
|
||||
DefaultButton = ContentDialogButton.Close,
|
||||
};
|
||||
|
||||
if (await confirmation.ShowAsync() == ContentDialogResult.Primary)
|
||||
{
|
||||
await _viewModel.DeleteCardAsync(card);
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.AppPassword) &&
|
||||
|
|
|
|||
9
src/Decky.App/ViewModels/CardEditorResult.cs
Normal file
9
src/Decky.App/ViewModels/CardEditorResult.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Decky.App.ViewModels;
|
||||
|
||||
public sealed record CardEditorResult(
|
||||
string Title,
|
||||
string Description,
|
||||
DateTimeOffset? DueDate,
|
||||
string Color,
|
||||
bool IsCompleted);
|
||||
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Decky.Core.Api;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Api.Requests;
|
||||
using Decky.Core.Configuration;
|
||||
using Decky.Core.Models;
|
||||
using Decky.Core.Services;
|
||||
|
|
@ -15,6 +18,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ICredentialService _credentialService;
|
||||
private readonly ConcurrentDictionary<int, SemaphoreSlim> _mutationLocks = new();
|
||||
private DeckApiClient? _apiClient;
|
||||
private CancellationTokenSource? _loadCancellation;
|
||||
private AppSettings _settings = new();
|
||||
|
|
@ -160,7 +164,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
var boards = await _apiClient.GetBoardsAsync();
|
||||
|
||||
Boards.Clear();
|
||||
foreach (var board in boards.Where(board => !board.Archived).OrderBy(board => board.Title))
|
||||
foreach (var board in boards
|
||||
.Where(board => !board.Archived && board.DeletedAt == 0)
|
||||
.OrderBy(board => board.Title))
|
||||
{
|
||||
Boards.Add(board);
|
||||
}
|
||||
|
|
@ -184,7 +190,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
await SaveSelectedBoardAsync(boardToOpen.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
|
||||
catch (Exception exception) when (
|
||||
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException)
|
||||
{
|
||||
IsConnected = false;
|
||||
StatusMessage = exception.Message;
|
||||
|
|
@ -261,6 +268,126 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
|
||||
private bool CanRefresh() => !IsBusy && _apiClient is not null && SelectedBoard is not null;
|
||||
|
||||
public async Task<CardDto?> GetCardDetailsAsync(CardDto card)
|
||||
{
|
||||
if (_apiClient is null || SelectedBoard is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Loading {card.Title}…";
|
||||
try
|
||||
{
|
||||
return await _apiClient.GetCardAsync(SelectedBoard.Id, card.StackId, card.Id);
|
||||
}
|
||||
catch (Exception exception) when (IsApiException(exception))
|
||||
{
|
||||
StatusMessage = $"Card details could not be loaded: {exception.Message}";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> CreateCardAsync(int stackId, CardEditorResult editor) =>
|
||||
RunBoardMutationAsync(
|
||||
async (client, boardId) =>
|
||||
{
|
||||
var stack = Stacks.FirstOrDefault(item => item.Id == stackId)
|
||||
?? throw new InvalidOperationException("The destination stack is no longer loaded.");
|
||||
var nextOrder = stack.Cards.Count == 0 ? 0 : stack.Cards.Max(card => card.Order) + 1;
|
||||
var created = await client.CreateCardAsync(
|
||||
boardId,
|
||||
stackId,
|
||||
new CreateCardRequest
|
||||
{
|
||||
Title = editor.Title,
|
||||
Description = editor.Description,
|
||||
DueDate = editor.DueDate,
|
||||
Color = editor.Color,
|
||||
Order = nextOrder,
|
||||
});
|
||||
|
||||
if (editor.IsCompleted)
|
||||
{
|
||||
var fullCard = await client.GetCardAsync(boardId, stackId, created.Id);
|
||||
await client.UpdateCardAsync(
|
||||
boardId,
|
||||
stackId,
|
||||
created.Id,
|
||||
CardUpdateRequest.FromCard(
|
||||
fullCard,
|
||||
done: DateTimeOffset.UtcNow,
|
||||
replaceDone: true));
|
||||
}
|
||||
},
|
||||
"Card created");
|
||||
|
||||
public Task<bool> UpdateCardAsync(CardDto card, CardEditorResult editor) =>
|
||||
RunBoardMutationAsync(
|
||||
async (client, boardId) =>
|
||||
{
|
||||
DateTimeOffset? done = editor.IsCompleted ? card.Done ?? DateTimeOffset.UtcNow : null;
|
||||
var request = CardUpdateRequest.FromCard(
|
||||
card,
|
||||
title: editor.Title,
|
||||
description: editor.Description,
|
||||
replaceDescription: true,
|
||||
dueDate: editor.DueDate,
|
||||
replaceDueDate: true,
|
||||
done: done,
|
||||
replaceDone: true,
|
||||
color: editor.Color);
|
||||
await client.UpdateCardAsync(boardId, card.StackId, card.Id, request);
|
||||
},
|
||||
"Card updated");
|
||||
|
||||
public Task<bool> DeleteCardAsync(CardDto card) =>
|
||||
RunBoardMutationAsync(
|
||||
(client, boardId) => client.DeleteCardAsync(boardId, card.StackId, card.Id),
|
||||
"Card deleted");
|
||||
|
||||
private async Task<bool> RunBoardMutationAsync(
|
||||
Func<DeckApiClient, int, Task> mutation,
|
||||
string successMessage)
|
||||
{
|
||||
var client = _apiClient;
|
||||
var board = SelectedBoard;
|
||||
if (client is null || board is null)
|
||||
{
|
||||
StatusMessage = "Connect and select a board before editing cards.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var mutationLock = _mutationLocks.GetOrAdd(board.Id, _ => new SemaphoreSlim(1, 1));
|
||||
await mutationLock.WaitAsync();
|
||||
IsBusy = true;
|
||||
StatusMessage = "Saving card…";
|
||||
try
|
||||
{
|
||||
await mutation(client, board.Id);
|
||||
await LoadBoardAsync(board);
|
||||
StatusMessage = successMessage;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (IsApiException(exception) || exception is InvalidOperationException)
|
||||
{
|
||||
StatusMessage = $"The card change failed: {exception.Message}";
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
mutationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsApiException(Exception exception) =>
|
||||
exception is DeckApiException or HttpRequestException or JsonException or ArgumentException;
|
||||
|
||||
private async Task LoadBoardAsync(BoardDto board)
|
||||
{
|
||||
if (_apiClient is null)
|
||||
|
|
@ -296,7 +423,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception) when (exception is DeckApiException or HttpRequestException)
|
||||
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or JsonException)
|
||||
{
|
||||
StatusMessage = $"Offline · {exception.Message}";
|
||||
}
|
||||
|
|
@ -319,6 +446,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
disposableSettingsService.Dispose();
|
||||
}
|
||||
|
||||
foreach (var mutationLock in _mutationLocks.Values)
|
||||
{
|
||||
mutationLock.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
179
src/Decky.App/Views/CardEditorDialog.cs
Normal file
179
src/Decky.App/Views/CardEditorDialog.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
using Decky.App.ViewModels;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Utilities;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Windows.UI;
|
||||
|
||||
namespace Decky.App.Views;
|
||||
|
||||
public sealed class CardEditorDialog : ContentDialog
|
||||
{
|
||||
private readonly TextBox _titleBox;
|
||||
private readonly TextBox _descriptionBox;
|
||||
private readonly CalendarDatePicker _dueDatePicker;
|
||||
private readonly ColorPicker _colorPicker;
|
||||
private readonly TextBox _hexColorBox;
|
||||
private readonly CheckBox _completedBox;
|
||||
private readonly TextBlock _errorText;
|
||||
private bool _synchronizingColor;
|
||||
|
||||
public CardEditorDialog(CardDto? card = null)
|
||||
{
|
||||
Title = card is null ? "Create card" : "Edit card";
|
||||
PrimaryButtonText = card is null ? "Create" : "Save";
|
||||
CloseButtonText = "Cancel";
|
||||
DefaultButton = ContentDialogButton.Primary;
|
||||
|
||||
_titleBox = new TextBox
|
||||
{
|
||||
Header = "Title",
|
||||
Text = card?.Title ?? string.Empty,
|
||||
MaxLength = 255,
|
||||
};
|
||||
_descriptionBox = new TextBox
|
||||
{
|
||||
Header = "Description",
|
||||
Text = card?.Description ?? string.Empty,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Height = 110,
|
||||
};
|
||||
_dueDatePicker = new CalendarDatePicker
|
||||
{
|
||||
Header = "Due date",
|
||||
Date = card?.DueDate,
|
||||
IsGroupLabelVisible = true,
|
||||
};
|
||||
|
||||
var initialColor = DeckColor.Normalize(card?.Color) ?? "E5E7EB";
|
||||
_colorPicker = new ColorPicker
|
||||
{
|
||||
IsAlphaEnabled = false,
|
||||
IsAlphaSliderVisible = false,
|
||||
IsAlphaTextInputVisible = false,
|
||||
Color = ParseColor(initialColor),
|
||||
};
|
||||
_hexColorBox = new TextBox
|
||||
{
|
||||
Header = "Hex color",
|
||||
Text = $"#{initialColor}",
|
||||
MaxLength = 7,
|
||||
};
|
||||
_completedBox = new CheckBox
|
||||
{
|
||||
Content = "Completed",
|
||||
IsChecked = card?.Done is not null,
|
||||
};
|
||||
_errorText = new TextBlock
|
||||
{
|
||||
Foreground = new SolidColorBrush(Colors.IndianRed),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
|
||||
_colorPicker.ColorChanged += (_, args) =>
|
||||
{
|
||||
if (_synchronizingColor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_synchronizingColor = true;
|
||||
_hexColorBox.Text = $"#{args.NewColor.R:X2}{args.NewColor.G:X2}{args.NewColor.B:X2}";
|
||||
_synchronizingColor = false;
|
||||
};
|
||||
_hexColorBox.TextChanged += (_, _) =>
|
||||
{
|
||||
if (_synchronizingColor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var normalized = DeckColor.Normalize(_hexColorBox.Text);
|
||||
if (normalized is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_synchronizingColor = true;
|
||||
_colorPicker.Color = ParseColor(normalized);
|
||||
_synchronizingColor = false;
|
||||
_errorText.Text = string.Empty;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Validation is shown when Save is pressed so typing is not interrupted.
|
||||
}
|
||||
};
|
||||
|
||||
Content = new ScrollViewer
|
||||
{
|
||||
MaxHeight = 620,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
MinWidth = 390,
|
||||
Children =
|
||||
{
|
||||
_titleBox,
|
||||
_descriptionBox,
|
||||
_dueDatePicker,
|
||||
new TextBlock { Text = "Color" },
|
||||
_colorPicker,
|
||||
_hexColorBox,
|
||||
_completedBox,
|
||||
_errorText,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
PrimaryButtonClick += ValidateAndCaptureResult;
|
||||
}
|
||||
|
||||
public CardEditorResult? Result { get; private set; }
|
||||
|
||||
public async Task<CardEditorResult?> ShowForResultAsync(XamlRoot xamlRoot)
|
||||
{
|
||||
XamlRoot = xamlRoot;
|
||||
var dialogResult = await ShowAsync();
|
||||
return dialogResult == ContentDialogResult.Primary ? Result : null;
|
||||
}
|
||||
|
||||
private void ValidateAndCaptureResult(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
var title = _titleBox.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
args.Cancel = true;
|
||||
_errorText.Text = "A card title is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var color = DeckColor.Normalize(_hexColorBox.Text)!;
|
||||
Result = new CardEditorResult(
|
||||
title,
|
||||
_descriptionBox.Text,
|
||||
_dueDatePicker.Date,
|
||||
color,
|
||||
_completedBox.IsChecked == true);
|
||||
_errorText.Text = string.Empty;
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
args.Cancel = true;
|
||||
_errorText.Text = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color ParseColor(string color) => Color.FromArgb(
|
||||
255,
|
||||
Convert.ToByte(color[..2], 16),
|
||||
Convert.ToByte(color[2..4], 16),
|
||||
Convert.ToByte(color[4..], 16));
|
||||
}
|
||||
|
|
@ -118,17 +118,51 @@ public sealed class DeckApiClient
|
|||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<CardDto> ReorderCardAsync(
|
||||
public async Task<CardDto> ReorderCardAsync(
|
||||
int boardId,
|
||||
int sourceStackId,
|
||||
int cardId,
|
||||
ReorderCardRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
SendJsonAsync<CardDto>(
|
||||
HttpMethod.Put,
|
||||
$"boards/{boardId}/stacks/{sourceStackId}/cards/{cardId}/reorder",
|
||||
request,
|
||||
cancellationToken);
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await SendReorderAsync(sourceStackId).ConfigureAwait(false);
|
||||
var card = await ResolveReorderedCardAsync().ConfigureAwait(false);
|
||||
|
||||
if (request.StackId != sourceStackId && card.StackId == sourceStackId)
|
||||
{
|
||||
// Deck 1.16.x binds the route stackId over the JSON body stackId. In
|
||||
// that version the route value is passed straight to CardService as
|
||||
// the destination, so retrying with the requested destination in the
|
||||
// route safely works around the server bug.
|
||||
await SendReorderAsync(request.StackId).ConfigureAwait(false);
|
||||
card = await ResolveReorderedCardAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return card;
|
||||
|
||||
async Task SendReorderAsync(int routeStackId)
|
||||
{
|
||||
using var response = await SendAsync(
|
||||
HttpMethod.Put,
|
||||
$"boards/{boardId}/stacks/{routeStackId}/cards/{cardId}/reorder",
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task<CardDto> ResolveReorderedCardAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetCardAsync(boardId, request.StackId, cardId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DeckApiException exception) when (
|
||||
exception.StatusCode == System.Net.HttpStatusCode.NotFound &&
|
||||
request.StackId != sourceStackId)
|
||||
{
|
||||
return await GetCardAsync(boardId, sourceStackId, cardId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteCardAsync(
|
||||
int boardId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Decky.Core.Api.Serialization;
|
||||
|
||||
namespace Decky.Core.Api.Models;
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ public sealed record BoardDto
|
|||
|
||||
public long LastModified { get; init; }
|
||||
|
||||
public long DeletedAt { get; init; }
|
||||
|
||||
public BoardPermissionsDto? Permissions { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
|
|
@ -62,6 +65,8 @@ public sealed record StackDto
|
|||
|
||||
public long LastModified { get; init; }
|
||||
|
||||
public long DeletedAt { get; init; }
|
||||
|
||||
public IReadOnlyList<CardDto> Cards { get; init; } = [];
|
||||
|
||||
[JsonExtensionData]
|
||||
|
|
@ -80,6 +85,7 @@ public sealed record CardDto
|
|||
|
||||
public string Type { get; init; } = "plain";
|
||||
|
||||
[JsonConverter(typeof(CardOwnerJsonConverter))]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
public int Order { get; init; }
|
||||
|
|
@ -101,4 +107,3 @@ public sealed record CardDto
|
|||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? AdditionalData { get; init; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public sealed record CardUpdateRequest
|
|||
CardDto card,
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
bool replaceDescription = false,
|
||||
DateTimeOffset? dueDate = null,
|
||||
bool replaceDueDate = false,
|
||||
DateTimeOffset? done = null,
|
||||
|
|
@ -61,7 +62,7 @@ public sealed record CardUpdateRequest
|
|||
Title = title ?? card.Title,
|
||||
Type = card.Type,
|
||||
Owner = card.Owner,
|
||||
Description = description ?? card.Description,
|
||||
Description = replaceDescription ? description : description ?? card.Description,
|
||||
Order = card.Order,
|
||||
DueDate = replaceDueDate ? dueDate : card.DueDate,
|
||||
StartDate = card.StartDate,
|
||||
|
|
@ -78,4 +79,3 @@ public sealed record ReorderCardRequest
|
|||
|
||||
public required int StackId { get; init; }
|
||||
}
|
||||
|
||||
|
|
|
|||
55
src/Decky.Core/Api/Serialization/CardOwnerJsonConverter.cs
Normal file
55
src/Decky.Core/Api/Serialization/CardOwnerJsonConverter.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Decky.Core.Api.Serialization;
|
||||
|
||||
public sealed class CardOwnerJsonConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
return reader.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException($"A card owner must be a string or user object, not {reader.TokenType}.");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
var owner = document.RootElement;
|
||||
|
||||
if (TryGetString(owner, "uid", out var userId) ||
|
||||
TryGetString(owner, "primaryKey", out userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
throw new JsonException("A card owner object did not contain a string 'uid' or 'primaryKey'.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
|
||||
private static bool TryGetString(JsonElement element, string propertyName, out string value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrEmpty(property.GetString()))
|
||||
{
|
||||
value = property.GetString()!;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue