feat: add gui shell with theme, i18n and error pages
This commit is contained in:
1 parent
22a0f61cb5
commit
f36561cb7f
33 files changed
+1333
-26
No files matched your search
+26
-4
@@ -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>
|
||||
</Application>
|
||||
|
||||
<!--
|
||||
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>
|
||||
+74
-9
@@ -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,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()
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <path></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 <路径></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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace YKanBan.Views;
|
||||
|
||||
public partial class WorkspaceView : UserControl
|
||||
{
|
||||
public WorkspaceView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user