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:
1 parent
4fc8fdda6f
commit
62a3c96dc0
33 files changed
+1314
-18
No files matched your search
+19
-1
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace YKanBan.Views.Pages;
|
||||||
|
|
||||||
|
public partial class MessagePage : UserControl
|
||||||
|
{
|
||||||
|
public MessagePage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace YKanBan.Views.Pages;
|
||||||
|
|
||||||
|
public partial class WelcomePage : UserControl
|
||||||
|
{
|
||||||
|
public WelcomePage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user