feat: add gui shell with theme, i18n and error pages

This commit is contained in:
yyc12345 committed 2026-10-03 11:22:39 +08:00
1 parent 22a0f61cb5
commit f36561cb7f
33 files changed
+1329 -22

No files matched your search

+24 -2
View File
@@ -2,14 +2,36 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="YKanBan.App"
xmlns:local="using:YKanBan"
xmlns:semi="https://irihi.tech/semi"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<!-- Semi.Avalonia provides the light and dark palettes; icons need their own theme. -->
<semi:SemiTheme/>
<StyleInclude Source="avares://IconPacks.Avalonia.Lucide/Lucide.axaml"/>
</Application.Styles>
<!--
Custom colors are declared as Light/Dark pairs and consumed through
DynamicResource, so a theme switch restyles every surface. Tag colors are
user data and stay as their literal #RRGGBB value.
-->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#F6F7F9"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#E2E4EA"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="TopBarBackgroundBrush" Color="#1C1E24"/>
<SolidColorBrush x:Key="TopBarBorderBrush" Color="#2C2F37"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+73 -8
View File
@@ -1,16 +1,31 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using YKanBan.Services;
using YKanBan.Storage.AppData;
using YKanBan.ViewModels;
using YKanBan.Views;
using Resx = YKanBan.Resources;
namespace YKanBan;
public partial class App : Application
{
/// <summary>
/// Gets the process-wide composition root, created at startup.
/// </summary>
public static AppServices? Services { get; private set; }
/// <summary>
/// Gets the language applied at startup; a live selection differing from it
/// needs a restart.
/// </summary>
public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -20,25 +35,75 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
// Startup order: apply the configured culture, its RTL flow direction
// and the theme BEFORE creating the main window.
Services = new AppServices();
CultureInfo culture = ResolveCulture(Services.Config.Language);
StartupLanguage = Services.Config.Language;
Resx.Culture = culture;
ApplyTheme(Services.Config.Theme);
var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []);
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
DataContext = viewModel,
// RTL detection drives the whole window flow direction; the two
// supported languages are LTR, but the infrastructure stays in
// place for future languages.
FlowDirection = culture.TextInfo.IsRightToLeft
? FlowDirection.RightToLeft
: FlowDirection.LeftToRight,
};
}
base.OnFrameworkInitializationCompleted();
}
private void DisableAvaloniaDataAnnotationValidation()
/// <summary>
/// Resolves a configured language to a culture, falling back to English.
/// </summary>
/// <param name="language">The language code from app.json.</param>
/// <returns>The resolved culture.</returns>
public static CultureInfo ResolveCulture(string language)
{
try
{
return new CultureInfo(language);
}
catch (CultureNotFoundException)
{
return new CultureInfo(AppConfig.DefaultLanguage);
}
}
/// <summary>
/// Applies a theme selection immediately; <see cref="ThemeOption.FollowSystem"/>
/// inherits the operating system preference.
/// </summary>
/// <param name="option">The theme selection.</param>
public static void ApplyTheme(ThemeOption option)
{
if (Current is null)
{
return;
}
Current.RequestedThemeVariant = option switch
{
ThemeOption.Light => ThemeVariant.Light,
ThemeOption.Dark => ThemeVariant.Dark,
_ => ThemeVariant.Default,
};
}
private static void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
+111
View File
@@ -16,6 +16,117 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="App_Name" xml:space="preserve">
<value>YKanBan</value>
</data>
<data name="Common_Ok" xml:space="preserve">
<value>OK</value>
</data>
<data name="Common_Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Main_Export" xml:space="preserve">
<value>Export</value>
</data>
<data name="Main_Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Main_About" xml:space="preserve">
<value>About</value>
</data>
<data name="Tab_Tags" xml:space="preserve">
<value>Tags</value>
</data>
<data name="Tab_Board" xml:space="preserve">
<value>Board</value>
</data>
<data name="Error_Argument_Title" xml:space="preserve">
<value>Invalid arguments</value>
</data>
<data name="Error_Argument_Message" xml:space="preserve">
<value>YKanBan was started with invalid command-line arguments.</value>
</data>
<data name="Error_Argument_Usage" xml:space="preserve">
<value>Usage: ykanban &lt;path&gt;</value>
</data>
<data name="Error_DirectoryMissing_Title" xml:space="preserve">
<value>Folder not found</value>
</data>
<data name="Error_DirectoryMissing_Message" xml:space="preserve">
<value>The folder does not exist: {0}</value>
</data>
<data name="Error_NotInitialized_Title" xml:space="preserve">
<value>No workspace here</value>
</data>
<data name="Error_NotInitialized_Message" xml:space="preserve">
<value>The folder does not contain a .ykanban workspace: {0}</value>
</data>
<data name="Error_NotInitialized_Initialize" xml:space="preserve">
<value>Initialize workspace</value>
</data>
<data name="Error_LockConflict_Title" xml:space="preserve">
<value>Workspace locked</value>
</data>
<data name="Error_LockConflict_Message" xml:space="preserve">
<value>The workspace is already open in another instance: {0}</value>
</data>
<data name="Settings_Title" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Settings_Language" xml:space="preserve">
<value>Language</value>
</data>
<data name="Settings_Language_RestartNotice" xml:space="preserve">
<value>Takes effect after restart</value>
</data>
<data name="Settings_LanguageEnglish" xml:space="preserve">
<value>English</value>
</data>
<data name="Settings_LanguageChinese" xml:space="preserve">
<value>简体中文</value>
</data>
<data name="Settings_Theme" xml:space="preserve">
<value>Theme</value>
</data>
<data name="Settings_ThemeLight" xml:space="preserve">
<value>Light</value>
</data>
<data name="Settings_ThemeDark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="Settings_ThemeFollowSystem" xml:space="preserve">
<value>Follow system</value>
</data>
<data name="Settings_Confirmations" xml:space="preserve">
<value>Confirmations</value>
</data>
<data name="Settings_ConfirmDeleteCard" xml:space="preserve">
<value>Ask before deleting a card</value>
</data>
<data name="Settings_ConfirmDeleteColumn" xml:space="preserve">
<value>Ask before deleting a column</value>
</data>
<data name="Settings_ConfirmDeleteTag" xml:space="preserve">
<value>Ask before deleting a tag</value>
</data>
<data name="Settings_ConfirmDiscardEdit" xml:space="preserve">
<value>Ask before discarding card edits</value>
</data>
<data name="About_Title" xml:space="preserve">
<value>About</value>
</data>
<data name="About_Version" xml:space="preserve">
<value>Version {0}</value>
</data>
<data name="About_Repository" xml:space="preserve">
<value>Repository</value>
</data>
<data name="About_License" xml:space="preserve">
<value>License</value>
</data>
<data name="About_LicenseValue" xml:space="preserve">
<value>MIT</value>
</data>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>To Do</value>
</data>
@@ -16,6 +16,117 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="App_Name" xml:space="preserve">
<value>YKanBan</value>
</data>
<data name="Common_Ok" xml:space="preserve">
<value>确定</value>
</data>
<data name="Common_Cancel" xml:space="preserve">
<value>取消</value>
</data>
<data name="Main_Export" xml:space="preserve">
<value>导出</value>
</data>
<data name="Main_Settings" xml:space="preserve">
<value>设置</value>
</data>
<data name="Main_About" xml:space="preserve">
<value>关于</value>
</data>
<data name="Tab_Tags" xml:space="preserve">
<value>标签</value>
</data>
<data name="Tab_Board" xml:space="preserve">
<value>看板</value>
</data>
<data name="Error_Argument_Title" xml:space="preserve">
<value>参数错误</value>
</data>
<data name="Error_Argument_Message" xml:space="preserve">
<value>YKanBan 启动参数有误。</value>
</data>
<data name="Error_Argument_Usage" xml:space="preserve">
<value>用法:ykanban &lt;路径&gt;</value>
</data>
<data name="Error_DirectoryMissing_Title" xml:space="preserve">
<value>目录不存在</value>
</data>
<data name="Error_DirectoryMissing_Message" xml:space="preserve">
<value>目录不存在:{0}</value>
</data>
<data name="Error_NotInitialized_Title" xml:space="preserve">
<value>尚未初始化</value>
</data>
<data name="Error_NotInitialized_Message" xml:space="preserve">
<value>该目录下没有 .ykanban 工作区:{0}</value>
</data>
<data name="Error_NotInitialized_Initialize" xml:space="preserve">
<value>初始化工作区</value>
</data>
<data name="Error_LockConflict_Title" xml:space="preserve">
<value>工作区已被占用</value>
</data>
<data name="Error_LockConflict_Message" xml:space="preserve">
<value>该工作区已在另一个实例中打开:{0}</value>
</data>
<data name="Settings_Title" xml:space="preserve">
<value>设置</value>
</data>
<data name="Settings_Language" xml:space="preserve">
<value>语言</value>
</data>
<data name="Settings_Language_RestartNotice" xml:space="preserve">
<value>重启后生效</value>
</data>
<data name="Settings_LanguageEnglish" xml:space="preserve">
<value>English</value>
</data>
<data name="Settings_LanguageChinese" xml:space="preserve">
<value>简体中文</value>
</data>
<data name="Settings_Theme" xml:space="preserve">
<value>主题</value>
</data>
<data name="Settings_ThemeLight" xml:space="preserve">
<value>亮色</value>
</data>
<data name="Settings_ThemeDark" xml:space="preserve">
<value>暗色</value>
</data>
<data name="Settings_ThemeFollowSystem" xml:space="preserve">
<value>跟随系统</value>
</data>
<data name="Settings_Confirmations" xml:space="preserve">
<value>确认对话框</value>
</data>
<data name="Settings_ConfirmDeleteCard" xml:space="preserve">
<value>删除卡片前询问</value>
</data>
<data name="Settings_ConfirmDeleteColumn" xml:space="preserve">
<value>删除列前询问</value>
</data>
<data name="Settings_ConfirmDeleteTag" xml:space="preserve">
<value>删除标签前询问</value>
</data>
<data name="Settings_ConfirmDiscardEdit" xml:space="preserve">
<value>放弃卡片编辑前询问</value>
</data>
<data name="About_Title" xml:space="preserve">
<value>关于</value>
</data>
<data name="About_Version" xml:space="preserve">
<value>版本 {0}</value>
</data>
<data name="About_Repository" xml:space="preserve">
<value>仓库</value>
</data>
<data name="About_License" xml:space="preserve">
<value>许可证</value>
</data>
<data name="About_LicenseValue" xml:space="preserve">
<value>MIT</value>
</data>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>待办</value>
</data>
+8 -3
View File
@@ -1,5 +1,4 @@
using Avalonia;
using System;
namespace YKanBan;
@@ -9,8 +8,14 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// The lifetime has ended: persist the configuration once, then release services.
App.Services?.SaveConfiguration();
App.Services?.Dispose();
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
+60
View File
@@ -0,0 +1,60 @@
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
namespace YKanBan.Services;
/// <summary>
/// Composition root for the whole process: owns the loaded configuration and
/// the single open workspace session. The configuration is persisted once at
/// shutdown; the session (lock plus connection) lives until disposal.
/// </summary>
public sealed class AppServices : IDisposable
{
/// <summary>
/// Loads the configuration and prepares the service graph.
/// </summary>
public AppServices()
{
ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath);
Config = ConfigStore.Load();
}
/// <summary>
/// Gets the mutable application configuration, persisted at shutdown.
/// </summary>
public AppConfig Config { get; }
/// <summary>
/// Gets the store backing app.json.
/// </summary>
public AppConfigStore ConfigStore { get; }
/// <summary>
/// Gets the open workspace session, or <see langword="null"/> while no
/// workspace is open (for example on an error page).
/// </summary>
public WorkspaceSession? Session { get; private set; }
/// <summary>
/// Opens a workspace, acquiring its exclusive lock and connection.
/// </summary>
/// <param name="folderPath">The workspace folder to open.</param>
/// <returns>The open session.</returns>
/// <exception cref="YKanBan.Storage.WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="YKanBan.Storage.WorkspaceLockException">Another instance already holds the lock.</exception>
public WorkspaceSession OpenWorkspace(string folderPath)
{
Session = WorkspaceSession.Open(folderPath);
return Session;
}
/// <summary>
/// Persists the configuration; called once at shutdown.
/// </summary>
public void SaveConfiguration() => ConfigStore.Save(Config);
/// <summary>
/// Releases the workspace session, if any.
/// </summary>
public void Dispose() => Session?.Dispose();
}
@@ -0,0 +1,39 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The about dialog: application name, version, repository link and license.
/// </summary>
public sealed class AboutDialogViewModel : ViewModelBase
{
/// <summary>
/// Initializes the view model, reading the version from the assembly.
/// </summary>
public AboutDialogViewModel()
{
string version = typeof(AboutDialogViewModel).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
Version = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.About_Version, version);
}
/// <summary>
/// Gets the application name.
/// </summary>
public string AppName => Resources.App_Name;
/// <summary>
/// Gets the localized version line.
/// </summary>
public string Version { get; }
/// <summary>
/// Gets the repository URL.
/// </summary>
public string RepositoryUrl => "https://github.com/yyc12345/YKanBan";
/// <summary>
/// Gets the license name.
/// </summary>
public string License => Resources.About_LicenseValue;
}
@@ -0,0 +1,133 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Storage.AppData;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The settings dialog: language (restart to apply), theme and the four
/// confirmation-dialog toggles. Nothing is applied until the user accepts;
/// cancelling or closing the window discards the changes.
/// </summary>
public sealed partial class SettingsDialogViewModel : ViewModelBase
{
private readonly AppConfig _config;
private readonly string _startupLanguage;
/// <summary>
/// Initializes the dialog from the live configuration.
/// </summary>
/// <param name="config">The live application configuration.</param>
/// <param name="startupLanguage">The language applied at startup, for the restart notice.</param>
public SettingsDialogViewModel(AppConfig config, string startupLanguage)
{
_config = config;
_startupLanguage = startupLanguage;
Languages =
[
new OptionItem(AppConfig.DefaultLanguage, Resources.Settings_LanguageEnglish),
new OptionItem("zh-Hans", Resources.Settings_LanguageChinese),
];
selectedLanguage = Languages.FirstOrDefault(option => (string)option.Value == _config.Language)
?? Languages[0];
Themes =
[
new OptionItem(ThemeOption.Light, Resources.Settings_ThemeLight),
new OptionItem(ThemeOption.Dark, Resources.Settings_ThemeDark),
new OptionItem(ThemeOption.FollowSystem, Resources.Settings_ThemeFollowSystem),
];
selectedTheme = Themes.FirstOrDefault(option => option.Value.Equals(_config.Theme)) ?? Themes[^1];
confirmDeleteCard = _config.Confirmations.DeleteCard;
confirmDeleteColumn = _config.Confirmations.DeleteColumn;
confirmDeleteTag = _config.Confirmations.DeleteTag;
confirmDiscardEdit = _config.Confirmations.DiscardEdit;
RefreshRestartNotice();
}
/// <summary>
/// Gets the available languages.
/// </summary>
public IReadOnlyList<OptionItem> Languages { get; }
/// <summary>
/// Gets the available themes.
/// </summary>
public IReadOnlyList<OptionItem> Themes { get; }
/// <summary>
/// Gets or sets the selected language; takes effect after a restart.
/// </summary>
[ObservableProperty]
private OptionItem? selectedLanguage;
/// <summary>
/// Gets or sets the selected theme; applied when the dialog is accepted.
/// </summary>
[ObservableProperty]
private OptionItem? selectedTheme;
/// <summary>
/// Gets or sets whether deleting a card asks for confirmation.
/// </summary>
[ObservableProperty]
private bool confirmDeleteCard;
/// <summary>
/// Gets or sets whether deleting a column asks for confirmation.
/// </summary>
[ObservableProperty]
private bool confirmDeleteColumn;
/// <summary>
/// Gets or sets whether deleting a tag asks for confirmation.
/// </summary>
[ObservableProperty]
private bool confirmDeleteTag;
/// <summary>
/// Gets or sets whether discarding card edits asks for confirmation.
/// </summary>
[ObservableProperty]
private bool confirmDiscardEdit;
/// <summary>
/// Gets whether the working language differs from the one applied at startup.
/// </summary>
[ObservableProperty]
private bool restartNoticeVisible;
/// <summary>
/// Commits the working values into the live configuration.
/// </summary>
public void Accept()
{
if (SelectedLanguage is not null)
{
_config.Language = (string)SelectedLanguage.Value;
}
if (SelectedTheme is not null)
{
_config.Theme = (ThemeOption)SelectedTheme.Value;
App.ApplyTheme(_config.Theme);
}
_config.Confirmations.DeleteCard = ConfirmDeleteCard;
_config.Confirmations.DeleteColumn = ConfirmDeleteColumn;
_config.Confirmations.DeleteTag = ConfirmDeleteTag;
_config.Confirmations.DiscardEdit = ConfirmDiscardEdit;
}
/// <summary>
/// Refreshes the restart notice after a language selection change.
/// </summary>
/// <param name="value">The new selection.</param>
partial void OnSelectedLanguageChanged(OptionItem? value) => RefreshRestartNotice();
/// <summary>
/// Recomputes whether the restart notice must be shown.
/// </summary>
private void RefreshRestartNotice() =>
RestartNoticeVisible = SelectedLanguage is not null && (string)SelectedLanguage.Value != _startupLanguage;
}
@@ -0,0 +1,9 @@
namespace YKanBan.ViewModels.ErrorPages;
/// <summary>
/// The full-window page shown when YKanBan is started without arguments or with
/// invalid arguments. It has no operations.
/// </summary>
public sealed class ArgumentErrorPageViewModel : ViewModelBase
{
}
@@ -0,0 +1,31 @@
using System.Globalization;
namespace YKanBan.ViewModels.ErrorPages;
/// <summary>
/// The full-window page shown when the folder passed on the command line does
/// not exist. It has no operations.
/// </summary>
public sealed class DirectoryMissingPageViewModel : ViewModelBase
{
/// <summary>
/// Initializes the page for a missing folder.
/// </summary>
/// <param name="folderPath">The folder path that does not exist.</param>
public DirectoryMissingPageViewModel(string folderPath)
{
FolderPath = folderPath;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath);
}
/// <summary>
/// Gets the folder path that does not exist.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Gets the localized explanatory message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,31 @@
using System.Globalization;
namespace YKanBan.ViewModels.ErrorPages;
/// <summary>
/// The full-window page shown when the workspace is already locked by another
/// instance. It has no operations.
/// </summary>
public sealed class LockConflictPageViewModel : ViewModelBase
{
/// <summary>
/// Initializes the page for a locked workspace.
/// </summary>
/// <param name="folderPath">The locked workspace folder.</param>
public LockConflictPageViewModel(string folderPath)
{
FolderPath = folderPath;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_LockConflict_Message, folderPath);
}
/// <summary>
/// Gets the locked workspace folder.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Gets the localized explanatory message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,43 @@
using System.Globalization;
using CommunityToolkit.Mvvm.Input;
namespace YKanBan.ViewModels.ErrorPages;
/// <summary>
/// The full-window page shown when the folder exists but has no .ykanban
/// structure. It is the only error page with an operation: initializing the
/// workspace.
/// </summary>
public sealed partial class NotInitializedPageViewModel : ViewModelBase
{
private readonly Interaction<Unit> _initializeWorkspace;
/// <summary>
/// Initializes the page for a folder without a workspace.
/// </summary>
/// <param name="folderPath">The uninitialized folder path.</param>
/// <param name="initializeWorkspace">The interaction that performs initialization.</param>
public NotInitializedPageViewModel(string folderPath, Interaction<Unit> initializeWorkspace)
{
FolderPath = folderPath;
_initializeWorkspace = initializeWorkspace;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_NotInitialized_Message, folderPath);
}
/// <summary>
/// Gets the uninitialized folder path.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Gets the localized explanatory message.
/// </summary>
public string Message { get; }
/// <summary>
/// Asks the owning view model to initialize this folder as a workspace.
/// </summary>
[RelayCommand]
private async Task InitializeAsync() => await _initializeWorkspace.Handle(Unit.Value);
}
+69
View File
@@ -0,0 +1,69 @@
namespace YKanBan.ViewModels;
/// <summary>
/// Input placeholder for interactions that carry no meaningful payload.
/// </summary>
public readonly record struct Unit
{
/// <summary>
/// Gets the single unit value.
/// </summary>
public static Unit Value => default;
}
/// <summary>
/// A view-model side request with no result that the owning view fulfils
/// through a registered handler (opening a dialog, for example) — the classic
/// MVVM interaction pattern, implemented locally without extra dependencies.
/// The view model never holds a reference to a view.
/// </summary>
/// <typeparam name="TInput">The request payload.</typeparam>
public sealed class Interaction<TInput>
{
private Func<TInput, Task>? _handler;
/// <summary>
/// Registers the single handler; the view calls this once.
/// </summary>
/// <param name="handler">The handler that fulfils the request.</param>
public void RegisterHandler(Func<TInput, Task> handler) => _handler = handler;
/// <summary>
/// Invokes the registered handler.
/// </summary>
/// <param name="input">The request payload.</param>
/// <returns>A task that completes when the handler completes.</returns>
/// <exception cref="InvalidOperationException">No handler has been registered.</exception>
public Task Handle(TInput input) =>
_handler is null
? throw new InvalidOperationException("No handler registered for this interaction.")
: _handler(input);
}
/// <summary>
/// A view-model side request that produces a result, fulfilled through a
/// registered handler.
/// </summary>
/// <typeparam name="TInput">The request payload.</typeparam>
/// <typeparam name="TOutput">The handler's answer.</typeparam>
public sealed class Interaction<TInput, TOutput>
{
private Func<TInput, Task<TOutput>>? _handler;
/// <summary>
/// Registers the single handler; the view calls this once.
/// </summary>
/// <param name="handler">The handler that fulfils the request.</param>
public void RegisterHandler(Func<TInput, Task<TOutput>> handler) => _handler = handler;
/// <summary>
/// Invokes the registered handler.
/// </summary>
/// <param name="input">The request payload.</param>
/// <returns>The handler's answer.</returns>
/// <exception cref="InvalidOperationException">No handler has been registered.</exception>
public Task<TOutput> Handle(TInput input) =>
_handler is null
? throw new InvalidOperationException("No handler registered for this interaction.")
: _handler(input);
}
+128 -2
View File
@@ -1,5 +1,131 @@
namespace YKanBan.ViewModels;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Launching;
using YKanBan.Services;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.ErrorPages;
public partial class MainWindowViewModel : ViewModelBase
namespace YKanBan.ViewModels;
/// <summary>
/// The main window view model. It resolves the launch arguments into either the
/// normal workspace view or one of the full-window error pages, and owns the
/// interactions that the view fulfils by opening dialogs.
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppServices _services;
/// <summary>
/// Initializes the view model and resolves the workspace state.
/// </summary>
/// <param name="services">The process composition root.</param>
/// <param name="arguments">The command-line arguments (excluding the executable).</param>
public MainWindowViewModel(AppServices services, IReadOnlyList<string> arguments)
{
_services = services;
ShowSettings = new Interaction<Unit>();
ShowAbout = new Interaction<Unit>();
InitializeWorkspace = new Interaction<Unit>();
InitializeWorkspace.RegisterHandler(_ => InitializeCurrentWorkspaceAsync());
Resolve(arguments);
}
/// <summary>
/// Gets the interaction that opens the settings dialog.
/// </summary>
public Interaction<Unit> ShowSettings { get; }
/// <summary>
/// Gets the interaction that opens the about dialog.
/// </summary>
public Interaction<Unit> ShowAbout { get; }
/// <summary>
/// Gets the interaction that initializes the not-initialized workspace.
/// </summary>
public Interaction<Unit> InitializeWorkspace { get; }
/// <summary>
/// Gets or sets the window title.
/// </summary>
[ObservableProperty]
private string title = Resources.App_Name;
/// <summary>
/// Gets or sets the current content: the workspace view or an error page.
/// </summary>
[ObservableProperty]
private ViewModelBase? content;
/// <summary>
/// Resolves the launch arguments into a content view model.
/// </summary>
/// <param name="arguments">The command-line arguments.</param>
private void Resolve(IReadOnlyList<string> arguments)
{
if (!LaunchArguments.TryParse(arguments, out string? folderPath))
{
Content = new ArgumentErrorPageViewModel();
return;
}
if (!Directory.Exists(folderPath))
{
Content = new DirectoryMissingPageViewModel(folderPath);
return;
}
try
{
OpenWorkspace(folderPath);
}
catch (WorkspaceNotInitializedException)
{
Content = new NotInitializedPageViewModel(folderPath, InitializeWorkspace);
}
catch (WorkspaceLockException)
{
Content = new LockConflictPageViewModel(folderPath);
}
}
/// <summary>
/// Opens the workspace and switches the content to the normal view.
/// </summary>
/// <param name="folderPath">The workspace folder to open.</param>
private void OpenWorkspace(string folderPath)
{
WorkspaceSession session = _services.OpenWorkspace(folderPath);
Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout);
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
}
/// <summary>
/// Initializes the folder shown by the not-initialized page, adds the preset
/// columns and opens the resulting workspace.
/// </summary>
/// <returns>A completed task once initialization has been attempted.</returns>
private Task InitializeCurrentWorkspaceAsync()
{
if (Content is not NotInitializedPageViewModel page)
{
return Task.CompletedTask;
}
try
{
WorkspaceInitializer.Initialize(page.FolderPath);
WorkspacePreset.AddPresetColumns(page.FolderPath);
OpenWorkspace(page.FolderPath);
}
catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException)
{
// Initialization failed; the page remains so the user can retry.
Debug.WriteLine(exception);
}
return Task.CompletedTask;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace YKanBan.ViewModels;
/// <summary>
/// A selectable option for combo boxes: a backing value plus its localized
/// display label.
/// </summary>
/// <param name="Value">The backing value (an enum value or a language code).</param>
/// <param name="Label">The localized display label.</param>
public sealed record OptionItem(object Value, string Label);
+69
View File
@@ -0,0 +1,69 @@
using CommunityToolkit.Mvvm.Input;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels;
/// <summary>
/// The normal workspace view: the top display area (name, path and the export,
/// settings and about buttons) plus the tags/board tabs. The tab contents are
/// filled in by the board and tag features.
/// </summary>
public sealed partial class WorkspaceViewModel : ViewModelBase
{
private readonly Interaction<Unit> _showSettings;
private readonly Interaction<Unit> _showAbout;
/// <summary>
/// Initializes the view for an open workspace.
/// </summary>
/// <param name="session">The open workspace session.</param>
/// <param name="showSettings">The interaction that opens the settings dialog.</param>
/// <param name="showAbout">The interaction that opens the about dialog.</param>
public WorkspaceViewModel(
WorkspaceSession session,
Interaction<Unit> showSettings,
Interaction<Unit> showAbout)
{
Session = session;
WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
WorkspacePath = session.FolderPath;
_showSettings = showSettings;
_showAbout = showAbout;
}
/// <summary>
/// Gets the open workspace session (lock plus repository).
/// </summary>
public WorkspaceSession Session { get; }
/// <summary>
/// Gets the workspace display name (the folder name).
/// </summary>
public string WorkspaceName { get; }
/// <summary>
/// Gets the full workspace folder path.
/// </summary>
public string WorkspacePath { get; }
/// <summary>
/// Opens the settings dialog through the registered interaction.
/// </summary>
[RelayCommand]
private async Task OpenSettingsAsync() => await _showSettings.Handle(Unit.Value);
/// <summary>
/// Opens the about dialog through the registered interaction.
/// </summary>
[RelayCommand]
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
/// <summary>
/// Raised by the export button; the export flow is wired up with the export
/// feature.
/// </summary>
[RelayCommand]
private static void Export()
{
}
}
+37
View File
@@ -0,0 +1,37 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="460" d:DesignHeight="320"
x:Class="YKanBan.Views.Dialogs.AboutDialog"
x:DataType="vm:AboutDialogViewModel"
Title="{x:Static lang:Resources.About_Title}"
Width="460" Height="320" MinWidth="460" MinHeight="320" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Text="{Binding AppName}" FontSize="20" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Version}"/>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.About_Repository}" FontWeight="SemiBold"/>
<HyperlinkButton Content="{Binding RepositoryUrl}" NavigateUri="{Binding RepositoryUrl}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.About_License}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding License}"/>
</StackPanel>
</StackPanel>
<Button Grid.Row="1" Content="{x:Static lang:Resources.Common_Ok}"
Click="OnCloseClick" HorizontalAlignment="Right" MinWidth="90"
IsDefault="True" IsCancel="True"/>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class AboutDialog : Window
{
public AboutDialog()
{
InitializeComponent();
}
/// <summary>Closes the dialog.</summary>
private void OnCloseClick(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,70 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:vmBase="using:YKanBan.ViewModels"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="440" d:DesignHeight="460"
x:Class="YKanBan.Views.Dialogs.SettingsDialog"
x:DataType="vm:SettingsDialogViewModel"
Title="{x:Static lang:Resources.Settings_Title}"
Width="440" Height="460" MinWidth="440" MinHeight="460" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="14">
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Settings_Language}"/>
<ComboBox ItemsSource="{Binding Languages}" SelectedItem="{Binding SelectedLanguage}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{x:Static lang:Resources.Settings_Language_RestartNotice}"
IsVisible="{Binding RestartNoticeVisible}"
FontStyle="Italic" Opacity="0.7"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Settings_Theme}"/>
<ComboBox ItemsSource="{Binding Themes}" SelectedItem="{Binding SelectedTheme}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="{x:Static lang:Resources.Settings_Confirmations}"/>
<CheckBox Content="{x:Static lang:Resources.Settings_ConfirmDeleteCard}"
IsChecked="{Binding ConfirmDeleteCard}"/>
<CheckBox Content="{x:Static lang:Resources.Settings_ConfirmDeleteColumn}"
IsChecked="{Binding ConfirmDeleteColumn}"/>
<CheckBox Content="{x:Static lang:Resources.Settings_ConfirmDeleteTag}"
IsChecked="{Binding ConfirmDeleteTag}"/>
<CheckBox Content="{x:Static lang:Resources.Settings_ConfirmDiscardEdit}"
IsChecked="{Binding ConfirmDiscardEdit}"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick" MinWidth="90"
IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" MinWidth="90"
IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class SettingsDialog : Window
{
public SettingsDialog()
{
InitializeComponent();
}
/// <summary>Applies the working settings and closes with a positive result.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
(DataContext as SettingsDialogViewModel)?.Accept();
Close(true);
}
/// <summary>Closes without applying anything.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,20 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.ErrorPages.ArgumentErrorPageView"
x:DataType="vm:ArgumentErrorPageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
MaxWidth="560" Margin="32">
<TextBlock Text="{x:Static lang:Resources.Error_Argument_Title}"
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{x:Static lang:Resources.Error_Argument_Message}"
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
<TextBlock Text="{x:Static lang:Resources.Error_Argument_Usage}"
FontFamily="Consolas,Menlo,monospace" HorizontalAlignment="Center"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class ArgumentErrorPageView : UserControl
{
public ArgumentErrorPageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.ErrorPages.DirectoryMissingPageView"
x:DataType="vm:DirectoryMissingPageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
MaxWidth="560" Margin="32">
<TextBlock Text="{x:Static lang:Resources.Error_DirectoryMissing_Title}"
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class DirectoryMissingPageView : UserControl
{
public DirectoryMissingPageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.ErrorPages.LockConflictPageView"
x:DataType="vm:LockConflictPageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
MaxWidth="560" Margin="32">
<TextBlock Text="{x:Static lang:Resources.Error_LockConflict_Title}"
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class LockConflictPageView : UserControl
{
public LockConflictPageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,20 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.ErrorPages.NotInitializedPageView"
x:DataType="vm:NotInitializedPageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
MaxWidth="560" Margin="32">
<TextBlock Text="{x:Static lang:Resources.Error_NotInitialized_Title}"
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
<Button Content="{x:Static lang:Resources.Error_NotInitialized_Initialize}"
Command="{Binding InitializeCommand}" HorizontalAlignment="Center" MinWidth="160"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class NotInitializedPageView : UserControl
{
public NotInitializedPageView()
{
InitializeComponent();
}
}
+5 -7
View File
@@ -3,16 +3,14 @@
xmlns:vm="using:YKanBan.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="YKanBan">
Title="{Binding Title}"
Width="1000" Height="640" MinWidth="720" MinHeight="480">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<!-- The content is the normal workspace view or one of the full-window error pages. -->
<ContentControl Content="{Binding Content}"/>
</Window>
+41
View File
@@ -1,11 +1,52 @@
using Avalonia.Controls;
using YKanBan.ViewModels;
using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs;
namespace YKanBan.Views;
public partial class MainWindow : Window
{
private bool _interactionsRegistered;
public MainWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered)
{
_interactionsRegistered = true;
viewModel.ShowSettings.RegisterHandler(ShowSettingsAsync);
viewModel.ShowAbout.RegisterHandler(ShowAboutAsync);
}
}
/// <summary>Shows the settings dialog centered on this window.</summary>
private async Task ShowSettingsAsync(Unit input)
{
if (App.Services is null)
{
return;
}
var dialog = new SettingsDialog
{
DataContext = new SettingsDialogViewModel(App.Services.Config, App.StartupLanguage),
};
await dialog.ShowDialog(this);
}
/// <summary>Shows the about dialog centered on this window.</summary>
private async Task ShowAboutAsync(Unit input)
{
var dialog = new AboutDialog
{
DataContext = new AboutDialogViewModel(),
};
await dialog.ShowDialog(this);
}
}
+58
View File
@@ -0,0 +1,58 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.WorkspaceView"
x:DataType="vm:WorkspaceViewModel">
<Grid RowDefinitions="Auto,*">
<!-- Top display area: workspace name and path, plus the three action buttons. -->
<Border Grid.Row="0"
Background="{DynamicResource TopBarBackgroundBrush}"
BorderBrush="{DynamicResource TopBarBorderBrush}"
BorderThickness="0,0,0,1"
Padding="16,10">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Text="{Binding WorkspaceName}" FontSize="18" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding WorkspacePath}" FontSize="12" Opacity="0.65"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Command="{Binding ExportCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="FileDown" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Main_Export}"/>
</StackPanel>
</Button>
<Button Command="{Binding OpenSettingsCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Settings" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Main_Settings}"/>
</StackPanel>
</Button>
<Button Command="{Binding OpenAboutCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Info" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Main_About}"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Tags and board tabs; their contents arrive with the corresponding features. -->
<TabControl Grid.Row="1" Margin="8">
<TabItem Header="{x:Static lang:Resources.Tab_Board}"/>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
</TabControl>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views;
public partial class WorkspaceView : UserControl
{
public WorkspaceView()
{
InitializeComponent();
}
}
+1
View File
@@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Version>0.1.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>