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/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