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
+1315
-19
No files matched your search
+19
-1
@@ -1,9 +1,27 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="YKanBan.App"
|
||||
xmlns:semi="https://irihi.tech/semi"
|
||||
RequestedThemeVariant="Default">
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<semi:SemiTheme />
|
||||
</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>
|
||||
+68
-10
@@ -1,18 +1,29 @@
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using YKanBan.Ipc;
|
||||
using YKanBan.Services;
|
||||
using YKanBan.Storage.AppData;
|
||||
using YKanBan.ViewModels;
|
||||
using YKanBan.Views;
|
||||
using Resx = YKanBan.Resources;
|
||||
|
||||
namespace YKanBan;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>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()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -23,26 +34,69 @@ public partial class App : Application
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
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
|
||||
{
|
||||
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.
|
||||
Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command));
|
||||
Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, mainViewModel, command));
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the main window to front for a forwarded launch command. Both
|
||||
/// 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)
|
||||
/// <summary>Resolves the configured language to a culture, falling back to English.</summary>
|
||||
public static CultureInfo ResolveCulture(string language)
|
||||
{
|
||||
// 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(() =>
|
||||
{
|
||||
if (desktop.MainWindow is { } window)
|
||||
@@ -50,6 +104,10 @@ public partial class App : Application
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
if (command is LaunchCommand.OpenWorkspace open)
|
||||
{
|
||||
mainViewModel.OpenWorkspaceFromLaunch(open.Path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,4 +51,139 @@
|
||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||
<value>| Name | Color | Description | Usage |</value>
|
||||
</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>
|
||||
@@ -51,4 +51,139 @@
|
||||
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
|
||||
</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>
|
||||
@@ -24,6 +24,10 @@ sealed class Program
|
||||
|
||||
SingleInstance = singleInstance;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Lifetime ended: persist the configuration once, then release the services.
|
||||
App.Services.SaveConfiguration();
|
||||
App.Services.Dispose();
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
[JsonProperty("version")]
|
||||
public int Version { get; set; } = CurrentFormatVersion;
|
||||
|
||||
/// <summary>UI language code: "en" (default) or "zh-Hans".</summary>
|
||||
[JsonProperty("language")]
|
||||
public string Language { get; set; } = "en";
|
||||
public string Language { get; set; } = DefaultLanguage;
|
||||
|
||||
/// <summary>UI theme selection (default: follow the system).</summary>
|
||||
[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 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"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
|
||||
x:Class="YKanBan.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="YKanBan">
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="{x:Static lang:Resources.App_Name}"
|
||||
Width="1000" Height="640" MinWidth="720" MinHeight="480">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
<Grid ColumnDefinitions="260,*">
|
||||
<!-- Sidebar -->
|
||||
<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>
|
||||
@@ -1,11 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
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;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private bool _interactionsRegistered;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered)
|
||||
{
|
||||
_interactionsRegistered = true;
|
||||
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