feat(m5): gui shell

- AppServices composition root: owns config, global workspace registry
  (app.db), platform path comparer and the LRU workspace connection pool;
  configuration persisted once at shutdown and services disposed
- AppDataPaths centralizes app.json / app.db locations; AppConfig gains
  a DefaultLanguage constant
- MVVM shell: MainWindowViewModel hosts SidebarViewModel and the current
  content page (welcome / workspace / localized failure state), mapping
  typed open failures to ResX messages
- SidebarViewModel: persistent workspace list with name-asc, name-desc
  and recently-updated sorting (selection persisted), open-project folder
  picker and initialize confirmation via Interaction<TInput,TOutput>
  handlers registered by the view; workspace entries open/remove
- Pages (welcome, workspace, message), dialogs (settings, about, confirm)
  with their view models; settings/about opened from the window
- App startup applies configured culture (RTL flow direction) and theme
  before the main window; forwarded IPC commands activate the window and
  switch workspace from the launch path
- Full UI text sourced from ResX in both en and zh-Hans; no hardcoded
  strings, theme colors via ThemeDictionaries
This commit is contained in:
yyc12345 committed 2026-10-02 18:02:22 +08:00
1 parent 4fc8fdda6f
commit 62a3c96dc0
33 files changed
+1314 -18

No files matched your search

+19 -1
View File
@@ -1,9 +1,27 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="YKanBan.App" x:Class="YKanBan.App"
xmlns:semi="https://irihi.tech/semi"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Styles> <Application.Styles>
<FluentTheme /> <semi:SemiTheme />
</Application.Styles> </Application.Styles>
<!--Custom colors MUST be declared as Light/Dark pairs inside ThemeDictionaries
and consumed via DynamicResource so theme switches restyle everything.-->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SidebarBackgroundBrush" Color="#F6F7F9"/>
<SolidColorBrush x:Key="SidebarBorderBrush" Color="#E2E4EA"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="SidebarBackgroundBrush" Color="#1C1E24"/>
<SolidColorBrush x:Key="SidebarBorderBrush" Color="#2C2F37"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application> </Application>
+68 -10
View File
@@ -1,18 +1,29 @@
using System.Linq; using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core; using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading; using Avalonia.Threading;
using YKanBan.Ipc; using YKanBan.Ipc;
using YKanBan.Services;
using YKanBan.Storage.AppData;
using YKanBan.ViewModels; using YKanBan.ViewModels;
using YKanBan.Views; using YKanBan.Views;
using Resx = YKanBan.Resources;
namespace YKanBan; namespace YKanBan;
public partial class App : Application public partial class App : Application
{ {
/// <summary>Composition root, created before the main window.</summary>
public static AppServices Services { get; } = new();
/// <summary>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() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
@@ -23,26 +34,69 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
DisableAvaloniaDataAnnotationValidation(); DisableAvaloniaDataAnnotationValidation();
// Startup order: apply the configured culture (and its RTL flow direction)
// and the theme BEFORE creating the main window.
CultureInfo culture = ResolveCulture(Services.Config.Language);
StartupLanguage = Services.Config.Language;
Resx.Culture = culture;
ApplyTheme(Services.Config.Theme);
var mainViewModel = new MainWindowViewModel(Services);
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(), DataContext = mainViewModel,
// 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,
}; };
// Primary instance: serve launch commands forwarded by secondary instances. // Primary instance: serve launch commands forwarded by secondary instances.
Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command)); Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, mainViewModel, command));
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
/// <summary> /// <summary>Resolves the configured language to a culture, falling back to English.</summary>
/// Brings the main window to front for a forwarded launch command. Both public static CultureInfo ResolveCulture(string language)
/// ShowMain and OpenWorkspace activate the window; switching to the
/// requested workspace lands together with the workspace UI.
/// </summary>
private static void HandleLaunchCommand(IClassicDesktopStyleApplicationLifetime desktop, LaunchCommand command)
{ {
// IPC delivery happens on a background thread; window access must hop to the UI thread. try
{
return new CultureInfo(language);
}
catch (CultureNotFoundException)
{
return new CultureInfo(AppConfig.DefaultLanguage);
}
}
/// <summary>Applies a theme selection immediately; Default follows the operating system.</summary>
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,
};
}
/// <summary>Activates the window for a forwarded command; OpenWorkspace also switches to it.</summary>
private static void HandleLaunchCommand(
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel mainViewModel,
LaunchCommand command)
{
// IPC delivery happens on a background thread; hop to the UI thread.
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
if (desktop.MainWindow is { } window) if (desktop.MainWindow is { } window)
@@ -50,6 +104,10 @@ public partial class App : Application
window.Show(); window.Show();
window.Activate(); window.Activate();
} }
if (command is LaunchCommand.OpenWorkspace open)
{
mainViewModel.OpenWorkspaceFromLaunch(open.Path);
}
}); });
} }
+135
View File
@@ -51,4 +51,139 @@
<data name="Export_TagsTableHeader" xml:space="preserve"> <data name="Export_TagsTableHeader" xml:space="preserve">
<value>| Name | Color | Description | Usage |</value> <value>| Name | Color | Description | Usage |</value>
</data> </data>
<data name="App_Name" xml:space="preserve">
<value>YKanBan</value>
</data>
<data name="Common_Yes" xml:space="preserve">
<value>Yes</value>
</data>
<data name="Common_No" xml:space="preserve">
<value>No</value>
</data>
<data name="Common_Close" xml:space="preserve">
<value>Close</value>
</data>
<data name="Sidebar_Welcome" xml:space="preserve">
<value>Welcome</value>
</data>
<data name="Sidebar_OpenProject" xml:space="preserve">
<value>Open project…</value>
</data>
<data name="Sidebar_SortLabel" xml:space="preserve">
<value>Sort</value>
</data>
<data name="Sidebar_Sort_NameAsc" xml:space="preserve">
<value>Name A to Z</value>
</data>
<data name="Sidebar_Sort_NameDesc" xml:space="preserve">
<value>Name Z to A</value>
</data>
<data name="Sidebar_Sort_RecentlyUpdated" xml:space="preserve">
<value>Recently updated</value>
</data>
<data name="Sidebar_Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="Sidebar_About" xml:space="preserve">
<value>About</value>
</data>
<data name="Sidebar_RemoveFromList" xml:space="preserve">
<value>Remove from list</value>
</data>
<data name="Welcome_Hint" xml:space="preserve">
<value>Open or create a workspace from the left.</value>
</data>
<data name="Dialog_InitializeTitle" xml:space="preserve">
<value>Initialize workspace?</value>
</data>
<data name="Dialog_InitializeMessage" xml:space="preserve">
<value>There is no .ykanban structure in "{0}". Initialize it here?</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_RepositoryUrl" xml:space="preserve">
<value>(to be filled)</value>
</data>
<data name="About_License" xml:space="preserve">
<value>License</value>
</data>
<data name="About_LicenseValue" xml:space="preserve">
<value>(to be filled)</value>
</data>
<data name="State_LockConflict_Title" xml:space="preserve">
<value>Workspace locked</value>
</data>
<data name="State_LockConflict_Detail" xml:space="preserve">
<value>The workspace is locked by another instance.</value>
</data>
<data name="State_NotInitialized_Title" xml:space="preserve">
<value>No workspace here</value>
</data>
<data name="State_NotInitialized_Detail" xml:space="preserve">
<value>The folder has no .ykanban structure.</value>
</data>
<data name="State_DirectoryMissing_Title" xml:space="preserve">
<value>Folder missing</value>
</data>
<data name="State_DirectoryMissing_Detail" xml:space="preserve">
<value>The folder does not exist on disk.</value>
</data>
<data name="State_SchemaVersion_Title" xml:space="preserve">
<value>Incompatible database</value>
</data>
<data name="State_SchemaVersion_Detail" xml:space="preserve">
<value>The workspace was written by a newer version.</value>
</data>
<data name="State_Unknown_Title" xml:space="preserve">
<value>Open failed</value>
</data>
</root> </root>
@@ -51,4 +51,139 @@
<data name="Export_TagsTableHeader" xml:space="preserve"> <data name="Export_TagsTableHeader" xml:space="preserve">
<value>| 名称 | 颜色 | 描述 | 使用数 |</value> <value>| 名称 | 颜色 | 描述 | 使用数 |</value>
</data> </data>
<data name="App_Name" xml:space="preserve">
<value>YKanBan</value>
</data>
<data name="Common_Yes" xml:space="preserve">
<value>是</value>
</data>
<data name="Common_No" xml:space="preserve">
<value>否</value>
</data>
<data name="Common_Close" xml:space="preserve">
<value>关闭</value>
</data>
<data name="Sidebar_Welcome" xml:space="preserve">
<value>欢迎页</value>
</data>
<data name="Sidebar_OpenProject" xml:space="preserve">
<value>打开项目…</value>
</data>
<data name="Sidebar_SortLabel" xml:space="preserve">
<value>排序</value>
</data>
<data name="Sidebar_Sort_NameAsc" xml:space="preserve">
<value>名称 A 到 Z</value>
</data>
<data name="Sidebar_Sort_NameDesc" xml:space="preserve">
<value>名称 Z 到 A</value>
</data>
<data name="Sidebar_Sort_RecentlyUpdated" xml:space="preserve">
<value>最近更新</value>
</data>
<data name="Sidebar_Settings" xml:space="preserve">
<value>设置</value>
</data>
<data name="Sidebar_About" xml:space="preserve">
<value>关于</value>
</data>
<data name="Sidebar_RemoveFromList" xml:space="preserve">
<value>从列表移除</value>
</data>
<data name="Welcome_Hint" xml:space="preserve">
<value>从左侧打开或新建工作区。</value>
</data>
<data name="Dialog_InitializeTitle" xml:space="preserve">
<value>初始化工作区?</value>
</data>
<data name="Dialog_InitializeMessage" xml:space="preserve">
<value>“{0}”中不存在 .ykanban 数据。在此初始化?</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_RepositoryUrl" xml:space="preserve">
<value>(待填写)</value>
</data>
<data name="About_License" xml:space="preserve">
<value>许可证</value>
</data>
<data name="About_LicenseValue" xml:space="preserve">
<value>(待填写)</value>
</data>
<data name="State_LockConflict_Title" xml:space="preserve">
<value>工作区已被占用</value>
</data>
<data name="State_LockConflict_Detail" xml:space="preserve">
<value>该工作区正被另一实例锁定。</value>
</data>
<data name="State_NotInitialized_Title" xml:space="preserve">
<value>此处没有工作区</value>
</data>
<data name="State_NotInitialized_Detail" xml:space="preserve">
<value>该文件夹没有 .ykanban 数据。</value>
</data>
<data name="State_DirectoryMissing_Title" xml:space="preserve">
<value>文件夹不存在</value>
</data>
<data name="State_DirectoryMissing_Detail" xml:space="preserve">
<value>该文件夹在磁盘上不存在。</value>
</data>
<data name="State_SchemaVersion_Title" xml:space="preserve">
<value>数据库版本不兼容</value>
</data>
<data name="State_SchemaVersion_Detail" xml:space="preserve">
<value>该工作区由更新版本的程序创建。</value>
</data>
<data name="State_Unknown_Title" xml:space="preserve">
<value>打开失败</value>
</data>
</root> </root>
+4
View File
@@ -24,6 +24,10 @@ sealed class Program
SingleInstance = singleInstance; SingleInstance = singleInstance;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// Lifetime ended: persist the configuration once, then release the services.
App.Services.SaveConfiguration();
App.Services.Dispose();
} }
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
+51
View File
@@ -0,0 +1,51 @@
using YKanBan.Platform;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
namespace YKanBan.Services;
/// <summary>
/// Composition root: owns the application-wide configuration, the global
/// workspace registry and the workspace connection pool for the whole
/// process lifetime. The configuration is persisted once at shutdown.
/// </summary>
public sealed class AppServices : IDisposable
{
/// <summary>Maximum number of simultaneously open workspaces.</summary>
private const int PoolCapacity = 8;
/// <summary>Loads the configuration and opens the global services.</summary>
public AppServices()
{
ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath);
Config = ConfigStore.Load();
Database = new AppDatabase(AppDataPaths.AppDatabasePath);
PathComparison = PathComparer.ForCurrentPlatform();
Pool = new WorkspaceConnectionPool(PoolCapacity, Database, PathComparison);
}
/// <summary>The mutable application configuration; persisted by <see cref="SaveConfiguration"/>.</summary>
public AppConfig Config { get; }
/// <summary>Store for app.json.</summary>
public AppConfigStore ConfigStore { get; }
/// <summary>Global workspace registry (app.db).</summary>
public AppDatabase Database { get; }
/// <summary>LRU pool of open workspace sessions.</summary>
public WorkspaceConnectionPool Pool { get; }
/// <summary>Platform path comparison rules.</summary>
public PathComparer PathComparison { get; }
/// <summary>Persists the configuration; called once at shutdown.</summary>
public void SaveConfiguration() => ConfigStore.Save(Config);
/// <summary>Releases the pool and the registry.</summary>
public void Dispose()
{
Pool.Dispose();
Database.Dispose();
}
}
+4 -1
View File
@@ -130,13 +130,16 @@ public sealed class AppConfig
/// <summary>Current configuration format version.</summary> /// <summary>Current configuration format version.</summary>
public const int CurrentFormatVersion = 1; public const int CurrentFormatVersion = 1;
/// <summary>The default UI language code.</summary>
public const string DefaultLanguage = "en";
/// <summary>Configuration format version of this file.</summary> /// <summary>Configuration format version of this file.</summary>
[JsonProperty("version")] [JsonProperty("version")]
public int Version { get; set; } = CurrentFormatVersion; public int Version { get; set; } = CurrentFormatVersion;
/// <summary>UI language code: "en" (default) or "zh-Hans".</summary> /// <summary>UI language code: "en" (default) or "zh-Hans".</summary>
[JsonProperty("language")] [JsonProperty("language")]
public string Language { get; set; } = "en"; public string Language { get; set; } = DefaultLanguage;
/// <summary>UI theme selection (default: follow the system).</summary> /// <summary>UI theme selection (default: follow the system).</summary>
[JsonProperty("theme")] [JsonProperty("theme")]
+15
View File
@@ -0,0 +1,15 @@
namespace YKanBan.Storage.AppData;
/// <summary>Well-known locations under the per-user application data folder.</summary>
public static class AppDataPaths
{
/// <summary>The per-user YKanBan application data folder.</summary>
public static string Directory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "YKanBan");
/// <summary>Full path of app.json.</summary>
public static string AppConfigJsonPath => Path.Combine(Directory, "app.json");
/// <summary>Full path of app.db.</summary>
public static string AppDatabasePath => Path.Combine(Directory, "app.db");
}
@@ -0,0 +1,26 @@
using System.Reflection;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>The about dialog data: application identity and version.</summary>
public sealed class AboutDialogViewModel : ViewModelBase
{
/// <summary>Display name of the application.</summary>
public string ApplicationName => Resources.App_Name;
/// <summary>Localized "Version x.y.z" text.</summary>
public string VersionText => string.Format(
System.Globalization.CultureInfo.CurrentCulture,
Resources.About_Version,
AssemblyVersion);
/// <summary>Repository URL (from resources).</summary>
public string RepositoryUrl => Resources.About_RepositoryUrl;
/// <summary>License name (from resources).</summary>
public string LicenseValue => Resources.About_LicenseValue;
/// <summary>The assembly version, formatted as major.minor.patch.</summary>
public static string AssemblyVersion =>
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
}
@@ -0,0 +1,25 @@
namespace YKanBan.ViewModels.Dialogs;
/// <summary>A generic confirmation dialog: localized title, message and yes/no answers.</summary>
public sealed class ConfirmDialogViewModel : ViewModelBase
{
public ConfirmDialogViewModel(string title, string message)
{
Title = title;
Message = message;
ConfirmText = Resources.Common_Yes;
CancelText = Resources.Common_No;
}
/// <summary>Localized dialog heading.</summary>
public string Title { get; }
/// <summary>Localized message body.</summary>
public string Message { get; }
/// <summary>Label of the confirming button.</summary>
public string ConfirmText { get; }
/// <summary>Label of the cancelling button.</summary>
public string CancelText { get; }
}
@@ -0,0 +1,107 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Services;
using YKanBan.Storage.AppData;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The settings dialog: language (restart to apply), theme (applies
/// immediately) and the four confirmation-dialog toggles. All changes write
/// straight into the configuration, which persists at shutdown.
/// </summary>
public partial class SettingsDialogViewModel : ViewModelBase
{
private readonly AppConfig _config;
private readonly string _startupLanguage;
public SettingsDialogViewModel(AppServices services)
{
_config = services.Config;
_startupLanguage = App.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;
}
/// <summary>Available languages.</summary>
public IReadOnlyList<OptionItem> Languages { get; }
/// <summary>Available themes.</summary>
public IReadOnlyList<OptionItem> Themes { get; }
/// <summary>The selected language; takes effect after a restart.</summary>
[ObservableProperty]
private OptionItem? selectedLanguage;
/// <summary>The selected theme; applies immediately.</summary>
[ObservableProperty]
private OptionItem? selectedTheme;
/// <summary>Whether deleting a card asks for confirmation.</summary>
[ObservableProperty]
private bool confirmDeleteCard;
/// <summary>Whether deleting a column asks for confirmation.</summary>
[ObservableProperty]
private bool confirmDeleteColumn;
/// <summary>Whether deleting a tag asks for confirmation.</summary>
[ObservableProperty]
private bool confirmDeleteTag;
/// <summary>Whether discarding card edits asks for confirmation.</summary>
[ObservableProperty]
private bool confirmDiscardEdit;
/// <summary>True while the selected language differs from the one applied at startup.</summary>
[ObservableProperty]
private bool restartNoticeVisible;
/// <summary>Stores the language and refreshes the restart notice.</summary>
partial void OnSelectedLanguageChanged(OptionItem? value)
{
if (value is null)
{
return;
}
_config.Language = (string)value.Value;
RestartNoticeVisible = _config.Language != _startupLanguage;
}
/// <summary>Stores the theme and applies it immediately.</summary>
partial void OnSelectedThemeChanged(OptionItem? value)
{
if (value is null)
{
return;
}
_config.Theme = (ThemeOption)value.Value;
App.ApplyTheme(_config.Theme);
}
partial void OnConfirmDeleteCardChanged(bool value) => _config.Confirmations.DeleteCard = value;
partial void OnConfirmDeleteColumnChanged(bool value) => _config.Confirmations.DeleteColumn = value;
partial void OnConfirmDeleteTagChanged(bool value) => _config.Confirmations.DeleteTag = value;
partial void OnConfirmDiscardEditChanged(bool value) => _config.Confirmations.DiscardEdit = value;
}
+26
View File
@@ -0,0 +1,26 @@
namespace YKanBan.ViewModels;
/// <summary>Input placeholder for interactions that carry no meaningful input.</summary>
public readonly record struct Unit;
/// <summary>
/// A view-model side request that the owning view fulfils through a
/// registered handler (dialogs, pickers) — the classic MVVM interaction
/// pattern, implemented locally without extra dependencies.
/// </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>
public void RegisterHandler(Func<TInput, Task<TOutput>> handler) => _handler = handler;
/// <summary>Invokes the registered handler.</summary>
/// <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);
}
+99 -1
View File
@@ -1,5 +1,103 @@
namespace YKanBan.ViewModels; using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Services;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Pages;
namespace YKanBan.ViewModels;
/// <summary>
/// Root view model: owns the sidebar and the current content page (welcome,
/// an open workspace, or a failure state page).
/// </summary>
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase
{ {
public MainWindowViewModel(AppServices services)
{
Services = services;
Sidebar = new SidebarViewModel(this, services);
CurrentContent = new WelcomePageViewModel();
}
/// <summary>The composition root.</summary>
public AppServices Services { get; }
/// <summary>The workspace sidebar.</summary>
public SidebarViewModel Sidebar { get; }
/// <summary>The currently displayed content page.</summary>
[ObservableProperty]
private object? currentContent;
/// <summary>Shows the welcome page (no workspace active).</summary>
public void ShowWelcome()
{
CurrentContent = new WelcomePageViewModel();
}
/// <summary>
/// Opens (or switches to) a workspace from the UI flow: a plain folder
/// prompts for initialization before opening.
/// </summary>
public async Task OpenWorkspaceWithInitializePromptAsync(string folderPath)
{
string canonical = Services.PathComparison.Normalize(folderPath);
if (!WorkspaceInitializer.IsWorkspace(canonical))
{
if (!Directory.Exists(canonical))
{
ShowFailure(Resources.State_DirectoryMissing_Title, Resources.State_DirectoryMissing_Detail);
return;
}
bool confirmed = await Sidebar.ConfirmInitialize.Handle(canonical);
if (!confirmed)
{
return;
}
WorkspaceInitializer.Initialize(canonical);
WorkspacePreset.SeedColumns(canonical);
}
TryOpenWorkspaceSession(canonical);
}
/// <summary>Opens (or switches to) a workspace without any initialization prompt (IPC entry).</summary>
public void OpenWorkspaceFromLaunch(string folderPath)
{
TryOpenWorkspaceSession(Services.PathComparison.Normalize(folderPath));
}
/// <summary>Opens the session through the pool; typed failures map to localized state pages.</summary>
public void TryOpenWorkspaceSession(string canonicalPath)
{
try
{
WorkspaceSession session = Services.Pool.Open(canonicalPath);
CurrentContent = new WorkspacePageViewModel(session);
Sidebar.RebuildWorkspaceList();
}
catch (Exception ex)
{
(string title, string detail) = DescribeOpenFailure(ex);
ShowFailure(title, detail);
}
}
/// <summary>Displays a localized failure state page.</summary>
private void ShowFailure(string title, string detail)
{
CurrentContent = new MessagePageViewModel(title, detail);
}
/// <summary>Maps an open failure to localized title/detail text by exception type.</summary>
private static (string Title, string Detail) DescribeOpenFailure(Exception exception) => exception switch
{
WorkspaceLockException => (Resources.State_LockConflict_Title, Resources.State_LockConflict_Detail),
WorkspaceNotInitializedException => (Resources.State_NotInitialized_Title, Resources.State_NotInitialized_Detail),
WorkspaceDirectoryMissingException => (Resources.State_DirectoryMissing_Title, Resources.State_DirectoryMissing_Detail),
SchemaVersionException => (Resources.State_SchemaVersion_Title, Resources.State_SchemaVersion_Detail),
_ => (Resources.State_Unknown_Title, exception.Message),
};
} }
+6
View File
@@ -0,0 +1,6 @@
namespace YKanBan.ViewModels;
/// <summary>A selectable option item for combo boxes: a value plus its localized label.</summary>
/// <param name="Value">The backing value (enum value or language code).</param>
/// <param name="Label">The localized display label.</param>
public sealed record OptionItem(object Value, string Label);
@@ -0,0 +1,17 @@
namespace YKanBan.ViewModels.Pages;
/// <summary>A failure state page: a localized title with a detail line.</summary>
public sealed class MessagePageViewModel : ViewModelBase
{
public MessagePageViewModel(string title, string detail)
{
Title = title;
Detail = detail;
}
/// <summary>Localized failure heading.</summary>
public string Title { get; }
/// <summary>Localized failure explanation.</summary>
public string Detail { get; }
}
@@ -0,0 +1,6 @@
namespace YKanBan.ViewModels.Pages;
/// <summary>The welcome page: application identity plus a hint to open a workspace.</summary>
public sealed class WelcomePageViewModel : ViewModelBase
{
}
@@ -0,0 +1,22 @@
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Pages;
/// <summary>
/// An open workspace page. The board, tags and search views mount here in
/// the later UI milestones; the frame shows the workspace identity for now.
/// </summary>
public sealed class WorkspacePageViewModel : ViewModelBase
{
public WorkspacePageViewModel(WorkspaceSession session)
{
FullPath = session.FolderPath;
Name = Path.GetFileName(session.FolderPath);
}
/// <summary>Canonical workspace folder path.</summary>
public string FullPath { get; }
/// <summary>Display name: the folder name.</summary>
public string Name { get; }
}
+140
View File
@@ -0,0 +1,140 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Services;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Pages;
namespace YKanBan.ViewModels;
/// <summary>
/// The sidebar: welcome and open-project buttons, the persistent workspace
/// list with its sort options, and the settings/about entries at the bottom.
/// </summary>
public partial class SidebarViewModel : ViewModelBase
{
private readonly MainWindowViewModel _owner;
private readonly AppServices _services;
public SidebarViewModel(MainWindowViewModel owner, AppServices services)
{
_owner = owner;
_services = services;
SortOptions =
[
new OptionItem(SidebarSortOption.NameAsc, Resources.Sidebar_Sort_NameAsc),
new OptionItem(SidebarSortOption.NameDesc, Resources.Sidebar_Sort_NameDesc),
new OptionItem(SidebarSortOption.RecentlyUpdated, Resources.Sidebar_Sort_RecentlyUpdated),
];
selectedSort = SortOptions.Single(option => option.Value.Equals(services.Config.Sort.Sidebar));
RebuildWorkspaceList();
}
/// <summary>Known workspaces in display order.</summary>
public ObservableCollection<WorkspaceItemViewModel> Workspaces { get; } = [];
/// <summary>The available list sort modes.</summary>
public IReadOnlyList<OptionItem> SortOptions { get; }
/// <summary>Asks the view for a folder via the system folder picker; null when cancelled.</summary>
public Interaction<Unit, string?> PickFolder { get; } = new();
/// <summary>Asks the view to confirm initializing a plain folder as a workspace.</summary>
public Interaction<string, bool> ConfirmInitialize { get; } = new();
/// <summary>The selected list sort mode; persisted with the configuration.</summary>
[ObservableProperty]
private OptionItem? selectedSort;
/// <summary>Shows the welcome page.</summary>
[RelayCommand]
private void ShowWelcome()
{
_owner.ShowWelcome();
}
/// <summary>Picks a folder and opens it as a workspace (offering initialization).</summary>
[RelayCommand]
private async Task OpenProjectAsync()
{
string? folder = await PickFolder.Handle(default);
if (folder is null)
{
return;
}
await _owner.OpenWorkspaceWithInitializePromptAsync(folder);
}
/// <summary>Rebuilds the list from the persistent registry, marking open workspaces.</summary>
public void RebuildWorkspaceList()
{
Workspaces.Clear();
foreach (WorkspaceRecord record in _services.Database.GetWorkspaces())
{
bool isOpen = _services.Pool.Find(record.Path) is not null;
Workspaces.Add(new WorkspaceItemViewModel(this, record.Path, Path.GetFileName(record.Path), record.LastEditedAt, isOpen));
}
ReorderWorkspaceList();
}
/// <summary>Opens a workspace when its sidebar entry is activated.</summary>
public void OpenWorkspace(WorkspaceItemViewModel item)
{
_owner.TryOpenWorkspaceSession(item.FullPath);
}
/// <summary>Releases the workspace resources and forgets its sidebar entry.</summary>
public void RemoveWorkspace(WorkspaceItemViewModel item)
{
_services.Pool.Remove(item.FullPath);
RebuildWorkspaceList();
// Removing the active workspace falls back to the welcome page.
if (_owner.CurrentContent is WorkspacePageViewModel active && active.FullPath == item.FullPath)
{
_owner.ShowWelcome();
}
}
/// <summary>Applies the selected sort mode to the list in place.</summary>
partial void OnSelectedSortChanged(OptionItem? value)
{
if (value is null)
{
return;
}
_services.Config.Sort.Sidebar = (SidebarSortOption)value.Value;
ReorderWorkspaceList();
}
/// <summary>Sorts the workspace collection by the current mode.</summary>
private void ReorderWorkspaceList()
{
if (SelectedSort?.Value is not SidebarSortOption mode)
{
return;
}
Comparison<WorkspaceItemViewModel> comparison = mode switch
{
SidebarSortOption.NameAsc => (a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase),
SidebarSortOption.NameDesc => (a, b) => string.Compare(b.Name, a.Name, StringComparison.OrdinalIgnoreCase),
SidebarSortOption.RecentlyUpdated => (a, b) => b.LastEditedAt.CompareTo(a.LastEditedAt),
_ => (a, b) => 0,
};
// Move each item into its sorted position (in-place keeps bindings alive).
List<WorkspaceItemViewModel> ordered = Workspaces.ToList();
ordered.Sort(comparison);
for (int index = 0; index < ordered.Count; index++)
{
int current = Workspaces.IndexOf(ordered[index]);
if (current != index)
{
Workspaces.Move(current, index);
}
}
}
}
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace YKanBan.ViewModels;
/// <summary>One sidebar workspace entry.</summary>
public partial class WorkspaceItemViewModel : ViewModelBase
{
private readonly SidebarViewModel _owner;
public WorkspaceItemViewModel(SidebarViewModel owner, string fullPath, string name, long lastEditedAt, bool isOpen)
{
_owner = owner;
FullPath = fullPath;
Name = name;
LastEditedAt = lastEditedAt;
IsOpen = isOpen;
}
/// <summary>Canonical workspace folder path.</summary>
public string FullPath { get; }
/// <summary>Display name: the folder name.</summary>
public string Name { get; }
/// <summary>Last edit time in Unix seconds, used by the "recently updated" sort.</summary>
public long LastEditedAt { get; }
/// <summary>Whether the workspace currently has an open session.</summary>
[ObservableProperty]
private bool isOpen;
/// <summary>Opens (or switches to) this workspace.</summary>
[RelayCommand]
private void Open()
{
_owner.OpenWorkspace(this);
}
/// <summary>Releases the workspace resources and removes the sidebar entry.</summary>
[RelayCommand]
private void Remove()
{
_owner.RemoveWorkspace(this);
}
}
+30
View File
@@ -0,0 +1,30 @@
<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"
x:Class="YKanBan.Views.Dialogs.AboutDialog"
x:DataType="vm:AboutDialogViewModel"
Title="{x:Static lang:Resources.About_Title}"
WindowStartupLocation="CenterOwner"
SizeToContent="Height" Width="380" CanResize="False"
ShowInTaskbar="False">
<StackPanel Margin="20" Spacing="10">
<TextBlock Text="{Binding ApplicationName}" FontSize="22" FontWeight="Bold"/>
<TextBlock Text="{Binding VersionText}" Opacity="0.8"/>
<Grid ColumnDefinitions="Auto,*" Margin="0,6,0,0">
<TextBlock Grid.Column="0" Text="{x:Static lang:Resources.About_Repository}" Opacity="0.7" Margin="0,0,8,0"/>
<TextBlock Grid.Column="1" Text="{Binding RepositoryUrl}" TextWrapping="Wrap"/>
</Grid>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="{x:Static lang:Resources.About_License}" Opacity="0.7" Margin="0,0,8,0"/>
<TextBlock Grid.Column="1" Text="{Binding LicenseValue}"/>
</Grid>
<Button Content="{x:Static lang:Resources.Common_Close}" IsCancel="True"
HorizontalAlignment="Right" MinWidth="90" Margin="0,8,0,0"/>
</StackPanel>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Dialogs;
public partial class AboutDialog : Window
{
public AboutDialog()
{
InitializeComponent();
}
}
+19
View File
@@ -0,0 +1,19 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
x:Class="YKanBan.Views.Dialogs.ConfirmDialog"
x:DataType="vm:ConfirmDialogViewModel"
Title="{Binding Title}"
WindowStartupLocation="CenterOwner"
SizeToContent="Height" Width="440" CanResize="False"
ShowInTaskbar="False">
<StackPanel Margin="20" Spacing="16">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="{Binding CancelText}" MinWidth="90" IsCancel="True" Click="OnCancelClick"/>
<Button Content="{Binding ConfirmText}" MinWidth="90" IsDefault="True" Classes="Primary" Click="OnConfirmClick"/>
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,16 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class ConfirmDialog : Window
{
public ConfirmDialog()
{
InitializeComponent();
}
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,50 @@
<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"
x:Class="YKanBan.Views.Dialogs.SettingsDialog"
x:DataType="vm:SettingsDialogViewModel"
Title="{x:Static lang:Resources.Settings_Title}"
WindowStartupLocation="CenterOwner"
SizeToContent="Height" Width="440" CanResize="False"
ShowInTaskbar="False">
<StackPanel Margin="20" 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>
<Button Content="{x:Static lang:Resources.Common_Close}" IsCancel="True"
HorizontalAlignment="Right" MinWidth="90"/>
</StackPanel>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Dialogs;
public partial class SettingsDialog : Window
{
public SettingsDialog()
{
InitializeComponent();
}
}
+97 -5
View File
@@ -1,15 +1,107 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels" xmlns:vm="using:YKanBan.ViewModels"
xmlns:pages="using:YKanBan.Views.Pages"
xmlns:lang="using:YKanBan"
xmlns:iconPacks="using:IconPacks.Avalonia.Lucide"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 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:Class="YKanBan.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Title="YKanBan"> Icon="/Assets/avalonia-logo.ico"
Title="{x:Static lang:Resources.App_Name}"
Width="1000" Height="640" MinWidth="720" MinHeight="480">
<Design.DataContext> <Grid ColumnDefinitions="260,*">
<vm:MainWindowViewModel/> <!-- Sidebar -->
</Design.DataContext> <Border Grid.Column="0"
Background="{DynamicResource SidebarBackgroundBrush}"
BorderBrush="{DynamicResource SidebarBorderBrush}"
BorderThickness="0,0,1,0"
Padding="8">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<Button Grid.Row="0" Margin="0,8,0,4" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding Sidebar.ShowWelcomeCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<iconPacks:PackIconLucide Kind="House"/>
<TextBlock Text="{x:Static lang:Resources.Sidebar_Welcome}"/>
</StackPanel>
</Button>
<Button Grid.Row="1" Margin="0,4" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding Sidebar.OpenProjectCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<iconPacks:PackIconLucide Kind="FolderPlus"/>
<TextBlock Text="{x:Static lang:Resources.Sidebar_OpenProject}"/>
</StackPanel>
</Button>
<TextBlock Grid.Row="2" Margin="8,12,0,4" Opacity="0.65"
Text="{x:Static lang:Resources.Sidebar_SortLabel}"/>
<ComboBox Grid.Row="3" Margin="0,0,0,8" HorizontalAlignment="Stretch"
ItemsSource="{Binding Sidebar.SortOptions}"
SelectedItem="{Binding Sidebar.SelectedSort}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ListBox Grid.Row="4" ItemsSource="{Binding Sidebar.Workspaces}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:WorkspaceItemViewModel">
<Button Margin="0,1" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding OpenCommand}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{x:Static lang:Resources.Sidebar_RemoveFromList}"
Command="{Binding RemoveCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="●" Opacity="0.55"
IsVisible="{Binding IsOpen}"/>
</Grid>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="5" Orientation="Horizontal" Spacing="8" Margin="0,8">
<Button Click="OnSettingsClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<iconPacks:PackIconLucide Kind="Settings"/>
<TextBlock Text="{x:Static lang:Resources.Sidebar_Settings}"/>
</StackPanel>
</Button>
<Button Click="OnAboutClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<iconPacks:PackIconLucide Kind="Info"/>
<TextBlock Text="{x:Static lang:Resources.Sidebar_About}"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Content area -->
<Border Grid.Column="1">
<ContentControl Content="{Binding CurrentContent}">
<ContentControl.DataTemplates>
<DataTemplate x:DataType="vm:Pages.WelcomePageViewModel">
<pages:WelcomePage/>
</DataTemplate>
<DataTemplate x:DataType="vm:Pages.WorkspacePageViewModel">
<pages:WorkspacePage/>
</DataTemplate>
<DataTemplate x:DataType="vm:Pages.MessagePageViewModel">
<pages:MessagePage/>
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</Border>
</Grid>
</Window> </Window>
+65
View File
@@ -1,11 +1,76 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using YKanBan.ViewModels;
using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs;
using Resx = YKanBan.Resources;
namespace YKanBan.Views; namespace YKanBan.Views;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private bool _interactionsRegistered;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered)
{
_interactionsRegistered = true;
RegisterInteractionHandlers(viewModel);
}
}
/// <summary>Wires the view model's dialog interactions to this window.</summary>
private void RegisterInteractionHandlers(MainWindowViewModel viewModel)
{
viewModel.Sidebar.PickFolder.RegisterHandler(async _ =>
{
IReadOnlyList<IStorageFolder> folders = await StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions
{
Title = Resx.Sidebar_OpenProject,
AllowMultiple = false,
});
return folders.Count == 1 ? folders[0].TryGetLocalPath() : null;
});
viewModel.Sidebar.ConfirmInitialize.RegisterHandler(async folderPath =>
{
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogViewModel(
Resx.Dialog_InitializeTitle,
string.Format(CultureInfo.CurrentCulture, Resx.Dialog_InitializeMessage, folderPath)),
};
return await dialog.ShowDialog<bool>(this);
});
}
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog
{
DataContext = new SettingsDialogViewModel(App.Services),
};
await dialog.ShowDialog(this);
}
private async void OnAboutClick(object? sender, RoutedEventArgs e)
{
var dialog = new AboutDialog
{
DataContext = new AboutDialogViewModel(),
};
await dialog.ShowDialog(this);
}
} }
+10
View File
@@ -0,0 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Pages"
x:Class="YKanBan.Views.Pages.MessagePage"
x:DataType="vm:MessagePageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Detail}" Opacity="0.7" HorizontalAlignment="Center" TextWrapping="Wrap"/>
</StackPanel>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Pages;
public partial class MessagePage : UserControl
{
public MessagePage()
{
InitializeComponent();
}
}
+11
View File
@@ -0,0 +1,11 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Pages.WelcomePage">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
<TextBlock Text="{x:Static lang:Resources.App_Name}" FontSize="32" FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{x:Static lang:Resources.Welcome_Hint}" Opacity="0.65"
HorizontalAlignment="Center"/>
</StackPanel>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Pages;
public partial class WelcomePage : UserControl
{
public WelcomePage()
{
InitializeComponent();
}
}
+10
View File
@@ -0,0 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Pages"
x:Class="YKanBan.Views.Pages.WorkspacePage"
x:DataType="vm:WorkspacePageViewModel">
<StackPanel Margin="24,20" Spacing="6">
<TextBlock Text="{Binding Name}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding FullPath}" Opacity="0.6" FontSize="12"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Pages;
public partial class WorkspacePage : UserControl
{
public WorkspacePage()
{
InitializeComponent();
}
}