diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md index c7c1f51..a45b40b 100644 --- a/.agent/DECISIONS.md +++ b/.agent/DECISIONS.md @@ -84,3 +84,27 @@ Record meaningful technical decisions here. Use one entry per decision. - Alternatives considered: Launch the system Storage Access Framework picker; request permissions through an application context. - Impacted areas: Android 10 and earlier storage permission checks, runtime permission requests, and raw external-storage access. +- Date: 2026-07-25 +- Decision: Give the dependency manager a page-scoped MD3 component token set and render its tabs with a compact custom state-layer template. +- Rationale: The default desktop tab indicator, unconstrained count badges, and filled hover treatment produced inconsistent geometry and excessive contrast. Page-scoped tokens keep responsive spacing, badge shape, state opacity, and the short pill indicator consistent across generated color themes. +- Alternatives considered: Modify the global Avalonia TabItem theme; continue using per-control literal values. +- Impacted areas: Dependency manager layout and styles; shared static theme tokens. + +- Date: 2026-07-27 +- Decision: Supersede the dependency-manager-specific tab header template with a global MD3 secondary TabItem style. +- Rationale: Secondary tabs are a shared component pattern. A global style gives every TabItem the same 48dp container, Title Small label, state layer, semantic colors, and full-width 2dp active indicator while allowing each view to supply arbitrary header content. +- Alternatives considered: Keep the dependency manager's page-scoped short pill indicator; duplicate the secondary-tab template in each view. +- Impacted areas: Global OpenUtau Mobile TabItem headers; dependency manager tab header markup and tokens. + +- Date: 2026-07-29 +- Decision: Implement dependency installation from local files entirely in the Mobile layer without modifying the upstream-derived Core package manager. +- Rationale: The existing Core API already supports file installation, while keeping this feature's UI state and error presentation outside Core avoids increasing the cost of future upstream synchronization. +- Alternatives considered: Add transactional extraction, package metadata results, or typed installation errors to `OpenUtau.Core`; duplicate package parsing in a Mobile adapter. +- Impacted areas: Dependency manager ViewModel, view state, and localization resources. Core installation behavior remains unchanged. + +- Date: 2026-07-29 +- Decision: Run non-cancellable background UI operations through a reusable LoadingPopup service with determinate and indeterminate progress modes. +- Rationale: Centralizing first-frame yielding, UI-thread progress updates, and guaranteed dialog closure prevents operation-specific loading overlays from racing with completion or subsequent error dialogs. +- Alternatives considered: Keep page-local busy indicators; open and close DialogHost directly in each ViewModel; add loading notifications to the upstream-derived Core. +- Impacted areas: Shared popup controls and services; dependency installation now uses the indeterminate loading mode. + diff --git a/OpenUtauMobile/App.axaml.cs b/OpenUtauMobile/App.axaml.cs index a51b27b..41fe07d 100644 --- a/OpenUtauMobile/App.axaml.cs +++ b/OpenUtauMobile/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Media; using Avalonia.Styling; using OpenUtau.Core.Util; using OpenUtauMobile.Helpers; @@ -31,7 +32,7 @@ public override void OnFrameworkInitializationCompleted() // Initialize runtime theme resources before creating UI. ThemeManagerV2.Initialize(); - var seed = ThemeSeedResolver.ResolveSeed( + Color seed = ThemeSeedResolver.ResolveSeed( ServiceHub.SystemAccentColorProvider, out _, out _); diff --git a/OpenUtauMobile/Assets/Lang/Strings.en.resx b/OpenUtauMobile/Assets/Lang/Strings.en.resx index 72e59c1..4cc4283 100644 --- a/OpenUtauMobile/Assets/Lang/Strings.en.resx +++ b/OpenUtauMobile/Assets/Lang/Strings.en.resx @@ -837,6 +837,12 @@ Dependency Management + + Please wait + + + Working in the background… + Refresh List @@ -882,8 +888,26 @@ Failed to load installed list: {0} - - Install from file not implemented yet + + Select dependency package + + + Installing the dependency package… + + + Installed dependency package "{0}" + + + The selected file is not a valid dependency package or is damaged. + + + Unable to read the package or write to dependency storage. Check storage permissions. + + + The selected package is no longer available. Choose the file again. + + + Unable to install the package. Check available storage space and whether the file is in use. Uninstalled {0} diff --git a/OpenUtauMobile/Assets/Lang/Strings.ja.resx b/OpenUtauMobile/Assets/Lang/Strings.ja.resx index 611000e..c6584d8 100644 --- a/OpenUtauMobile/Assets/Lang/Strings.ja.resx +++ b/OpenUtauMobile/Assets/Lang/Strings.ja.resx @@ -834,6 +834,12 @@ 依存関係管理 + + しばらくお待ちください + + + バックグラウンド処理を実行しています… + リストを更新 @@ -879,8 +885,26 @@ インストール済みリストの読み込みに失敗: {0} - - ファイルからのインストールは未実装です + + 依存関係パッケージを選択 + + + 依存関係パッケージをインストールしています… + + + 依存関係パッケージ「{0}」をインストールしました + + + 選択したファイルは有効な依存関係パッケージではないか、破損しています。 + + + パッケージの読み込みまたは依存関係ストレージへの書き込みができません。ストレージ権限を確認してください。 + + + 選択したパッケージは利用できなくなりました。ファイルを再度選択してください。 + + + パッケージをインストールできません。空き容量とファイルが使用中でないか確認してください。 {0} をアンインストールしました diff --git a/OpenUtauMobile/Assets/Lang/Strings.ru.resx b/OpenUtauMobile/Assets/Lang/Strings.ru.resx index 7ef7e65..d23c81a 100644 --- a/OpenUtauMobile/Assets/Lang/Strings.ru.resx +++ b/OpenUtauMobile/Assets/Lang/Strings.ru.resx @@ -819,6 +819,12 @@ Управление зависимостями + + Пожалуйста, подождите + + + Выполняется фоновая операция… + Обновить список @@ -864,8 +870,26 @@ Не удалось загрузить список установленных: {0} - - Установка из файла ещё не реализована + + Выберите пакет зависимостей + + + Установка пакета зависимостей… + + + Пакет зависимостей «{0}» установлен + + + Выбранный файл не является допустимым пакетом зависимостей или повреждён. + + + Не удалось прочитать пакет или записать данные зависимостей. Проверьте разрешения хранилища. + + + Выбранный пакет больше недоступен. Выберите файл ещё раз. + + + Не удалось установить пакет. Проверьте свободное место и не используется ли файл. Удалено: {0} diff --git a/OpenUtauMobile/Assets/Lang/Strings.uk.resx b/OpenUtauMobile/Assets/Lang/Strings.uk.resx index 61b3171..2a22562 100644 --- a/OpenUtauMobile/Assets/Lang/Strings.uk.resx +++ b/OpenUtauMobile/Assets/Lang/Strings.uk.resx @@ -819,6 +819,12 @@ Керування залежностями + + Будь ласка, зачекайте + + + Виконується фонова операція… + Оновити список @@ -864,8 +870,26 @@ Не вдалося завантажити список встановлених: {0} - - Встановлення з файла ще не реалізовано + + Виберіть пакет залежностей + + + Встановлення пакета залежностей… + + + Пакет залежностей «{0}» встановлено + + + Вибраний файл не є припустимим пакетом залежностей або пошкоджений. + + + Не вдалося прочитати пакет або записати дані залежностей. Перевірте дозволи сховища. + + + Вибраний пакет більше недоступний. Виберіть файл ще раз. + + + Не вдалося встановити пакет. Перевірте вільне місце та чи не використовується файл. Видалено {0} diff --git a/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx b/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx index e019fd2..5a4052f 100644 --- a/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx +++ b/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx @@ -837,6 +837,12 @@ 依赖项管理 + + 请稍候 + + + 正在进行后台操作… + 刷新列表 @@ -882,8 +888,26 @@ 加载已安装列表失败: {0} - - 从文件安装功能待实现 + + 选择依赖包 + + + 正在安装依赖包… + + + 已安装依赖包“{0}” + + + 所选文件不是有效的依赖包,或文件已损坏。 + + + 无法读取依赖包或写入依赖项存储,请检查存储权限。 + + + 所选依赖包已不可用,请重新选择文件。 + + + 无法安装依赖包,请检查可用存储空间以及文件是否被占用。 已卸载 {0} diff --git a/OpenUtauMobile/Controls/LoadingPopup.axaml b/OpenUtauMobile/Controls/LoadingPopup.axaml new file mode 100644 index 0000000..e5dbd11 --- /dev/null +++ b/OpenUtauMobile/Controls/LoadingPopup.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/OpenUtauMobile/Controls/LoadingPopup.axaml.cs b/OpenUtauMobile/Controls/LoadingPopup.axaml.cs new file mode 100644 index 0000000..be4f9e4 --- /dev/null +++ b/OpenUtauMobile/Controls/LoadingPopup.axaml.cs @@ -0,0 +1,11 @@ +namespace OpenUtauMobile.Controls; + +public partial class LoadingPopup : PopupDialogControl +{ + protected override PopupDialogWidthPreset WidthPreset => PopupDialogWidthPreset.Compact; + + public LoadingPopup() + { + InitializeComponent(); + } +} diff --git a/OpenUtauMobile/Services/LoadingPopupService.cs b/OpenUtauMobile/Services/LoadingPopupService.cs new file mode 100644 index 0000000..3047625 --- /dev/null +++ b/OpenUtauMobile/Services/LoadingPopupService.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Threading; +using OpenUtauMobile.Controls; +using OpenUtauMobile.ViewModels; + +namespace OpenUtauMobile.Services; + +/// +/// 在通用加载弹窗中运行后台操作 +/// +/// +/// 适用于安装、导入、导出等需要阻止用户继续操作,且通常无法立即完成的后台任务。 +/// 短暂操作或不阻塞交互的状态提示应优先使用 Toast 或页面内加载状态。 +/// +/// 无法获取进度时调用 ; +/// 可获取 0–100 进度时调用带初始进度的重载,并通过 +/// 持续更新进度和说明。 +/// +/// Service 会先显示弹窗,再运行任务,并在任务成功或抛出异常时关闭弹窗。 +/// 异常会在弹窗完全关闭后继续向调用方传播,因此成功提示和错误弹窗应由调用方在 +/// await RunAsync(...) 之后处理。调用方不应自行关闭 LoadingPopup。 +/// +/// +/// +/// await LoadingPopupService.RunAsync(message, _ => InstallAsync()); +/// +/// await LoadingPopupService.RunAsync(message, 0d, async loading => +/// { +/// loading.UpdateProgress(50d, nextMessage); +/// await ExportAsync(); +/// }); +/// +/// +public static class LoadingPopupService +{ + /// 使用无确定进度的加载弹窗运行后台操作 + public static async Task RunAsync( + string message, + Func operation) + { + LoadingPopupViewModel viewModel = new(message); + await RunAsync(viewModel, operation); + } + + /// 使用确定进度的加载弹窗运行后台操作 + public static async Task RunAsync( + string message, + double initialProgress, + Func operation) + { + LoadingPopupViewModel viewModel = new(message, initialProgress); + await RunAsync(viewModel, operation); + } + + private static async Task RunAsync( + LoadingPopupViewModel viewModel, + Func operation) + { + ArgumentNullException.ThrowIfNull(operation); + + Task popupTask = PopupService.Show( + new LoadingPopup(), + viewModel); + + // 先让弹窗完成布局和首帧渲染,再启动可能包含同步阶段的后台操作。 + await Dispatcher.UIThread.InvokeAsync( + () => { }, + DispatcherPriority.Background); + + try + { + await operation(viewModel); + } + finally + { + viewModel.Close(); + await popupTask; + } + } +} diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/OpenUtauMobileTheme.axaml b/OpenUtauMobile/Themes/OpenUtauMobile/OpenUtauMobileTheme.axaml index d4764b2..571a715 100644 --- a/OpenUtauMobile/Themes/OpenUtauMobile/OpenUtauMobileTheme.axaml +++ b/OpenUtauMobile/Themes/OpenUtauMobile/OpenUtauMobileTheme.axaml @@ -15,8 +15,9 @@ - + + @@ -41,4 +42,4 @@ - \ No newline at end of file + diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/Runtime/ThemeStaticTokens.cs b/OpenUtauMobile/Themes/OpenUtauMobile/Runtime/ThemeStaticTokens.cs index 597283d..262b1db 100644 --- a/OpenUtauMobile/Themes/OpenUtauMobile/Runtime/ThemeStaticTokens.cs +++ b/OpenUtauMobile/Themes/OpenUtauMobile/Runtime/ThemeStaticTokens.cs @@ -1,5 +1,6 @@ using System; using Avalonia; +using Avalonia.Media; namespace OpenUtauMobile.Themes.OpenUtauMobile.Runtime; @@ -8,6 +9,7 @@ namespace OpenUtauMobile.Themes.OpenUtauMobile.Runtime; /// public static class ThemeBaseLayoutTokens { + public static double SpaceXXS => 2d; public static double SpaceXS => 4d; public static double SpaceS => 8d; public static double SpaceM => 16d; @@ -203,6 +205,40 @@ public static class ThemeSemComponentTokens public static double SliderMinHeight => 48d; } +public static class ThemeSemTabItemTokens +{ + public static double ContainerHeight => 48d; + public static double ActiveIndicatorHeight => 2d; + public static double LabelSize => ThemeBaseTypographyTokens.LabelLSize; + + public static Thickness HeaderInset => new(24, 0); + + public static CornerRadius ActiveIndicatorCorner => ThemeBaseShapeTokens.CornerNone; + + public static FontWeight LabelWeight => FontWeight.Medium; +} + +public static class ThemeSemDependencyManagerTokens +{ + public static double ContentMaxWidth => 1248d; + public static double BadgeMinWidth => 16d; + public static double BadgeHeight => 16d; + public static double SearchFieldHeight => 48d; + public static double IconButtonSize => 48d; + + public static Thickness PageInset => new(24, 16, 24, 24); + public static Thickness BadgeInset => new(4, 0); + public static Thickness SearchFieldInset => new(40, 0, 16, 0); + public static Thickness SearchFieldEndSpacing => new(0, 0, 12, 0); + public static Thickness ToolbarBottomSpacing => new(0, 0, 0, 16); + public static Thickness CardInset => new(16); + public static Thickness CardBottomSpacing => new(0, 0, 0, 12); + + public static CornerRadius SearchFieldCorner => ThemeBaseShapeTokens.CornerXXXXL; + public static CornerRadius IconButtonCorner => ThemeBaseShapeTokens.CornerXXXXL; + public static CornerRadius BadgeCorner => ThemeBaseShapeTokens.CornerM; +} + public static class ThemeSemOpacityTokens { // ── Semantic Opacity Levels ── @@ -221,4 +257,4 @@ public static class ThemeSemOpacityTokens // ── Overlay Opacity ── public static double Scrim => ThemeBaseOpacityTokens.OverlayScrim; public static double LightScrim => ThemeBaseOpacityTokens.OverlayLightScrim; -} \ No newline at end of file +} diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TabItem.axaml b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TabItem.axaml new file mode 100644 index 0000000..3e5b926 --- /dev/null +++ b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TabItem.axaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextBox.axaml b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextBox.axaml new file mode 100644 index 0000000..0a13c0c --- /dev/null +++ b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextBox.axaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextInput.axaml b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextInput.axaml deleted file mode 100644 index 98b716a..0000000 --- a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/TextInput.axaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/Views/DependencyManagerView.axaml b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/Views/DependencyManagerView.axaml index ddfbeb0..1514bed 100644 --- a/OpenUtauMobile/Themes/OpenUtauMobile/Styles/Views/DependencyManagerView.axaml +++ b/OpenUtauMobile/Themes/OpenUtauMobile/Styles/Views/DependencyManagerView.axaml @@ -3,15 +3,64 @@ xmlns:theme="clr-namespace:OpenUtauMobile.Themes.OpenUtauMobile.Runtime"> - + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/OpenUtauMobile/ViewModels/DependencyManagerViewModel.cs b/OpenUtauMobile/ViewModels/DependencyManagerViewModel.cs index dd7d270..770b072 100644 --- a/OpenUtauMobile/ViewModels/DependencyManagerViewModel.cs +++ b/OpenUtauMobile/ViewModels/DependencyManagerViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Linq; @@ -7,6 +9,7 @@ using OpenUtau.Core; using OpenUtauMobile.Helpers; using OpenUtauMobile.Services; +using OpenUtauMobile.Storage; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -50,6 +53,10 @@ public class DependencyManagerViewModel : NavigateViewModelBase [Reactive] public int SortMode { get; set; } = 0; + /// 是否正在从本地文件安装依赖包 + [Reactive] + public bool IsInstallingFromFile { get; set; } + // ═══════════════════════════════════════════════════ // Commands // ═══════════════════════════════════════════════════ @@ -68,7 +75,12 @@ public DependencyManagerViewModel(MainViewModel navigator) : base(navigator) BackCommand = ReactiveCommand.Create(OnBack); RefreshRegistryCommand = ReactiveCommand.CreateFromTask(LoadAvailablePackagesAsync); RefreshInstalledCommand = ReactiveCommand.CreateFromTask(LoadInstalledPackagesAsync); - InstallFromFileCommand = ReactiveCommand.CreateFromTask(OnInstallFromFileAsync); + IObservable canInstallFromFile = this + .WhenAnyValue(x => x.IsInstallingFromFile) + .Select(isInstalling => !isInstalling); + InstallFromFileCommand = ReactiveCommand.CreateFromTask( + OnInstallFromFileAsync, + canInstallFromFile); // 自动加载数据 Task.Run(async () => @@ -99,11 +111,11 @@ private async Task LoadAvailablePackagesAsync() IsLoadingRegistry = true; try { - var packages = await PackageManager.Inst.FetchRegistryAsync(); + List packages = await PackageManager.Inst.FetchRegistryAsync(); // 清空并重新填充 AvailablePackages.Clear(); - foreach (var pkg in packages) + foreach (RegistrySoftware pkg in packages) { AvailablePackages.Add(new DependencyItemViewModel(pkg, this)); } @@ -129,11 +141,11 @@ public async Task LoadInstalledPackagesAsync() IsLoadingInstalled = true; try { - var installed = await PackageManager.Inst.GetInstalledAsync(); + List installed = await PackageManager.Inst.GetInstalledAsync(); // 清空并重新填充 InstalledPackages.Clear(); - foreach (var meta in installed) + foreach (OudepMetadata meta in installed) { InstalledPackages.Add(new InstalledDependencyViewModel(meta, this)); } @@ -154,21 +166,78 @@ public async Task LoadInstalledPackagesAsync() /// 更新可用包的已安装状态 private void UpdateInstalledStatus() { - foreach (var pkg in AvailablePackages) + foreach (DependencyItemViewModel pkg in AvailablePackages) { pkg.UpdateInstalledStatus(InstalledPackages); } } /// 从本地文件安装依赖包 - private Task OnInstallFromFileAsync() + private async Task OnInstallFromFileAsync() + { + string[] filters = [$"*{PackageManager.OudepExt}"]; + string archivePath; + try + { + archivePath = await FilePicker.PickSingleFileAsync( + L.S("DependencyManager.FilePicker.SelectPackage"), + filters); + } + catch (Exception ex) + { + ShowInstallFromFileError( + L.S("DependencyManager.Error.FileUnavailable"), + ex); + return; + } + + if (string.IsNullOrEmpty(archivePath)) + { + return; + } + + IsInstallingFromFile = true; + try + { + await LoadingPopupService.RunAsync( + L.S("DependencyManager.Loading.InstallFromFile"), + _ => PackageManager.Inst.InstallFromFileAsync(archivePath)); + string fileName = Path.GetFileName(archivePath); + ToastService.Enqueue(string.Format( + L.S("DependencyManager.Toast.InstallFromFileSuccess"), + fileName)); + SelectedTabIndex = 1; + } + catch (Exception ex) + { + string message = GetInstallFromFileErrorMessage(ex); + ShowInstallFromFileError(message, ex); + } + finally + { + IsInstallingFromFile = false; + await LoadInstalledPackagesAsync(); + } + } + + /// 将安装异常转换为面向用户的错误信息 + private static string GetInstallFromFileErrorMessage(Exception exception) { - // TODO: 实现文件选择器 - // 1. 打开文件选择器,选择 .oudep 文件 - // 2. 调用 PackageManager.Inst.InstallFromFileAsync(filePath) - // 3. 安装完成后刷新已安装列表 - ToastService.Enqueue(L.S("DependencyManager.Toast.InstallFromFileNotImpl")); - return Task.CompletedTask; + return exception switch + { + UnauthorizedAccessException => L.S("DependencyManager.Error.AccessDenied"), + FileNotFoundException or DirectoryNotFoundException => + L.S("DependencyManager.Error.FileUnavailable"), + IOException => L.S("DependencyManager.Error.Storage"), + _ => L.S("DependencyManager.Error.InvalidPackage") + }; + } + + /// 显示从文件安装失败弹窗 + private static void ShowInstallFromFileError(string message, Exception exception) + { + ErrorMessageNotification notification = new(message, exception); + ErrorDialogService.Show(new ErrorDialogViewModel(notification)); } /// 卸载指定的依赖包 @@ -258,15 +327,15 @@ public DependencyItemViewModel(RegistrySoftware software, DependencyManagerViewM /// 更新已安装状态 public void UpdateInstalledStatus(ObservableCollection installedPackages) { - var installed = installedPackages.FirstOrDefault(p => p.Id == Id); + InstalledDependencyViewModel? installed = installedPackages.FirstOrDefault(p => p.Id == Id); IsInstalled = installed != null; InstalledVersion = installed?.Version ?? string.Empty; // 检查是否有更新:已安装且远程版本号更高 if (IsInstalled && !string.IsNullOrEmpty(InstalledVersion) && !string.IsNullOrEmpty(LatestVersion)) { - if (Version.TryParse(InstalledVersion, out var installedVer) && - Version.TryParse(LatestVersion, out var latestVer)) + if (Version.TryParse(InstalledVersion, out Version? installedVer) && + Version.TryParse(LatestVersion, out Version? latestVer)) { HasUpdate = latestVer > installedVer; } @@ -289,7 +358,7 @@ private async Task OnInstallAsync() try { - var progress = new Progress(percent => { InstallProgress = percent; }); + Progress progress = new(percent => { InstallProgress = percent; }); await PackageManager.Inst.InstallAsync(_software, progress); ToastService.Enqueue(string.Format(L.S("DependencyManager.Toast.InstallSuccess"), Name)); @@ -350,4 +419,4 @@ private async Task OnUninstallAsync() IsUninstalling = false; } } -} \ No newline at end of file +} diff --git a/OpenUtauMobile/ViewModels/LoadingPopupViewModel.cs b/OpenUtauMobile/ViewModels/LoadingPopupViewModel.cs new file mode 100644 index 0000000..a8dcad3 --- /dev/null +++ b/OpenUtauMobile/ViewModels/LoadingPopupViewModel.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading; +using Avalonia.Threading; +using OpenUtauMobile.Helpers; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; + +namespace OpenUtauMobile.ViewModels; + +/// +/// 通用后台操作加载弹窗 ViewModel +/// +/// +/// 由 创建并管理生命周期。 +/// 后台任务可使用本类型更新进度或提示文本,不应直接持有或显示对应 View。 +/// +public class LoadingPopupViewModel : PopupViewModelBase, IProgress +{ + private const double MinimumProgress = 0d; + private const double MaximumProgress = 100d; + private int _isClosed; + + /// 当前后台操作说明 + [Reactive] + public string Message { get; private set; } + + /// 当前进度,范围为 0–100 + [Reactive] + public double Progress { get; private set; } + + /// 是否使用无确定进度的加载状态 + [Reactive] + public bool IsIndeterminate { get; private set; } + + /// 用于界面显示的进度文本 + public string ProgressText => $"{Progress:0}%"; + + /// 后台操作说明 + public LoadingPopupViewModel(string message) + { + Message = NormalizeMessage(message); + IsIndeterminate = true; + } + + /// 后台操作说明 + /// 初始进度,超出 0–100 时自动截断 + public LoadingPopupViewModel(string message, double progress) + { + Message = NormalizeMessage(message); + Progress = NormalizeProgress(progress); + IsIndeterminate = false; + } + + /// 报告确定进度 + public void Report(double value) + { + UpdateProgress(value); + } + + /// 更新确定进度和可选操作说明 + public void UpdateProgress(double progress, string? message = null) + { + RunOnUiThread(() => + { + Progress = NormalizeProgress(progress); + IsIndeterminate = false; + if (message != null) + { + Message = NormalizeMessage(message); + } + + this.RaisePropertyChanged(nameof(ProgressText)); + }); + } + + /// 切换为无确定进度的加载状态 + public void SetIndeterminate(string? message = null) + { + RunOnUiThread(() => + { + IsIndeterminate = true; + if (message != null) + { + Message = NormalizeMessage(message); + } + }); + } + + /// 更新后台操作说明 + public void UpdateMessage(string message) + { + RunOnUiThread(() => Message = NormalizeMessage(message)); + } + + /// 由后台操作完成方关闭弹窗 + public void Close() + { + if (Interlocked.Exchange(ref _isClosed, 1) != 0) + { + return; + } + + RunOnUiThread(() => RaiseClose(null), allowAfterClose: true); + } + + /// 后台操作期间忽略返回请求 + public override void RequestBack() + { + } + + private void RunOnUiThread(Action action, bool allowAfterClose = false) + { + if (!allowAfterClose && Volatile.Read(ref _isClosed) != 0) + { + return; + } + + if (Dispatcher.UIThread.CheckAccess()) + { + action(); + return; + } + + Dispatcher.UIThread.Post(() => + { + if (allowAfterClose || Volatile.Read(ref _isClosed) == 0) + { + action(); + } + }); + } + + private static string NormalizeMessage(string message) + { + return string.IsNullOrWhiteSpace(message) + ? L.S("LoadingPopup.DefaultMessage") + : message; + } + + private static double NormalizeProgress(double progress) + { + return double.IsFinite(progress) + ? Math.Clamp(progress, MinimumProgress, MaximumProgress) + : MinimumProgress; + } +} diff --git a/OpenUtauMobile/Views/DependencyManagerView.axaml b/OpenUtauMobile/Views/DependencyManagerView.axaml index 4238e66..52af51d 100644 --- a/OpenUtauMobile/Views/DependencyManagerView.axaml +++ b/OpenUtauMobile/Views/DependencyManagerView.axaml @@ -15,24 +15,20 @@ - - - + - - + - - + - - + - - - + Classes="DependencyTabs"> + + - - - - + + + + - - - - - - + + + + + - + - + - + - + @@ -142,19 +138,15 @@ - - + + - - - - + - - + - + @@ -189,18 +180,18 @@ FontWeight="Medium" Foreground="{DynamicResource Sem.Color.OnSurfaceVariant}" /> - - + Margin="0,0,0,10" > + + + - + @@ -223,11 +214,9 @@ - - + - - + - - + - - + - - + @@ -310,41 +296,30 @@ CornerRadius="10" ShowProgressText="True" /> - - - - - + - - - - + + + + - - + HorizontalScrollBarVisibility="Disabled"> + - + - - - - + - + @@ -416,7 +387,7 @@ Foreground="{DynamicResource Sem.Color.Outline}" /> - +