diff --git a/YKanBan/App.axaml b/YKanBan/App.axaml
index 2ba681d..1f67adf 100644
--- a/YKanBan/App.axaml
+++ b/YKanBan/App.axaml
@@ -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">
-
-
+
-
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs
index 0c02113..1977aec 100644
--- a/YKanBan/App.axaml.cs
+++ b/YKanBan/App.axaml.cs
@@ -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
{
+ ///
+ /// Gets the process-wide composition root, created at startup.
+ ///
+ public static AppServices? Services { get; private set; }
+
+ ///
+ /// Gets the language applied at startup; a live selection differing from it
+ /// needs a restart.
+ ///
+ public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage;
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -20,28 +35,78 @@ 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()
+ ///
+ /// Resolves a configured language to a culture, falling back to English.
+ ///
+ /// The language code from app.json.
+ /// The resolved culture.
+ public static CultureInfo ResolveCulture(string language)
+ {
+ try
+ {
+ return new CultureInfo(language);
+ }
+ catch (CultureNotFoundException)
+ {
+ return new CultureInfo(AppConfig.DefaultLanguage);
+ }
+ }
+
+ ///
+ /// Applies a theme selection immediately;
+ /// inherits the operating system preference.
+ ///
+ /// The theme selection.
+ 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().ToArray();
- // remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
-}
\ No newline at end of file
+}
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
index 8a6f4c9..7150648 100644
--- a/YKanBan/Assets/Locales/Resources.resx
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -16,6 +16,117 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+ YKanBan
+
+
+ OK
+
+
+ Cancel
+
+
+ Export
+
+
+ Settings
+
+
+ About
+
+
+ Tags
+
+
+ Board
+
+
+ Invalid arguments
+
+
+ YKanBan was started with invalid command-line arguments.
+
+
+ Usage: ykanban <path>
+
+
+ Folder not found
+
+
+ The folder does not exist: {0}
+
+
+ No workspace here
+
+
+ The folder does not contain a .ykanban workspace: {0}
+
+
+ Initialize workspace
+
+
+ Workspace locked
+
+
+ The workspace is already open in another instance: {0}
+
+
+ Settings
+
+
+ Language
+
+
+ Takes effect after restart
+
+
+ English
+
+
+ 简体中文
+
+
+ Theme
+
+
+ Light
+
+
+ Dark
+
+
+ Follow system
+
+
+ Confirmations
+
+
+ Ask before deleting a card
+
+
+ Ask before deleting a column
+
+
+ Ask before deleting a tag
+
+
+ Ask before discarding card edits
+
+
+ About
+
+
+ Version {0}
+
+
+ Repository
+
+
+ License
+
+
+ MIT
+
To Do
diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
index 162785e..1abc7d7 100644
--- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -16,6 +16,117 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+ YKanBan
+
+
+ 确定
+
+
+ 取消
+
+
+ 导出
+
+
+ 设置
+
+
+ 关于
+
+
+ 标签
+
+
+ 看板
+
+
+ 参数错误
+
+
+ YKanBan 启动参数有误。
+
+
+ 用法:ykanban <路径>
+
+
+ 目录不存在
+
+
+ 目录不存在:{0}
+
+
+ 尚未初始化
+
+
+ 该目录下没有 .ykanban 工作区:{0}
+
+
+ 初始化工作区
+
+
+ 工作区已被占用
+
+
+ 该工作区已在另一个实例中打开:{0}
+
+
+ 设置
+
+
+ 语言
+
+
+ 重启后生效
+
+
+ English
+
+
+ 简体中文
+
+
+ 主题
+
+
+ 亮色
+
+
+ 暗色
+
+
+ 跟随系统
+
+
+ 确认对话框
+
+
+ 删除卡片前询问
+
+
+ 删除列前询问
+
+
+ 删除标签前询问
+
+
+ 放弃卡片编辑前询问
+
+
+ 关于
+
+
+ 版本 {0}
+
+
+ 仓库
+
+
+ 许可证
+
+
+ MIT
+
待办
diff --git a/YKanBan/Program.cs b/YKanBan/Program.cs
index bbc7334..8a1170b 100644
--- a/YKanBan/Program.cs
+++ b/YKanBan/Program.cs
@@ -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()
diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs
new file mode 100644
index 0000000..ac6b593
--- /dev/null
+++ b/YKanBan/Services/AppServices.cs
@@ -0,0 +1,60 @@
+using YKanBan.Storage.AppData;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.Services;
+
+///
+/// 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.
+///
+public sealed class AppServices : IDisposable
+{
+ ///
+ /// Loads the configuration and prepares the service graph.
+ ///
+ public AppServices()
+ {
+ ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath);
+ Config = ConfigStore.Load();
+ }
+
+ ///
+ /// Gets the mutable application configuration, persisted at shutdown.
+ ///
+ public AppConfig Config { get; }
+
+ ///
+ /// Gets the store backing app.json.
+ ///
+ public AppConfigStore ConfigStore { get; }
+
+ ///
+ /// Gets the open workspace session, or while no
+ /// workspace is open (for example on an error page).
+ ///
+ public WorkspaceSession? Session { get; private set; }
+
+ ///
+ /// Opens a workspace, acquiring its exclusive lock and connection.
+ ///
+ /// The workspace folder to open.
+ /// The open session.
+ /// The folder has no .ykanban structure.
+ /// Another instance already holds the lock.
+ public WorkspaceSession OpenWorkspace(string folderPath)
+ {
+ Session = WorkspaceSession.Open(folderPath);
+ return Session;
+ }
+
+ ///
+ /// Persists the configuration; called once at shutdown.
+ ///
+ public void SaveConfiguration() => ConfigStore.Save(Config);
+
+ ///
+ /// Releases the workspace session, if any.
+ ///
+ public void Dispose() => Session?.Dispose();
+}
diff --git a/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs
new file mode 100644
index 0000000..48bfc53
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs
@@ -0,0 +1,39 @@
+using System.Globalization;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The about dialog: application name, version, repository link and license.
+///
+public sealed class AboutDialogViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the view model, reading the version from the assembly.
+ ///
+ 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);
+ }
+
+ ///
+ /// Gets the application name.
+ ///
+ public string AppName => Resources.App_Name;
+
+ ///
+ /// Gets the localized version line.
+ ///
+ public string Version { get; }
+
+ ///
+ /// Gets the repository URL.
+ ///
+ public string RepositoryUrl => "https://github.com/yyc12345/YKanBan";
+
+ ///
+ /// Gets the license name.
+ ///
+ public string License => Resources.About_LicenseValue;
+}
diff --git a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
new file mode 100644
index 0000000..cc83055
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
@@ -0,0 +1,133 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// 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.
+///
+public sealed partial class SettingsDialogViewModel : ViewModelBase
+{
+ private readonly AppConfig _config;
+ private readonly string _startupLanguage;
+
+ ///
+ /// Initializes the dialog from the live configuration.
+ ///
+ /// The live application configuration.
+ /// The language applied at startup, for the restart notice.
+ 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();
+ }
+
+ ///
+ /// Gets the available languages.
+ ///
+ public IReadOnlyList Languages { get; }
+
+ ///
+ /// Gets the available themes.
+ ///
+ public IReadOnlyList Themes { get; }
+
+ ///
+ /// Gets or sets the selected language; takes effect after a restart.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedLanguage;
+
+ ///
+ /// Gets or sets the selected theme; applied when the dialog is accepted.
+ ///
+ [ObservableProperty]
+ private OptionItem? selectedTheme;
+
+ ///
+ /// Gets or sets whether deleting a card asks for confirmation.
+ ///
+ [ObservableProperty]
+ private bool confirmDeleteCard;
+
+ ///
+ /// Gets or sets whether deleting a column asks for confirmation.
+ ///
+ [ObservableProperty]
+ private bool confirmDeleteColumn;
+
+ ///
+ /// Gets or sets whether deleting a tag asks for confirmation.
+ ///
+ [ObservableProperty]
+ private bool confirmDeleteTag;
+
+ ///
+ /// Gets or sets whether discarding card edits asks for confirmation.
+ ///
+ [ObservableProperty]
+ private bool confirmDiscardEdit;
+
+ ///
+ /// Gets whether the working language differs from the one applied at startup.
+ ///
+ [ObservableProperty]
+ private bool restartNoticeVisible;
+
+ ///
+ /// Commits the working values into the live configuration.
+ ///
+ 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;
+ }
+
+ ///
+ /// Refreshes the restart notice after a language selection change.
+ ///
+ /// The new selection.
+ partial void OnSelectedLanguageChanged(OptionItem? value) => RefreshRestartNotice();
+
+ ///
+ /// Recomputes whether the restart notice must be shown.
+ ///
+ private void RefreshRestartNotice() =>
+ RestartNoticeVisible = SelectedLanguage is not null && (string)SelectedLanguage.Value != _startupLanguage;
+}
diff --git a/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs
new file mode 100644
index 0000000..4007c14
--- /dev/null
+++ b/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs
@@ -0,0 +1,9 @@
+namespace YKanBan.ViewModels.ErrorPages;
+
+///
+/// The full-window page shown when YKanBan is started without arguments or with
+/// invalid arguments. It has no operations.
+///
+public sealed class ArgumentErrorPageViewModel : ViewModelBase
+{
+}
diff --git a/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs
new file mode 100644
index 0000000..5eb1fd2
--- /dev/null
+++ b/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+
+namespace YKanBan.ViewModels.ErrorPages;
+
+///
+/// The full-window page shown when the folder passed on the command line does
+/// not exist. It has no operations.
+///
+public sealed class DirectoryMissingPageViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the page for a missing folder.
+ ///
+ /// The folder path that does not exist.
+ public DirectoryMissingPageViewModel(string folderPath)
+ {
+ FolderPath = folderPath;
+ Message = string.Format(
+ Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath);
+ }
+
+ ///
+ /// Gets the folder path that does not exist.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Gets the localized explanatory message.
+ ///
+ public string Message { get; }
+}
diff --git a/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs
new file mode 100644
index 0000000..817cd69
--- /dev/null
+++ b/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+
+namespace YKanBan.ViewModels.ErrorPages;
+
+///
+/// The full-window page shown when the workspace is already locked by another
+/// instance. It has no operations.
+///
+public sealed class LockConflictPageViewModel : ViewModelBase
+{
+ ///
+ /// Initializes the page for a locked workspace.
+ ///
+ /// The locked workspace folder.
+ public LockConflictPageViewModel(string folderPath)
+ {
+ FolderPath = folderPath;
+ Message = string.Format(
+ Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_LockConflict_Message, folderPath);
+ }
+
+ ///
+ /// Gets the locked workspace folder.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Gets the localized explanatory message.
+ ///
+ public string Message { get; }
+}
diff --git a/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs
new file mode 100644
index 0000000..40830b9
--- /dev/null
+++ b/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs
@@ -0,0 +1,43 @@
+using System.Globalization;
+using CommunityToolkit.Mvvm.Input;
+
+namespace YKanBan.ViewModels.ErrorPages;
+
+///
+/// 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.
+///
+public sealed partial class NotInitializedPageViewModel : ViewModelBase
+{
+ private readonly Interaction _initializeWorkspace;
+
+ ///
+ /// Initializes the page for a folder without a workspace.
+ ///
+ /// The uninitialized folder path.
+ /// The interaction that performs initialization.
+ public NotInitializedPageViewModel(string folderPath, Interaction initializeWorkspace)
+ {
+ FolderPath = folderPath;
+ _initializeWorkspace = initializeWorkspace;
+ Message = string.Format(
+ Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_NotInitialized_Message, folderPath);
+ }
+
+ ///
+ /// Gets the uninitialized folder path.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Gets the localized explanatory message.
+ ///
+ public string Message { get; }
+
+ ///
+ /// Asks the owning view model to initialize this folder as a workspace.
+ ///
+ [RelayCommand]
+ private async Task InitializeAsync() => await _initializeWorkspace.Handle(Unit.Value);
+}
diff --git a/YKanBan/ViewModels/Interaction.cs b/YKanBan/ViewModels/Interaction.cs
new file mode 100644
index 0000000..001fe54
--- /dev/null
+++ b/YKanBan/ViewModels/Interaction.cs
@@ -0,0 +1,69 @@
+namespace YKanBan.ViewModels;
+
+///
+/// Input placeholder for interactions that carry no meaningful payload.
+///
+public readonly record struct Unit
+{
+ ///
+ /// Gets the single unit value.
+ ///
+ public static Unit Value => default;
+}
+
+///
+/// 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.
+///
+/// The request payload.
+public sealed class Interaction
+{
+ private Func? _handler;
+
+ ///
+ /// Registers the single handler; the view calls this once.
+ ///
+ /// The handler that fulfils the request.
+ public void RegisterHandler(Func handler) => _handler = handler;
+
+ ///
+ /// Invokes the registered handler.
+ ///
+ /// The request payload.
+ /// A task that completes when the handler completes.
+ /// No handler has been registered.
+ public Task Handle(TInput input) =>
+ _handler is null
+ ? throw new InvalidOperationException("No handler registered for this interaction.")
+ : _handler(input);
+}
+
+///
+/// A view-model side request that produces a result, fulfilled through a
+/// registered handler.
+///
+/// The request payload.
+/// The handler's answer.
+public sealed class Interaction
+{
+ private Func>? _handler;
+
+ ///
+ /// Registers the single handler; the view calls this once.
+ ///
+ /// The handler that fulfils the request.
+ public void RegisterHandler(Func> handler) => _handler = handler;
+
+ ///
+ /// Invokes the registered handler.
+ ///
+ /// The request payload.
+ /// The handler's answer.
+ /// No handler has been registered.
+ public Task Handle(TInput input) =>
+ _handler is null
+ ? throw new InvalidOperationException("No handler registered for this interaction.")
+ : _handler(input);
+}
diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs
index dd8a2fd..9f1f1d3 100644
--- a/YKanBan/ViewModels/MainWindowViewModel.cs
+++ b/YKanBan/ViewModels/MainWindowViewModel.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed partial class MainWindowViewModel : ViewModelBase
{
+ private readonly AppServices _services;
+
+ ///
+ /// Initializes the view model and resolves the workspace state.
+ ///
+ /// The process composition root.
+ /// The command-line arguments (excluding the executable).
+ public MainWindowViewModel(AppServices services, IReadOnlyList arguments)
+ {
+ _services = services;
+ ShowSettings = new Interaction();
+ ShowAbout = new Interaction();
+ InitializeWorkspace = new Interaction();
+ InitializeWorkspace.RegisterHandler(_ => InitializeCurrentWorkspaceAsync());
+ Resolve(arguments);
+ }
+
+ ///
+ /// Gets the interaction that opens the settings dialog.
+ ///
+ public Interaction ShowSettings { get; }
+
+ ///
+ /// Gets the interaction that opens the about dialog.
+ ///
+ public Interaction ShowAbout { get; }
+
+ ///
+ /// Gets the interaction that initializes the not-initialized workspace.
+ ///
+ public Interaction InitializeWorkspace { get; }
+
+ ///
+ /// Gets or sets the window title.
+ ///
+ [ObservableProperty]
+ private string title = Resources.App_Name;
+
+ ///
+ /// Gets or sets the current content: the workspace view or an error page.
+ ///
+ [ObservableProperty]
+ private ViewModelBase? content;
+
+ ///
+ /// Resolves the launch arguments into a content view model.
+ ///
+ /// The command-line arguments.
+ private void Resolve(IReadOnlyList 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);
+ }
+ }
+
+ ///
+ /// Opens the workspace and switches the content to the normal view.
+ ///
+ /// The workspace folder to open.
+ private void OpenWorkspace(string folderPath)
+ {
+ WorkspaceSession session = _services.OpenWorkspace(folderPath);
+ Content = new WorkspaceViewModel(session, ShowSettings, ShowAbout);
+ Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
+ }
+
+ ///
+ /// Initializes the folder shown by the not-initialized page, adds the preset
+ /// columns and opens the resulting workspace.
+ ///
+ /// A completed task once initialization has been attempted.
+ 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;
+ }
}
diff --git a/YKanBan/ViewModels/OptionItem.cs b/YKanBan/ViewModels/OptionItem.cs
new file mode 100644
index 0000000..510a12a
--- /dev/null
+++ b/YKanBan/ViewModels/OptionItem.cs
@@ -0,0 +1,9 @@
+namespace YKanBan.ViewModels;
+
+///
+/// A selectable option for combo boxes: a backing value plus its localized
+/// display label.
+///
+/// The backing value (an enum value or a language code).
+/// The localized display label.
+public sealed record OptionItem(object Value, string Label);
diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs
new file mode 100644
index 0000000..570dda0
--- /dev/null
+++ b/YKanBan/ViewModels/WorkspaceViewModel.cs
@@ -0,0 +1,69 @@
+using CommunityToolkit.Mvvm.Input;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.ViewModels;
+
+///
+/// The normal workspace view: the top display area (name, path and the export,
+/// settings and about buttons) plus the tags/board tabs. The tab contents are
+/// filled in by the board and tag features.
+///
+public sealed partial class WorkspaceViewModel : ViewModelBase
+{
+ private readonly Interaction _showSettings;
+ private readonly Interaction _showAbout;
+
+ ///
+ /// Initializes the view for an open workspace.
+ ///
+ /// The open workspace session.
+ /// The interaction that opens the settings dialog.
+ /// The interaction that opens the about dialog.
+ public WorkspaceViewModel(
+ WorkspaceSession session,
+ Interaction showSettings,
+ Interaction showAbout)
+ {
+ Session = session;
+ WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
+ WorkspacePath = session.FolderPath;
+ _showSettings = showSettings;
+ _showAbout = showAbout;
+ }
+
+ ///
+ /// Gets the open workspace session (lock plus repository).
+ ///
+ public WorkspaceSession Session { get; }
+
+ ///
+ /// Gets the workspace display name (the folder name).
+ ///
+ public string WorkspaceName { get; }
+
+ ///
+ /// Gets the full workspace folder path.
+ ///
+ public string WorkspacePath { get; }
+
+ ///
+ /// Opens the settings dialog through the registered interaction.
+ ///
+ [RelayCommand]
+ private async Task OpenSettingsAsync() => await _showSettings.Handle(Unit.Value);
+
+ ///
+ /// Opens the about dialog through the registered interaction.
+ ///
+ [RelayCommand]
+ private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
+
+ ///
+ /// Raised by the export button; the export flow is wired up with the export
+ /// feature.
+ ///
+ [RelayCommand]
+ private static void Export()
+ {
+ }
+}
diff --git a/YKanBan/Views/Dialogs/AboutDialog.axaml b/YKanBan/Views/Dialogs/AboutDialog.axaml
new file mode 100644
index 0000000..537525f
--- /dev/null
+++ b/YKanBan/Views/Dialogs/AboutDialog.axaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/AboutDialog.axaml.cs b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs
new file mode 100644
index 0000000..7fb96ea
--- /dev/null
+++ b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class AboutDialog : Window
+{
+ public AboutDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// Closes the dialog.
+ private void OnCloseClick(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml b/YKanBan/Views/Dialogs/SettingsDialog.axaml
new file mode 100644
index 0000000..31d1539
--- /dev/null
+++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
new file mode 100644
index 0000000..ba0cac9
--- /dev/null
+++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
@@ -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();
+ }
+
+ /// Applies the working settings and closes with a positive result.
+ private void OnOkClick(object? sender, RoutedEventArgs e)
+ {
+ (DataContext as SettingsDialogViewModel)?.Accept();
+ Close(true);
+ }
+
+ /// Closes without applying anything.
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml
new file mode 100644
index 0000000..0b573fc
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs
new file mode 100644
index 0000000..2dc50a8
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.ErrorPages;
+
+public partial class ArgumentErrorPageView : UserControl
+{
+ public ArgumentErrorPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml
new file mode 100644
index 0000000..caeed09
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs
new file mode 100644
index 0000000..aa4c9f0
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.ErrorPages;
+
+public partial class DirectoryMissingPageView : UserControl
+{
+ public DirectoryMissingPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/ErrorPages/LockConflictPageView.axaml b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml
new file mode 100644
index 0000000..c0d4f74
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs
new file mode 100644
index 0000000..0905d8e
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.ErrorPages;
+
+public partial class LockConflictPageView : UserControl
+{
+ public LockConflictPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml
new file mode 100644
index 0000000..e4b9316
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs
new file mode 100644
index 0000000..0c90457
--- /dev/null
+++ b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.ErrorPages;
+
+public partial class NotInitializedPageView : UserControl
+{
+ public NotInitializedPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/MainWindow.axaml b/YKanBan/Views/MainWindow.axaml
index 37eb7d8..36f5995 100644
--- a/YKanBan/Views/MainWindow.axaml
+++ b/YKanBan/Views/MainWindow.axaml
@@ -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">
-
-
-
-
+
+
diff --git a/YKanBan/Views/MainWindow.axaml.cs b/YKanBan/Views/MainWindow.axaml.cs
index d50dd51..6cbaf8f 100644
--- a/YKanBan/Views/MainWindow.axaml.cs
+++ b/YKanBan/Views/MainWindow.axaml.cs
@@ -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();
}
-}
\ No newline at end of file
+
+ 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);
+ }
+ }
+
+ /// Shows the settings dialog centered on this window.
+ 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);
+ }
+
+ /// Shows the about dialog centered on this window.
+ private async Task ShowAboutAsync(Unit input)
+ {
+ var dialog = new AboutDialog
+ {
+ DataContext = new AboutDialogViewModel(),
+ };
+ await dialog.ShowDialog(this);
+ }
+}
diff --git a/YKanBan/Views/WorkspaceView.axaml b/YKanBan/Views/WorkspaceView.axaml
new file mode 100644
index 0000000..99f5d46
--- /dev/null
+++ b/YKanBan/Views/WorkspaceView.axaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/WorkspaceView.axaml.cs b/YKanBan/Views/WorkspaceView.axaml.cs
new file mode 100644
index 0000000..bfc5a17
--- /dev/null
+++ b/YKanBan/Views/WorkspaceView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views;
+
+public partial class WorkspaceView : UserControl
+{
+ public WorkspaceView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/YKanBan.csproj b/YKanBan/YKanBan.csproj
index 72cd205..2eca467 100644
--- a/YKanBan/YKanBan.csproj
+++ b/YKanBan/YKanBan.csproj
@@ -2,6 +2,7 @@
WinExe
net9.0
+ 0.1.0
enable
enable
true