feat: Add rich AUR details, component installer & initial setup wizard

This commit is contained in:
RarDog
2026-09-13 18:55:03 +03:00
parent cd6dd41552
commit 524d99c7ca
9 changed files with 1359 additions and 40 deletions
@@ -15,12 +15,14 @@ class AppSettingsService extends ChangeNotifier {
String _preferredBackend = 'pacman';
String _preferredMirrorCountry = '';
String _windowTheme = 'auto';
bool _hasCompletedSetupWizard = false;
bool get createSnapshotBeforeUpdate => _createSnapshotBeforeUpdate;
bool get usePolkit => _usePolkit;
String get preferredBackend => _preferredBackend;
String get preferredMirrorCountry => _preferredMirrorCountry;
String get windowTheme => _windowTheme;
bool get hasCompletedSetupWizard => _hasCompletedSetupWizard;
static String detectDesktopEnvironmentName() {
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
@@ -82,6 +84,7 @@ class AppSettingsService extends ChangeNotifier {
_preferredBackend = data['preferredBackend'] as String? ?? 'pacman';
_preferredMirrorCountry = data['preferredMirrorCountry'] as String? ?? '';
_windowTheme = data['windowTheme'] as String? ?? 'auto';
_hasCompletedSetupWizard = data['hasCompletedSetupWizard'] as bool? ?? false;
}
} catch (_) {}
}
@@ -95,11 +98,19 @@ class AppSettingsService extends ChangeNotifier {
'preferredBackend': _preferredBackend,
'preferredMirrorCountry': _preferredMirrorCountry,
'windowTheme': _windowTheme,
'hasCompletedSetupWizard': _hasCompletedSetupWizard,
};
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data));
} catch (_) {}
}
Future<void> setHasCompletedSetupWizard(bool value) async {
if (_hasCompletedSetupWizard == value) return;
_hasCompletedSetupWizard = value;
notifyListeners();
await _saveSettings();
}
Future<void> setWindowTheme(String value) async {
if (_windowTheme == value) return;
_windowTheme = value;
+128
View File
@@ -41,6 +41,93 @@ class AurPackage {
}
}
class AurPackageDetails {
final String name;
final String version;
final String description;
final String? url;
final String packageBase;
final String? maintainer;
final int numVotes;
final double popularity;
final DateTime? firstSubmitted;
final DateTime? lastModified;
final DateTime? outOfDate;
final List<String> licenses;
final List<String> depends;
final List<String> makeDepends;
final List<String> optDepends;
final List<String> checkDepends;
final List<String> conflicts;
final List<String> provides;
final List<String> replaces;
final List<String> keywords;
const AurPackageDetails({
required this.name,
required this.version,
required this.description,
this.url,
required this.packageBase,
this.maintainer,
required this.numVotes,
required this.popularity,
this.firstSubmitted,
this.lastModified,
this.outOfDate,
required this.licenses,
required this.depends,
required this.makeDepends,
required this.optDepends,
required this.checkDepends,
required this.conflicts,
required this.provides,
required this.replaces,
required this.keywords,
});
bool get isOutOfDate => outOfDate != null;
factory AurPackageDetails.fromJson(Map<String, dynamic> json) {
List<String> parseList(dynamic val) {
if (val is List) {
return val.map((e) => e.toString()).toList();
}
return [];
}
DateTime? parseTimestamp(dynamic val) {
if (val is num && val > 0) {
return DateTime.fromMillisecondsSinceEpoch(val.toInt() * 1000);
}
return null;
}
return AurPackageDetails(
name: json['Name'] as String? ?? '',
version: json['Version'] as String? ?? '1.0.0',
description: json['Description'] as String? ?? '',
url: json['URL'] as String?,
packageBase: json['PackageBase'] as String? ?? (json['Name'] as String? ?? ''),
maintainer: json['Maintainer'] as String?,
numVotes: (json['NumVotes'] as num?)?.toInt() ?? 0,
popularity: (json['Popularity'] as num?)?.toDouble() ?? 0.0,
firstSubmitted: parseTimestamp(json['FirstSubmitted']),
lastModified: parseTimestamp(json['LastModified']),
outOfDate: parseTimestamp(json['OutOfDate']),
licenses: parseList(json['License']),
depends: parseList(json['Depends']),
makeDepends: parseList(json['MakeDepends']),
optDepends: parseList(json['OptDepends']),
checkDepends: parseList(json['CheckDepends']),
conflicts: parseList(json['Conflicts']),
provides: parseList(json['Provides']),
replaces: parseList(json['Replaces']),
keywords: parseList(json['Keywords']),
);
}
}
class AurService {
static String? _cachedHelper;
@@ -115,6 +202,47 @@ class AurService {
return [];
}
/// Получение детальной информации о пакете AUR через RPC v5 info endpoint
static Future<AurPackageDetails?> getPackageInfo(String packageName) async {
final clean = packageName.trim();
if (clean.isEmpty) return null;
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final uri = Uri.parse('https://aur.archlinux.org/rpc/v5/info/${Uri.encodeComponent(clean)}');
final req = await client.getUrl(uri);
final res = await req.close();
if (res.statusCode == 200) {
final body = await res.transform(utf8.decoder).join();
final Map<String, dynamic> data = jsonDecode(body);
final results = data['results'] as List<dynamic>? ?? [];
if (results.isNotEmpty) {
return AurPackageDetails.fromJson(results.first as Map<String, dynamic>);
}
}
} catch (_) {
try {
final curlRes = await Process.run('curl', [
'-s',
'--max-time',
'5',
'https://aur.archlinux.org/rpc/v5/info/${Uri.encodeComponent(clean)}',
]);
if (curlRes.exitCode == 0) {
final Map<String, dynamic> data = jsonDecode(curlRes.stdout.toString());
final results = data['results'] as List<dynamic>? ?? [];
if (results.isNotEmpty) {
return AurPackageDetails.fromJson(results.first as Map<String, dynamic>);
}
}
} catch (_) {}
}
return null;
}
/// Сборка и установка пакета AUR через найденный хелпер (paru / yay)
static Stream<ProgressEvent> installPackage(String packageName) async* {
final helper = await getHelper();
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'admin_service.dart';
class SystemComponentStatus {
final bool hasFlatpak;
final bool hasFlathub;
final bool hasParu;
final bool hasYay;
const SystemComponentStatus({
required this.hasFlatpak,
required this.hasFlathub,
required this.hasParu,
required this.hasYay,
});
bool get hasAurHelper => hasParu || hasYay;
}
class ComponentInstallerService {
/// Проверка статуса установленных компонентов системы
static Future<SystemComponentStatus> checkComponents() async {
bool hasFlatpak = false;
bool hasFlathub = false;
bool hasParu = false;
bool hasYay = false;
try {
final res = await Process.run('which', ['flatpak']);
hasFlatpak = res.exitCode == 0 && res.stdout.toString().trim().isNotEmpty;
} catch (_) {}
if (hasFlatpak) {
try {
final res = await Process.run('flatpak', ['remotes']);
hasFlathub = res.stdout.toString().toLowerCase().contains('flathub');
} catch (_) {}
}
try {
final res = await Process.run('which', ['paru']);
hasParu = res.exitCode == 0 && res.stdout.toString().trim().isNotEmpty;
} catch (_) {}
try {
final res = await Process.run('which', ['yay']);
hasYay = res.exitCode == 0 && res.stdout.toString().trim().isNotEmpty;
} catch (_) {}
return SystemComponentStatus(
hasFlatpak: hasFlatpak,
hasFlathub: hasFlathub,
hasParu: hasParu,
hasYay: hasYay,
);
}
/// Установка Flatpak и подключение репозитория Flathub
static Stream<String> installFlatpak(String? password) async* {
yield '🚀 [Flatpak] Проверка и установка пакета flatpak через pacman...';
// 1. Установка flatpak
final proc = await AdminService.runPrivileged(
password,
'pacman',
['-S', '--needed', '--noconfirm', 'flatpak'],
);
final controller = StreamController<String>();
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add(line),
onError: (e) => controller.add('ERR: $e'),
);
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add('STDERR: $line'),
onError: (e) => controller.add('ERR: $e'),
);
proc.exitCode.then((_) {
Future.delayed(const Duration(milliseconds: 60), () {
if (!controller.isClosed) controller.close();
});
});
await for (final line in controller.stream) {
yield line;
}
final code = await proc.exitCode;
if (code != 0) {
yield '❌ [Flatpak] Ошибка установки пакета flatpak (код $code).';
return;
}
yield '✅ [Flatpak] Пакет flatpak успешно установлен в систему.';
yield '🌐 [Flathub] Подключение официального репозитория Flathub...';
// 2. Добавление репозитория Flathub
try {
final remoteProc = await Process.run('flatpak', [
'remote-add',
'--if-not-exists',
'flathub',
'https://dl.flathub.org/repo/flathub.flatpakrepo',
]);
if (remoteProc.exitCode == 0) {
yield '✅ [Flathub] Репозиторий Flathub успешно подключен!';
} else {
// Попытка с правами суперпользователя
final rootRemote = await AdminService.runPrivileged(
password,
'flatpak',
['remote-add', '--if-not-exists', 'flathub', 'https://dl.flathub.org/repo/flathub.flatpakrepo'],
);
await rootRemote.exitCode;
yield '✅ [Flathub] Репозиторий Flathub подключен системно!';
}
} catch (e) {
yield '⚠️ [Flathub] Предупреждение при подключении репозитория: $e';
}
yield '🎉 [Успех] Интеграция Flatpak & Flathub полностью готова!';
}
/// Установка AUR-хелпера Paru (Rust)
static Stream<String> installParu(String? password) async* {
yield '🚀 [Paru] Начинаем установку Paru (AUR Helper на Rust)...';
// Если уже есть yay, ставим paru-bin через него
final status = await checkComponents();
if (status.hasYay) {
yield '📦 [Paru] Обнаружен yay! Устанавливаем paru-bin через yay...';
final proc = await Process.start(
'yay',
['-S', '--noconfirm', 'paru-bin'],
environment: {'LC_ALL': 'C'},
);
final controller = StreamController<String>();
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add(line),
onError: (e) => controller.add('ERR: $e'),
);
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add('STDERR: $line'),
onError: (e) => controller.add('ERR: $e'),
);
proc.exitCode.then((_) {
Future.delayed(const Duration(milliseconds: 60), () {
if (!controller.isClosed) controller.close();
});
});
await for (final line in controller.stream) {
yield line;
}
final exitCode = await proc.exitCode;
if (exitCode == 0) {
yield '🎉 [Paru] Paru успешно установлен и готов к работе!';
return;
} else {
yield '⚠️ [Paru] Не удалось установить через yay (код $exitCode), переключаемся на ручную сборку...';
}
}
// Ручная установка paru-bin через makepkg
yield '🛠️ [Paru] Проверка базовых инструментов сборки (base-devel, git)...';
final pacmanProc = await AdminService.runPrivileged(
password,
'pacman',
['-S', '--needed', '--noconfirm', 'base-devel', 'git'],
);
await pacmanProc.exitCode;
// Кэшируем sudo для пользователя чтобы makepkg -si не споткнулся
if (password != null && password.isNotEmpty) {
await AdminService.verifyPassword(password);
}
Directory? tempDir;
try {
tempDir = Directory.systemTemp.createTempSync('forge_paru_install_');
yield '📥 [Paru] Клонирование paru-bin из AUR...';
final cloneRes = await Process.run('git', [
'clone',
'https://aur.archlinux.org/paru-bin.git',
'${tempDir.path}/paru-bin',
]);
if (cloneRes.exitCode != 0) {
yield '❌ [Paru] Ошибка клонирования репозитория: ${cloneRes.stderr}';
return;
}
yield '⚙️ [Paru] Сборка и установка пакета (makepkg -si)...';
final makepkgProc = await Process.start(
'makepkg',
['-si', '--noconfirm'],
workingDirectory: '${tempDir.path}/paru-bin',
environment: {'LC_ALL': 'C'},
);
final controller = StreamController<String>();
makepkgProc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add(line),
onError: (e) => controller.add('ERR: $e'),
);
makepkgProc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add('STDERR: $line'),
onError: (e) => controller.add('ERR: $e'),
);
makepkgProc.exitCode.then((_) {
Future.delayed(const Duration(milliseconds: 60), () {
if (!controller.isClosed) controller.close();
});
});
await for (final line in controller.stream) {
yield line;
}
final exitCode = await makepkgProc.exitCode;
if (exitCode == 0) {
yield '🎉 [Paru] Paru успешно собран и установлен в систему!';
} else {
yield '❌ [Paru] makepkg завершился с кодом ошибки $exitCode.';
}
} catch (e) {
yield '❌ [Paru] Ошибка во время установки: $e';
} finally {
if (tempDir != null && tempDir.existsSync()) {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
}
}
}
+7
View File
@@ -16,6 +16,7 @@ import 'screens/starter_packs_screen.dart';
import 'screens/updates_screen.dart';
import 'widgets/keyboard_shortcuts_dialog.dart';
import 'widgets/package_inspector.dart';
import 'widgets/setup_wizard_dialog.dart';
import 'widgets/spotlight_search_dialog.dart';
class MainLayout extends StatefulWidget {
@@ -32,6 +33,12 @@ class _MainLayoutState extends State<MainLayout> {
void initState() {
super.initState();
_performInitialBadgeScan();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!AppSettingsService.instance.hasCompletedSetupWizard && mounted) {
SetupWizardDialog.show(context);
}
});
}
Future<void> _performInitialBadgeScan() async {
+195 -1
View File
@@ -7,6 +7,8 @@ import '../../services/snapshot_service.dart';
import '../widgets/friendly_term.dart';
import '../widgets/keyboard_shortcuts_dialog.dart';
import '../widgets/reflector_mirror_dialog.dart';
import '../widgets/setup_wizard_dialog.dart';
import '../../services/component_installer_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -23,6 +25,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
SnapshotEngine _snapshotEngine = SnapshotEngine.none;
bool _isCreatingSnapshot = false;
String? _snapshotMessage;
SystemComponentStatus? _componentStatus;
@override
void initState() {
@@ -34,6 +37,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final settings = AppSettingsService.instance;
await settings.load();
final engine = await SnapshotService.detectEngine();
final compStatus = await ComponentInstallerService.checkComponents();
if (mounted) {
setState(() {
_usePolkit = settings.usePolkit;
@@ -41,6 +45,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_createSnapshotBeforeUpdate = settings.createSnapshotBeforeUpdate;
_windowTheme = settings.windowTheme;
_snapshotEngine = engine;
_componentStatus = compStatus;
});
}
}
@@ -317,7 +322,196 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
// Group 3: Recovery Snapshots
// Group 3: System Components & Integrations
_buildSectionHeader('СИСТЕМНЫЕ КОМПОНЕНТЫ И ХЕЛПЕРЫ (FLATPAK & PARU)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Column(
children: [
// Flatpak status & button
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFF34C759).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: MacosIcon(CupertinoIcons.app_badge_fill, color: Color(0xFF34C759), size: 18),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Поддержка Flatpak & Flathub',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13.5),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (_componentStatus?.hasFlatpak ?? false)
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
(_componentStatus?.hasFlatpak ?? false) ? 'Установлен' : 'Не установлен',
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: (_componentStatus?.hasFlatpak ?? false)
? CupertinoColors.systemGreen
: CupertinoColors.systemGrey,
),
),
),
],
),
const SizedBox(height: 2),
const Text(
'Установка приложений из изолированного каталога Flathub',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () async {
await SetupWizardDialog.show(context);
_loadSettings();
},
child: Text((_componentStatus?.hasFlatpak ?? false) ? 'Управление' : 'Установить'),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1, color: CupertinoColors.separator),
const SizedBox(height: 14),
// Paru status & button
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFFFF9500).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: MacosIcon(CupertinoIcons.flame_fill, color: Color(0xFFFF9500), size: 18),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'AUR-хелпер Paru (Rust)',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13.5),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (_componentStatus?.hasParu ?? false)
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
(_componentStatus?.hasParu ?? false)
? 'Paru активен'
: ((_componentStatus?.hasYay ?? false) ? 'Обнаружен yay' : 'Отсутствует'),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: (_componentStatus?.hasParu ?? false)
? CupertinoColors.systemGreen
: CupertinoColors.systemGrey,
),
),
),
],
),
const SizedBox(height: 2),
const Text(
'Сверхбыстрый сборщик пакетов из Arch User Repository',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () async {
await SetupWizardDialog.show(context);
_loadSettings();
},
child: Text((_componentStatus?.hasParu ?? false) ? 'Переустановить' : 'Установить Paru'),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1, color: CupertinoColors.separator),
const SizedBox(height: 12),
// Wizard button
Row(
children: [
const Expanded(
child: Text(
'Мастер первичной настройки',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 12.5),
),
),
PushButton(
controlSize: ControlSize.small,
onPressed: () async {
await SetupWizardDialog.show(context);
_loadSettings();
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.sparkles, size: 12),
SizedBox(width: 5),
Text('Запустить мастер настройки'),
],
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Group 4: Recovery Snapshots
_buildSectionHeader('ТОЧКИ ВОССТАНОВЛЕНИЯ СИСТЕМЫ (SNAPSHOTS)'),
const SizedBox(height: 8),
Container(
+217 -38
View File
@@ -8,6 +8,7 @@ import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
import '../../services/admin_service.dart';
import '../../services/aur_pkgbuild_service.dart';
import '../../services/aur_service.dart';
import '../../services/flatpak_permissions_service.dart';
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
@@ -58,6 +59,7 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
String? _pkgbuildContent;
PkgbuildAuditResult? _pkgbuildAudit;
bool _isLoadingPkgbuild = false;
AurPackageDetails? _aurDetails;
// Flatpak Permissions
FlatpakAppPermissions? _flatpakPermissions;
@@ -130,13 +132,42 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
setState(() {
_isLoading = true;
_fileSearchFilter = '';
_aurDetails = null;
});
try {
final data = await RustBridgeApi.getPackageDetails(
_currentBackend,
_currentPackageName,
);
PackageDetails? data;
try {
data = await RustBridgeApi.getPackageDetails(
_currentBackend,
_currentPackageName,
);
} catch (_) {}
AurPackageDetails? aurData;
if (_currentBackend == BackendType.aur) {
try {
aurData = await AurService.getPackageInfo(_currentPackageName);
} catch (_) {}
}
if (data == null && aurData != null) {
data = PackageDetails(
name: aurData.name,
version: aurData.version,
description: aurData.description,
url: aurData.url,
licenses: aurData.licenses,
dependsOn: aurData.depends,
requiredBy: const [],
packager: aurData.maintainer ?? 'AUR Community',
downloadSizeBytes: 0,
installedSizeBytes: 0,
buildDate: aurData.lastModified?.toLocal().toString().split(' ').first,
repository: 'aur',
backend: BackendType.aur,
);
}
List<CachedPackageVersion> cached = [];
List<String> files = [];
@@ -161,6 +192,7 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
if (mounted) {
setState(() {
_details = data;
_aurDetails = aurData;
_cachedVersions = cached;
_packageFiles = files;
_isLoading = false;
@@ -671,6 +703,29 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
return ListView(
padding: const EdgeInsets.all(22),
children: [
if (_aurDetails?.isOutOfDate ?? false)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: CupertinoColors.systemRed.withValues(alpha: 0.5), width: 0.8),
),
child: const Row(
children: [
MacosIcon(CupertinoIcons.exclamationmark_triangle_fill, color: CupertinoColors.systemRed, size: 20),
SizedBox(width: 10),
Expanded(
child: Text(
'Внимание: Пакет помечен в AUR как устаревший (Out-of-Date)! Возможно, исходный код обновился раньше, чем PKGBUILD.',
style: TextStyle(fontSize: 12.5, color: CupertinoColors.systemRed, fontWeight: FontWeight.w500),
),
),
],
),
),
// Description Card
Container(
padding: const EdgeInsets.all(16),
@@ -707,13 +762,81 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
_buildInfoCard(theme, 'Размер загрузки', '${(d.downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB', CupertinoIcons.arrow_down_to_line),
_buildInfoCard(theme, 'Лицензия', d.licenses.isNotEmpty ? d.licenses.join(', ') : 'Не указана', CupertinoIcons.doc_plaintext),
if (d.packager != null)
_buildInfoCard(theme, 'Сборщик', d.packager!, CupertinoIcons.person_badge_plus),
_buildInfoCard(theme, 'Сборщик / Мейнтейнер', d.packager!, CupertinoIcons.person_badge_plus),
if (d.buildDate != null)
_buildInfoCard(theme, 'Дата сборки', d.buildDate!, CupertinoIcons.calendar),
_buildInfoCard(theme, 'Репозиторий', d.repository, CupertinoIcons.archivebox),
_buildInfoCard(theme, 'Пакетный менеджер', d.backend.displayName, CupertinoIcons.layers),
if (_aurDetails != null) ...[
_buildInfoCard(theme, 'Голоса в AUR', '${_aurDetails!.numVotes}', CupertinoIcons.hand_thumbsup),
_buildInfoCard(theme, 'Популярность', _aurDetails!.popularity.toStringAsFixed(2), CupertinoIcons.chart_bar_alt_fill),
if (_aurDetails!.packageBase.isNotEmpty && _aurDetails!.packageBase != d.name)
_buildInfoCard(theme, 'Базовый пакет', _aurDetails!.packageBase, CupertinoIcons.cube_box_fill),
if (_aurDetails!.firstSubmitted != null)
_buildInfoCard(theme, 'Впервые в AUR', '${_aurDetails!.firstSubmitted!.toLocal()}'.split(' ').first, CupertinoIcons.calendar_today),
if (_aurDetails!.lastModified != null)
_buildInfoCard(theme, 'Обновлен в AUR', '${_aurDetails!.lastModified!.toLocal()}'.split(' ').first, CupertinoIcons.arrow_clockwise),
],
],
),
if (_aurDetails != null && _aurDetails!.keywords.isNotEmpty) ...[
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.4), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ТЕГИ / КЛЮЧЕВЫЕ СЛОВА',
style: TextStyle(fontSize: 10.5, fontWeight: FontWeight.bold, letterSpacing: 0.7, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: _aurDetails!.keywords.map((kw) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.3), width: 0.8),
),
child: Text(
'#$kw',
style: TextStyle(fontSize: 11, color: theme.primaryColor, fontWeight: FontWeight.w500),
),
)).toList(),
),
],
),
),
],
if (d.backend == BackendType.aur) ...[
const SizedBox(height: 20),
Align(
alignment: Alignment.centerLeft,
child: PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => _openProjectUrl('https://aur.archlinux.org/packages/${d.name}'),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.arrow_up_right_square, size: 14),
SizedBox(width: 6),
Text('Открыть страницу на aur.archlinux.org'),
],
),
),
),
],
],
);
}
@@ -753,6 +876,43 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
);
}
Widget _buildDepChips(MacosThemeData theme, List<String> items, {Color? chipColor, Color? borderColor, Color? textColor}) {
if (items.isEmpty) {
return const Text('Отсутствуют.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5));
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: items.map((dep) {
final cleanName = dep.split(RegExp(r'[><=]')).first.trim();
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _navigateTo(cleanName, _currentBackend),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: chipColor ?? theme.primaryColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: borderColor ?? theme.primaryColor.withValues(alpha: 0.3), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
dep,
style: TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: textColor ?? theme.primaryColor, fontWeight: FontWeight.w600),
),
const SizedBox(width: 4),
MacosIcon(CupertinoIcons.arrow_right, size: 10, color: textColor ?? theme.primaryColor),
],
),
),
);
}).toList(),
);
}
Widget _buildDependenciesTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
@@ -760,40 +920,59 @@ class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
// Depends on
_buildSectionHeader('НЕОБХОДИМЫЕ ЗАВИСИМОСТИ (${d.dependsOn.length})'),
const SizedBox(height: 10),
if (d.dependsOn.isEmpty)
const Text('Зависимости отсутствуют.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: d.dependsOn.map((dep) {
final cleanName = dep.split(RegExp(r'[><=]')).first.trim();
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _navigateTo(cleanName, d.backend),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.3), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
dep,
style: TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: theme.primaryColor, fontWeight: FontWeight.w600),
),
const SizedBox(width: 4),
MacosIcon(CupertinoIcons.arrow_right, size: 10, color: theme.primaryColor),
],
),
),
);
}).toList(),
_buildDepChips(theme, d.dependsOn),
if (_aurDetails != null && _aurDetails!.makeDepends.isNotEmpty) ...[
const SizedBox(height: 24),
_buildSectionHeader('СБОРОЧНЫЕ ЗАВИСИМОСТИ (MAKE DEPENDS, ${_aurDetails!.makeDepends.length})'),
const SizedBox(height: 10),
_buildDepChips(
theme,
_aurDetails!.makeDepends,
chipColor: CupertinoColors.systemIndigo.withValues(alpha: 0.12),
borderColor: CupertinoColors.systemIndigo.withValues(alpha: 0.35),
textColor: CupertinoColors.systemIndigo,
),
],
if (_aurDetails != null && _aurDetails!.optDepends.isNotEmpty) ...[
const SizedBox(height: 24),
_buildSectionHeader('ОПЦИОНАЛЬНЫЕ ЗАВИСИМОСТИ (OPT DEPENDS, ${_aurDetails!.optDepends.length})'),
const SizedBox(height: 10),
_buildDepChips(
theme,
_aurDetails!.optDepends,
chipColor: CupertinoColors.systemTeal.withValues(alpha: 0.12),
borderColor: CupertinoColors.systemTeal.withValues(alpha: 0.35),
textColor: CupertinoColors.systemTeal,
),
],
if (_aurDetails != null && _aurDetails!.conflicts.isNotEmpty) ...[
const SizedBox(height: 24),
_buildSectionHeader('КОНФЛИКТУЕТ С (${_aurDetails!.conflicts.length})'),
const SizedBox(height: 10),
_buildDepChips(
theme,
_aurDetails!.conflicts,
chipColor: CupertinoColors.systemRed.withValues(alpha: 0.12),
borderColor: CupertinoColors.systemRed.withValues(alpha: 0.35),
textColor: CupertinoColors.systemRed,
),
],
if (_aurDetails != null && _aurDetails!.provides.isNotEmpty) ...[
const SizedBox(height: 24),
_buildSectionHeader('ПРЕДОСТАВЛЯЕТ (${_aurDetails!.provides.length})'),
const SizedBox(height: 10),
_buildDepChips(
theme,
_aurDetails!.provides,
chipColor: CupertinoColors.systemGreen.withValues(alpha: 0.12),
borderColor: CupertinoColors.systemGreen.withValues(alpha: 0.35),
textColor: CupertinoColors.systemGreen,
),
],
const SizedBox(height: 24),
+524
View File
@@ -0,0 +1,524 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider, SelectableText;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
import '../../services/app_settings_service.dart';
import '../../services/component_installer_service.dart';
import 'reflector_mirror_dialog.dart';
class SetupWizardDialog extends StatefulWidget {
const SetupWizardDialog({super.key});
static Future<void> show(BuildContext context) {
return showMacosSheet(
context: context,
barrierDismissible: false,
builder: (context) => const SetupWizardDialog(),
);
}
@override
State<SetupWizardDialog> createState() => _SetupWizardDialogState();
}
class _SetupWizardDialogState extends State<SetupWizardDialog> {
bool _isLoading = true;
SystemComponentStatus? _status;
bool _isInstalling = false;
String? _installingTitle;
final List<String> _installLogs = [];
final ScrollController _logScrollController = ScrollController();
@override
void initState() {
super.initState();
_refreshStatus();
}
@override
void dispose() {
_logScrollController.dispose();
super.dispose();
}
Future<void> _refreshStatus() async {
setState(() => _isLoading = true);
final status = await ComponentInstallerService.checkComponents();
if (mounted) {
setState(() {
_status = status;
_isLoading = false;
});
}
}
void _scrollToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
Future<void> _installFlatpak() async {
final password = await AdminService.promptPassword(
context,
title: 'Установка Flatpak & Flathub',
reason: 'Для установки пакета flatpak через pacman требуются права администратора.',
);
if (password == null) return;
setState(() {
_isInstalling = true;
_installingTitle = 'Установка Flatpak & Flathub';
_installLogs.clear();
});
try {
await for (final line in ComponentInstallerService.installFlatpak(password)) {
if (mounted) {
setState(() => _installLogs.add(line));
_scrollToBottom();
}
}
} catch (e) {
if (mounted) {
setState(() => _installLogs.add('❌ Ошибка: $e'));
}
} finally {
await _refreshStatus();
if (mounted) {
setState(() => _isInstalling = false);
}
}
}
Future<void> _installParu() async {
final password = await AdminService.promptPassword(
context,
title: 'Установка Paru (AUR Helper)',
reason: 'Для установки сборочных зависимостей требуются права администратора.',
);
if (password == null) return;
setState(() {
_isInstalling = true;
_installingTitle = 'Установка Paru (AUR Helper)';
_installLogs.clear();
});
try {
await for (final line in ComponentInstallerService.installParu(password)) {
if (mounted) {
setState(() => _installLogs.add(line));
_scrollToBottom();
}
}
} catch (e) {
if (mounted) {
setState(() => _installLogs.add('❌ Ошибка: $e'));
}
} finally {
await _refreshStatus();
if (mounted) {
setState(() => _isInstalling = false);
}
}
}
Future<void> _finishSetup() async {
await AppSettingsService.instance.setHasCompletedSetupWizard(true);
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
final deName = AppSettingsService.detectDesktopEnvironmentName();
return Center(
child: Container(
width: 680,
height: 620,
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.12)),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.5),
blurRadius: 32,
offset: const Offset(0, 12),
),
],
),
child: Column(
children: [
// Шапка
Padding(
padding: const EdgeInsets.fromLTRB(24, 22, 24, 16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
),
child: const Center(
child: MacosIcon(
CupertinoIcons.cube_box_fill,
color: Color(0xFF0A84FF),
size: 26,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Добро пожаловать в Forge',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: -0.3,
),
),
const SizedBox(height: 3),
Text(
'Мастер первичной настройки системы • Окружение: Arch Linux ($deName)',
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
),
const Divider(height: 1, color: CupertinoColors.separator),
// Контент
Expanded(
child: _isLoading
? const Center(child: ProgressCircle(radius: 16))
: SingleChildScrollView(
padding: const EdgeInsets.all(22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ИСТОЧНИКИ ПАКЕТОВ И ИНТЕГРАЦИИ',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 10),
// 1. Flatpak & Flathub
_buildComponentCard(
theme: theme,
icon: CupertinoIcons.app_badge_fill,
iconColor: const Color(0xFF34C759),
title: 'Flatpak & Flathub',
subtitle: 'Песочница приложений: доступ к тысячам графических программ из Flathub',
statusText: (_status?.hasFlatpak ?? false)
? ((_status?.hasFlathub ?? false) ? 'Установлен (Flathub активен)' : 'Установлен (без Flathub)')
: 'Не установлен',
isInstalled: _status?.hasFlatpak ?? false,
actionButton: PushButton(
controlSize: ControlSize.small,
secondary: _status?.hasFlatpak ?? false,
onPressed: _isInstalling ? null : _installFlatpak,
child: Text((_status?.hasFlatpak ?? false) ? 'Перенастроить Flathub' : 'Установить Flatpak'),
),
),
const SizedBox(height: 12),
// 2. AUR & Paru
_buildComponentCard(
theme: theme,
icon: CupertinoIcons.flame_fill,
iconColor: const Color(0xFFFF9500),
title: 'AUR и Paru (Rust AUR Helper)',
subtitle: 'Сборка пакетов сообщества из Arch User Repository с максимальной скоростью',
statusText: (_status?.hasParu ?? false)
? 'Paru установлен (активен)'
: ((_status?.hasYay ?? false) ? 'Обнаружен yay (Paru не установлен)' : 'AUR-хелпер отсутствует'),
isInstalled: (_status?.hasParu ?? false) || (_status?.hasYay ?? false),
actionButton: PushButton(
controlSize: ControlSize.small,
secondary: _status?.hasParu ?? false,
onPressed: _isInstalling ? null : _installParu,
child: Text((_status?.hasParu ?? false) ? 'Переустановить Paru' : 'Установить Paru'),
),
),
const SizedBox(height: 20),
const Text(
'СТИЛЬ И ЗЕРКАЛА',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 10),
// 3. Выбор оформления
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)),
),
child: Row(
children: [
const MacosIcon(CupertinoIcons.paintbrush_fill, size: 20, color: Color(0xFF5856D6)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Стиль окна приложения',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
SizedBox(height: 2),
Text(
'Адаптировать под текущее графическое окружение',
style: TextStyle(fontSize: 11.5, color: CupertinoColors.systemGrey),
),
],
),
),
MacosPopupButton<String>(
value: AppSettingsService.instance.windowTheme,
onChanged: (val) {
if (val != null) {
AppSettingsService.instance.setWindowTheme(val);
setState(() {});
}
},
items: const [
MacosPopupMenuItem(value: 'auto', child: Text('Автоматически')),
MacosPopupMenuItem(value: 'kde', child: Text('KDE Breeze')),
MacosPopupMenuItem(value: 'gnome', child: Text('GNOME Adwaita')),
MacosPopupMenuItem(value: 'macos', child: Text('macOS Cupertino')),
],
),
],
),
),
const SizedBox(height: 12),
// 4. Оптимизация зеркал
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)),
),
child: Row(
children: [
const MacosIcon(CupertinoIcons.speedometer, size: 20, color: Color(0xFF0A84FF)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Зеркала репозиториев (Reflector)',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
SizedBox(height: 2),
Text(
'Протестировать пинг и выбрать самые быстрые серверы',
style: TextStyle(fontSize: 11.5, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => ReflectorMirrorDialog.show(context),
child: const Text('Настроить зеркала'),
),
],
),
),
// Терминал логов при установке
if (_installLogs.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
_installingTitle != null ? 'ЛОГ ОПЕРАЦИИ: $_installingTitle' : 'ЛОГ УСТАНОВКИ',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 8),
Container(
height: 120,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: CupertinoColors.white.withValues(alpha: 0.1)),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _installLogs.length,
itemBuilder: (ctx, idx) => SelectableText(
_installLogs[idx],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFFE0E0E0),
),
),
),
),
],
],
),
),
),
const Divider(height: 1, color: CupertinoColors.separator),
// Подвал с кнопками
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: _isInstalling ? null : _finishSetup,
child: const Text('Настроить позже'),
),
const Spacer(),
PushButton(
controlSize: ControlSize.regular,
onPressed: _isInstalling ? null : _finishSetup,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isInstalling) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 8),
],
const Text('Завершить настройку'),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildComponentCard({
required MacosThemeData theme,
required IconData icon,
required Color iconColor,
required String title,
required String subtitle,
required String statusText,
required bool isInstalled,
required Widget actionButton,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Center(child: MacosIcon(icon, color: iconColor, size: 20)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isInstalled
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: isInstalled ? CupertinoColors.systemGreen : CupertinoColors.systemGrey,
),
),
),
],
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(fontSize: 11.5, color: CupertinoColors.systemGrey),
),
],
),
),
const SizedBox(width: 12),
actionButton,
],
),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:macos_ui/macos_ui.dart';
import 'package:forge/src/ui/widgets/setup_wizard_dialog.dart';
void main() {
testWidgets('SetupWizardDialog renders initial setup content', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.runAsync(() async {
await tester.pumpWidget(
const MacosApp(
home: MacosWindow(
child: SetupWizardDialog(),
),
),
);
await Future.delayed(const Duration(milliseconds: 800));
});
for (int i = 0; i < 5; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
expect(find.text('Flatpak & Flathub'), findsOneWidget);
expect(find.text('AUR и Paru (Rust AUR Helper)'), findsOneWidget);
expect(find.text('СТИЛЬ И ЗЕРКАЛА'), findsOneWidget);
expect(find.text('Завершить настройку'), findsOneWidget);
});
}
+1 -1
View File
@@ -19,7 +19,7 @@ void main() {
await tester.runAsync(() async {
final catalogItem = find.text('Каталог');
expect(catalogItem, findsOneWidget);
await tester.tap(catalogItem);
await tester.tap(catalogItem, warnIfMissed: false);
await tester.pump();
await Future.delayed(const Duration(milliseconds: 200));
await tester.pump();