feat: Initial commit of Forge package manager (Flutter + Rust)

This commit is contained in:
RarDog
2026-09-13 18:28:53 +03:00
commit e8c9d13cf1
65 changed files with 14764 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:macos_ui/macos_ui.dart';
import 'src/rust/frb_generated.dart';
import 'src/services/app_settings_service.dart';
import 'src/ui/main_layout.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Инициализация Rust FFI бэкенда через flutter_rust_bridge v2
await RustLib.init();
runApp(const ForgeApp());
}
class ForgeApp extends StatelessWidget {
const ForgeApp({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: AppSettingsService.instance,
builder: (context, _) {
final activeStyle = AppSettingsService.instance.effectiveWindowThemeStyle;
final themeData = activeStyle.buildThemeData();
return MacosApp(
title: 'Forge',
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: themeData,
darkTheme: themeData,
home: const MainLayout(),
);
},
);
}
}
+265
View File
@@ -0,0 +1,265 @@
enum BackendType {
pacman,
flatpak,
aur;
String get displayName {
switch (this) {
case BackendType.pacman:
return 'Pacman';
case BackendType.flatpak:
return 'Flatpak';
case BackendType.aur:
return 'AUR';
}
}
}
enum UpdateStep {
checkingDependencies,
downloading,
verifying,
installing,
runningHooks,
done,
failed,
}
class PackageUpdate {
final String name;
final String currentVersion;
final String newVersion;
final String repository;
final int downloadSizeBytes;
final String description;
final bool isSecurityUpdate;
final BackendType backend;
const PackageUpdate({
required this.name,
required this.currentVersion,
required this.newVersion,
required this.repository,
required this.downloadSizeBytes,
required this.description,
required this.isSecurityUpdate,
required this.backend,
});
String get formattedSize {
if (downloadSizeBytes <= 0) return '—';
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (downloadSizeBytes >= 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (downloadSizeBytes >= 1024) {
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$downloadSizeBytes B';
}
}
class PackageInfo {
final String name;
final String version;
final int installedSizeBytes;
final String description;
final String? installDate;
final bool isExplicit;
final BackendType backend;
const PackageInfo({
required this.name,
required this.version,
required this.installedSizeBytes,
required this.description,
this.installDate,
required this.isExplicit,
required this.backend,
});
String get formattedSize {
if (installedSizeBytes <= 0) return '—';
if (installedSizeBytes >= 1024 * 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (installedSizeBytes >= 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (installedSizeBytes >= 1024) {
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$installedSizeBytes B';
}
}
class PackageDetails {
final String name;
final String version;
final String description;
final String? url;
final List<String> licenses;
final List<String> dependsOn;
final List<String> requiredBy;
final String? packager;
final int downloadSizeBytes;
final int installedSizeBytes;
final String? buildDate;
final String repository;
final BackendType backend;
const PackageDetails({
required this.name,
required this.version,
required this.description,
this.url,
required this.licenses,
required this.dependsOn,
required this.requiredBy,
this.packager,
required this.downloadSizeBytes,
required this.installedSizeBytes,
this.buildDate,
required this.repository,
required this.backend,
});
String get formattedInstalledSize {
if (installedSizeBytes <= 0) return 'Неизвестно';
if (installedSizeBytes >= 1024 * 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (installedSizeBytes >= 1024 * 1024) {
return '${(installedSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (installedSizeBytes >= 1024) {
return '${(installedSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$installedSizeBytes B';
}
String get formattedDownloadSize {
if (downloadSizeBytes <= 0) return 'Неизвестно';
if (downloadSizeBytes >= 1024 * 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (downloadSizeBytes >= 1024 * 1024) {
return '${(downloadSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (downloadSizeBytes >= 1024) {
return '${(downloadSizeBytes / 1024).toStringAsFixed(1)} KB';
}
return '$downloadSizeBytes B';
}
}
class ProgressEvent {
final UpdateStep step;
final String? packageName;
final double percentage; // 0.0 .. 1.0
final String message;
final String? logLine;
const ProgressEvent({
required this.step,
this.packageName,
required this.percentage,
required this.message,
this.logLine,
});
}
class CleanerScanResult {
final int pacmanCacheBytes;
final List<String> orphanPackages;
final int orphanBytes;
final int flatpakUnusedCount;
final int flatpakUnusedBytes;
final int journalBytes;
final int totalReclaimableBytes;
const CleanerScanResult({
required this.pacmanCacheBytes,
required this.orphanPackages,
required this.orphanBytes,
required this.flatpakUnusedCount,
required this.flatpakUnusedBytes,
required this.journalBytes,
required this.totalReclaimableBytes,
});
static String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
}
enum HistoryAction {
upgraded,
installed,
removed;
String get displayName {
switch (this) {
case HistoryAction.upgraded:
return 'Обновлено';
case HistoryAction.installed:
return 'Установлено';
case HistoryAction.removed:
return 'Удалено';
}
}
}
class HistoryItem {
final String timestamp;
final HistoryAction action;
final String packageName;
final String? oldVersion;
final String newVersion;
const HistoryItem({
required this.timestamp,
required this.action,
required this.packageName,
this.oldVersion,
required this.newVersion,
});
}
class DiscoverApp {
final String id;
final String name;
final String summary;
final String category;
final String iconName;
final BackendType backend;
final bool isInstalled;
final double rating;
const DiscoverApp({
required this.id,
required this.name,
required this.summary,
required this.category,
required this.iconName,
required this.backend,
required this.isInstalled,
required this.rating,
});
}
class CachedPackageVersion {
final String version;
final String filePath;
final int fileSizeBytes;
final DateTime modifiedTime;
const CachedPackageVersion({
required this.version,
required this.filePath,
required this.fileSizeBytes,
required this.modifiedTime,
});
}
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class AdminService {
/// Проверка, требуются ли права sudo без ввода пароля (NOPASSWD или уже кэшировано)
static Future<bool> isPasswordRequired() async {
try {
final res = await Process.run('sudo', ['-n', 'true']);
return res.exitCode != 0;
} catch (_) {
return true;
}
}
/// Проверка валидности пароля через sudo -S -v
static Future<bool> verifyPassword(String password) async {
try {
final proc = await Process.start('sudo', ['-S', '-v', '-p', '']);
proc.stdin.writeln(password);
await proc.stdin.flush();
await proc.stdin.close();
final exitCode = await proc.exitCode;
return exitCode == 0;
} catch (_) {
return false;
}
}
/// Запуск команды с правами суперпользователя через sudo -S
static Future<Process> runPrivileged(
String? password,
String executable,
List<String> arguments, {
Map<String, String>? environment,
}) async {
final List<String> sudoArgs = ['-S', '-p', '', executable, ...arguments];
final proc = await Process.start(
'sudo',
sudoArgs,
environment: {
'LC_ALL': 'C',
...?environment,
},
);
if (password != null && password.isNotEmpty) {
proc.stdin.writeln(password);
await proc.stdin.flush();
}
await proc.stdin.close();
return proc;
}
/// Запрос пароля администратора через аутентичный модальный диалог macOS
static Future<String?> promptPassword(
BuildContext context, {
String title = 'Forge запрашивает права администратора',
required String reason,
}) async {
// Если пароль не требуется (NOPASSWD), сразу возвращаем пустую строку
final required = await isPasswordRequired();
if (!required) {
return '';
}
if (!context.mounted) return null;
return showMacosSheet<String>(
context: context,
barrierDismissible: false,
builder: (ctx) => _AdminPasswordSheet(
title: title,
reason: reason,
),
);
}
}
class _AdminPasswordSheet extends StatefulWidget {
final String title;
final String reason;
const _AdminPasswordSheet({
required this.title,
required this.reason,
});
@override
State<_AdminPasswordSheet> createState() => _AdminPasswordSheetState();
}
class _AdminPasswordSheetState extends State<_AdminPasswordSheet> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _isVerifying = false;
String? _errorMessage;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _submit() async {
final pass = _controller.text;
if (pass.isEmpty) {
setState(() => _errorMessage = 'Введите пароль администратора');
return;
}
setState(() {
_isVerifying = true;
_errorMessage = null;
});
final isValid = await AdminService.verifyPassword(pass);
if (!mounted) return;
if (isValid) {
Navigator.of(context).pop(pass);
} else {
setState(() {
_isVerifying = false;
_errorMessage = 'Неверный пароль. Пожалуйста, попробуйте снова.';
});
_controller.clear();
_focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
final user = Platform.environment['USER'] ?? 'admin';
return Center(
child: Container(
width: 440,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.15),
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.5),
blurRadius: 30,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
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.lock_shield_fill,
color: Color(0xFF0A84FF),
size: 26,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 4),
Text(
widget.reason,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Пользователь
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.person_crop_circle,
size: 18,
color: CupertinoColors.systemGrey,
),
const SizedBox(width: 8),
Text(
'Пользователь: $user',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 12),
// Поле пароля
MacosTextField(
controller: _controller,
focusNode: _focusNode,
obscureText: true,
enabled: !_isVerifying,
placeholder: 'Пароль учетной записи',
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...[
const SizedBox(height: 8),
Row(
children: [
const MacosIcon(
CupertinoIcons.exclamationmark_triangle_fill,
size: 13,
color: CupertinoColors.systemRed,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemRed,
),
),
),
],
),
],
const SizedBox(height: 22),
// Кнопки действий
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: _isVerifying ? null : () => Navigator.of(context).pop(null),
child: const Text('Отмена'),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: _isVerifying ? null : _submit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isVerifying) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 8),
],
const Text('Подтвердить'),
],
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
class AppBadgeController extends ChangeNotifier {
static final AppBadgeController instance = AppBadgeController._();
AppBadgeController._();
int _updatesCount = 0;
int _cleanerBytes = 0;
int get updatesCount => _updatesCount;
int get cleanerBytes => _cleanerBytes;
void setUpdatesCount(int count) {
if (_updatesCount != count) {
_updatesCount = count;
notifyListeners();
}
}
void setCleanerBytes(int bytes) {
if (_cleanerBytes != bytes) {
_cleanerBytes = bytes;
notifyListeners();
}
}
static String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../ui/theme/window_theme_style.dart';
class AppSettingsService extends ChangeNotifier {
static final AppSettingsService instance = AppSettingsService._();
AppSettingsService._() {
_loadSettings();
}
bool _createSnapshotBeforeUpdate = false;
bool _usePolkit = true;
String _preferredBackend = 'pacman';
String _preferredMirrorCountry = '';
String _windowTheme = 'auto';
bool get createSnapshotBeforeUpdate => _createSnapshotBeforeUpdate;
bool get usePolkit => _usePolkit;
String get preferredBackend => _preferredBackend;
String get preferredMirrorCountry => _preferredMirrorCountry;
String get windowTheme => _windowTheme;
static String detectDesktopEnvironmentName() {
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
return 'KDE Plasma';
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
return 'GNOME';
}
return 'KDE Plasma';
}
static WindowThemeStyle detectDesktopEnvironmentStyle() {
final xdg = (Platform.environment['XDG_CURRENT_DESKTOP'] ?? '').toUpperCase();
final session = (Platform.environment['DESKTOP_SESSION'] ?? '').toUpperCase();
if (xdg.contains('KDE') || session.contains('KDE') || session.contains('PLASMA')) {
return WindowThemeStyle.kde;
} else if (xdg.contains('GNOME') || session.contains('GNOME')) {
return WindowThemeStyle.gnome;
}
return WindowThemeStyle.kde;
}
WindowThemeStyle get effectiveWindowThemeStyle {
switch (_windowTheme) {
case 'macos':
return WindowThemeStyle.macos;
case 'gnome':
return WindowThemeStyle.gnome;
case 'kde':
return WindowThemeStyle.kde;
case 'auto':
default:
return detectDesktopEnvironmentStyle();
}
}
File get _configFile {
final home = Platform.environment['HOME'] ?? '';
final dir = Directory('$home/.config/forge');
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
return File('${dir.path}/settings.json');
}
Future<void> load() async {
_loadSettings();
}
void _loadSettings() {
try {
final file = _configFile;
if (file.existsSync()) {
final content = file.readAsStringSync();
final Map<String, dynamic> data = jsonDecode(content);
_createSnapshotBeforeUpdate = data['createSnapshotBeforeUpdate'] as bool? ?? false;
_usePolkit = data['usePolkit'] as bool? ?? true;
_preferredBackend = data['preferredBackend'] as String? ?? 'pacman';
_preferredMirrorCountry = data['preferredMirrorCountry'] as String? ?? '';
_windowTheme = data['windowTheme'] as String? ?? 'auto';
}
} catch (_) {}
}
Future<void> _saveSettings() async {
try {
final file = _configFile;
final data = {
'createSnapshotBeforeUpdate': _createSnapshotBeforeUpdate,
'usePolkit': _usePolkit,
'preferredBackend': _preferredBackend,
'preferredMirrorCountry': _preferredMirrorCountry,
'windowTheme': _windowTheme,
};
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data));
} catch (_) {}
}
Future<void> setWindowTheme(String value) async {
if (_windowTheme == value) return;
_windowTheme = value;
notifyListeners();
await _saveSettings();
}
Future<void> setCreateSnapshotBeforeUpdate(bool value) async {
if (_createSnapshotBeforeUpdate == value) return;
_createSnapshotBeforeUpdate = value;
notifyListeners();
await _saveSettings();
}
Future<void> setUsePolkit(bool value) async {
if (_usePolkit == value) return;
_usePolkit = value;
notifyListeners();
await _saveSettings();
}
Future<void> setPreferredBackend(String value) async {
if (_preferredBackend == value) return;
_preferredBackend = value;
notifyListeners();
await _saveSettings();
}
Future<void> setPreferredMirrorCountry(String value) async {
if (_preferredMirrorCountry == value) return;
_preferredMirrorCountry = value;
notifyListeners();
await _saveSettings();
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:convert';
import 'dart:io';
class ArchNewsItem {
final String title;
final String link;
final String pubDate;
final String description;
final bool isManualIntervention;
const ArchNewsItem({
required this.title,
required this.link,
required this.pubDate,
required this.description,
required this.isManualIntervention,
});
}
class ArchNewsService {
static List<ArchNewsItem>? _cachedNews;
static DateTime? _lastFetchTime;
/// Загрузка последних новостей Arch Linux из официального RSS-фида
static Future<List<ArchNewsItem>> fetchNews({bool forceRefresh = false}) async {
if (!forceRefresh && _cachedNews != null && _lastFetchTime != null) {
if (DateTime.now().difference(_lastFetchTime!).inMinutes < 30) {
return _cachedNews!;
}
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 6);
final request = await client.getUrl(Uri.parse('https://archlinux.org/feeds/news/'));
final response = await request.close();
if (response.statusCode == 200) {
final xmlString = await response.transform(utf8.decoder).join();
final items = _parseRss(xmlString);
_cachedNews = items;
_lastFetchTime = DateTime.now();
return items;
}
} catch (_) {
// Fallback через curl, если HttpClient ограничен сетевыми политиками
try {
final res = await Process.run('curl', ['-s', '--max-time', '6', 'https://archlinux.org/feeds/news/']);
if (res.exitCode == 0 && res.stdout.toString().isNotEmpty) {
final items = _parseRss(res.stdout.toString());
_cachedNews = items;
_lastFetchTime = DateTime.now();
return items;
}
} catch (_) {}
}
return _cachedNews ?? [];
}
/// Проверка, требуют ли какие-либо из доступных обновлений ручного вмешательства
static List<ArchNewsItem> checkInterventions(
List<String> updatingPackageNames,
List<ArchNewsItem> newsItems,
) {
final List<ArchNewsItem> matched = [];
final lowerPkgNames = updatingPackageNames.map((p) => p.toLowerCase()).toSet();
for (final news in newsItems) {
if (!news.isManualIntervention) continue;
final lowerTitle = news.title.toLowerCase();
for (final pkg in lowerPkgNames) {
if (lowerTitle.contains(pkg)) {
matched.add(news);
break;
}
}
}
return matched;
}
static List<ArchNewsItem> _parseRss(String xml) {
final List<ArchNewsItem> items = [];
final itemBlocks = xml.split('<item>');
for (int i = 1; i < itemBlocks.length; i++) {
final block = itemBlocks[i];
final rawTitle = _extractTag(block, 'title');
final link = _extractTag(block, 'link');
final rawPubDate = _extractTag(block, 'pubDate');
final rawDesc = _extractTag(block, 'description');
final title = _decodeHtmlEntities(rawTitle).trim();
final cleanDesc = _cleanHtml(rawDesc);
final pubDate = _formatPubDate(rawPubDate);
final isIntervention = title.toLowerCase().contains('manual intervention') ||
title.toLowerCase().contains('breaking changes');
if (title.isNotEmpty) {
items.add(ArchNewsItem(
title: title,
link: link,
pubDate: pubDate,
description: cleanDesc,
isManualIntervention: isIntervention,
));
}
}
return items;
}
static String _decodeHtmlEntities(String text) {
if (text.isEmpty) return '';
return text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ')
.replaceAll('&#8216;', "'")
.replaceAll('&#8217;', "'")
.replaceAll('&#8220;', '"')
.replaceAll('&#8221;', '"')
.replaceAll('&#8211;', '–')
.replaceAll('&#8212;', '—')
.replaceAll(RegExp(r'&#(\d+);'), '')
.replaceAll(RegExp(r'&#x([0-9a-fA-F]+);'), '');
}
static String _cleanHtml(String raw) {
if (raw.isEmpty) return '';
// Сначала разворачиваем сущности < и > для получения HTML тегов
String text = raw
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&amp;', '&');
// Форматируем списки и абзацы
text = text
.replaceAll(RegExp(r'<\s*br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ')
.replaceAll(RegExp(r'</\s*li\s*>', caseSensitive: false), '\n');
// Удаляем все оставшиеся HTML теги
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// Декодируем оставшиеся сущности
text = _decodeHtmlEntities(text);
// Нормализуем пробелы и пустые строки
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
static String _formatPubDate(String rawDate) {
if (rawDate.isEmpty) return '';
try {
// Пример формата: "Tue, 21 Jul 2026 13:01:46 +0000"
final parts = rawDate.trim().split(RegExp(r'\s+'));
if (parts.length >= 4) {
final day = parts[1];
final monthStr = parts[2].toLowerCase();
final year = parts[3];
const months = {
'jan': 'янв',
'feb': 'фев',
'mar': 'мар',
'apr': 'апр',
'may': 'мая',
'jun': 'июн',
'jul': 'июл',
'aug': 'авг',
'sep': 'сен',
'oct': 'окт',
'nov': 'ноя',
'dec': 'дек',
};
final monthRu = months[monthStr] ?? monthStr;
return '$day $monthRu $year';
}
} catch (_) {}
return rawDate;
}
static String _extractTag(String xml, String tag) {
final startTag = '<$tag>';
final endTag = '</$tag>';
final sIdx = xml.indexOf(startTag);
if (sIdx == -1) {
// CDATA or namespaced
final cdataStart = '<$tag><![CDATA[';
final cdataEnd = ']]></$tag>';
final cdIdx = xml.indexOf(cdataStart);
if (cdIdx != -1) {
final cdEnd = xml.indexOf(cdataEnd, cdIdx);
if (cdEnd != -1) {
return xml.substring(cdIdx + cdataStart.length, cdEnd).trim();
}
}
return '';
}
final eIdx = xml.indexOf(endTag, sIdx);
if (eIdx == -1) return '';
return xml.substring(sIdx + startTag.length, eIdx).trim();
}
}
+199
View File
@@ -0,0 +1,199 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../rust/api/packages.dart';
class AurPackage {
final String name;
final String version;
final String description;
final int numVotes;
final double popularity;
final String? maintainer;
final int? lastModified;
final String? url;
final bool isOutOfDate;
const AurPackage({
required this.name,
required this.version,
required this.description,
required this.numVotes,
required this.popularity,
this.maintainer,
this.lastModified,
this.url,
required this.isOutOfDate,
});
factory AurPackage.fromJson(Map<String, dynamic> json) {
return AurPackage(
name: json['Name'] as String? ?? '',
version: json['Version'] as String? ?? '1.0.0',
description: json['Description'] as String? ?? '',
numVotes: (json['NumVotes'] as num?)?.toInt() ?? 0,
popularity: (json['Popularity'] as num?)?.toDouble() ?? 0.0,
maintainer: json['Maintainer'] as String?,
lastModified: (json['LastModified'] as num?)?.toInt(),
url: json['URL'] as String?,
isOutOfDate: json['OutOfDate'] != null,
);
}
}
class AurService {
static String? _cachedHelper;
/// Определение доступного AUR-хелпера (предпочтение paru, затем yay)
static Future<String?> getHelper() async {
if (_cachedHelper != null) return _cachedHelper;
try {
final paruRes = await Process.run('which', ['paru']);
if (paruRes.exitCode == 0 && paruRes.stdout.toString().trim().isNotEmpty) {
_cachedHelper = 'paru';
return _cachedHelper;
}
} catch (_) {}
try {
final yayRes = await Process.run('which', ['yay']);
if (yayRes.exitCode == 0 && yayRes.stdout.toString().trim().isNotEmpty) {
_cachedHelper = 'yay';
return _cachedHelper;
}
} catch (_) {}
return null;
}
/// Поиск пакетов в AUR через официальный RPC API v5
static Future<List<AurPackage>> search(String query) async {
if (query.trim().isEmpty) return [];
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final uri = Uri.parse('https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}');
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>? ?? [];
final list = results
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
.toList();
// Сортируем по количеству голосов
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
return list;
}
} catch (_) {
// Fallback через curl
try {
final curlRes = await Process.run('curl', [
'-s',
'--max-time',
'5',
'https://aur.archlinux.org/rpc/v5/search/${Uri.encodeComponent(query.trim())}',
]);
if (curlRes.exitCode == 0) {
final Map<String, dynamic> data = jsonDecode(curlRes.stdout.toString());
final results = data['results'] as List<dynamic>? ?? [];
final list = results
.map((item) => AurPackage.fromJson(item as Map<String, dynamic>))
.toList();
list.sort((a, b) => b.numVotes.compareTo(a.numVotes));
return list;
}
} catch (_) {}
}
return [];
}
/// Сборка и установка пакета AUR через найденный хелпер (paru / yay)
static Stream<ProgressEvent> installPackage(String packageName) async* {
final helper = await getHelper();
if (helper == null) {
yield const ProgressEvent(
step: UpdateStep.failed,
percentage: 1.0,
message: 'Не найден AUR-хелпер (установите yay или paru)',
logLine: '[ERROR] Neither yay nor paru is installed on this system.',
);
return;
}
yield ProgressEvent(
step: UpdateStep.checkingDependencies,
packageName: packageName,
percentage: 0.1,
message: 'Подготовка к сборке $packageName через $helper...',
logLine: ':: Resolving AUR dependencies using $helper for $packageName...',
);
try {
final proc = await Process.start(
helper,
['-S', '--noconfirm', packageName],
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('[STDOUT ERR] $e'),
);
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => controller.add('[STDERR] $line'),
onError: (e) => controller.add('[STDERR ERR] $e'),
);
proc.exitCode.then((_) {
Future.delayed(const Duration(milliseconds: 60), () {
if (!controller.isClosed) controller.close();
});
});
await for (final line in controller.stream) {
yield ProgressEvent(
step: UpdateStep.installing,
packageName: packageName,
percentage: 0.6,
message: 'Сборка и установка $packageName...',
logLine: line,
);
}
final exitCode = await proc.exitCode;
if (exitCode == 0) {
yield ProgressEvent(
step: UpdateStep.done,
packageName: packageName,
percentage: 1.0,
message: 'Пакет $packageName успешно собран и установлен!',
logLine: ':: AUR installation finished successfully.',
);
} else {
yield ProgressEvent(
step: UpdateStep.failed,
packageName: packageName,
percentage: 1.0,
message: 'Ошибка при установке $packageName (код $exitCode)',
logLine: '[ERROR] $helper exited with code $exitCode',
);
}
} catch (e) {
yield ProgressEvent(
step: UpdateStep.failed,
packageName: packageName,
percentage: 1.0,
message: 'Не удалось запустить $helper: $e',
logLine: '[ERROR] Process launch error: $e',
);
}
}
}
+353
View File
@@ -0,0 +1,353 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import '../rust/api/packages.dart';
import 'admin_service.dart';
import 'app_settings_service.dart';
import 'aur_service.dart';
import 'catalog_service.dart';
import 'snapshot_service.dart';
class BatchInstallProgress {
final String currentStep;
final double percentage; // 0.0 .. 1.0
final String? logLine;
final bool isCompleted;
final bool isFailed;
const BatchInstallProgress({
required this.currentStep,
required this.percentage,
this.logLine,
this.isCompleted = false,
this.isFailed = false,
});
}
class BatchInstallService extends ChangeNotifier {
static final BatchInstallService instance = BatchInstallService._();
BatchInstallService._();
final Map<String, CatalogApp> _queue = {};
bool _isInstalling = false;
bool get isInstalling => _isInstalling;
List<CatalogApp> get queueApps => _queue.values.toList();
int get count => _queue.length;
bool get isEmpty => _queue.isEmpty;
List<CatalogApp> get pacmanApps =>
_queue.values.where((a) => a.backend == BackendType.pacman).toList();
List<CatalogApp> get aurApps =>
_queue.values.where((a) => a.backend == BackendType.aur).toList();
List<CatalogApp> get flatpakApps =>
_queue.values.where((a) => a.backend == BackendType.flatpak).toList();
bool contains(CatalogApp app) => _queue.containsKey(app.uniqueKey);
void toggle(CatalogApp app) {
if (contains(app)) {
_queue.remove(app.uniqueKey);
} else {
_queue[app.uniqueKey] = app;
}
notifyListeners();
}
void add(CatalogApp app) {
_queue[app.uniqueKey] = app;
notifyListeners();
}
void remove(CatalogApp app) {
_queue.remove(app.uniqueKey);
notifyListeners();
}
void clear() {
_queue.clear();
notifyListeners();
}
/// Запуск пакетной установки выбранных приложений
Stream<BatchInstallProgress> runBatchInstall({
required String? password,
}) async* {
if (_queue.isEmpty) return;
_isInstalling = true;
notifyListeners();
final toInstall = List<CatalogApp>.from(_queue.values);
final pacmanList = toInstall.where((a) => a.backend == BackendType.pacman).toList();
final aurList = toInstall.where((a) => a.backend == BackendType.aur).toList();
final flatpakList = toInstall.where((a) => a.backend == BackendType.flatpak).toList();
final totalStages = (pacmanList.isNotEmpty ? 1 : 0) +
(aurList.isNotEmpty ? 1 : 0) +
(flatpakList.isNotEmpty ? 1 : 0);
int currentStage = 0;
bool hasErrors = false;
// Шаг 0: Снимок системы, если включено в настройках
if (AppSettingsService.instance.createSnapshotBeforeUpdate && password != null && (pacmanList.isNotEmpty || aurList.isNotEmpty)) {
yield const BatchInstallProgress(
currentStep: 'Создание точки восстановления системы (Snapshot)...',
percentage: 0.05,
logLine: '📸 Создание моментального снимка системы перед пакетной установкой...',
);
try {
final snap = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge batch install (${toInstall.length} apps)',
);
yield BatchInstallProgress(
currentStep: 'Снимок системы создан',
percentage: 0.08,
logLine: snap.success ? '✅ ${snap.message}' : '⚠️ ${snap.message}',
);
} catch (e) {
yield BatchInstallProgress(
currentStep: 'Снимок пропущен',
percentage: 0.08,
logLine: '⚠️ Не удалось создать снимок: $e',
);
}
}
// Шаг 1: Pacman пакеты
if (pacmanList.isNotEmpty) {
currentStage++;
final pkgIds = pacmanList.map((a) => a.id).toList();
final pkgLabels = pacmanList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Установка пакетов Pacman (${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [Pacman] Установка: ${pkgIds.join(" ")}',
);
try {
final proc = await AdminService.runPrivileged(
password,
'pacman',
['-S', '--noconfirm', '--needed', ...pkgIds],
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman: установка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman: обработка...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman успешно завершен',
percentage: currentStage / (totalStages + 0.2),
logLine: '✅ [Pacman] Пакеты успешно установлены!',
);
for (final a in pacmanList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Pacman завершился с ошибкой',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [Pacman] Ошибка установки (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение Pacman',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [Pacman] Ошибка: $e',
isFailed: true,
);
}
}
// Шаг 2: AUR пакеты (через yay или paru)
if (aurList.isNotEmpty) {
currentStage++;
final helper = await AurService.getHelper() ?? 'yay';
final pkgIds = aurList.map((a) => a.id).toList();
final pkgLabels = aurList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Сборка и установка AUR ($helper: ${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [AUR] Сборка через $helper: ${pkgIds.join(" ")}',
);
try {
if (password != null) {
await AdminService.verifyPassword(password);
}
final proc = await Process.start(
helper,
['-S', '--noconfirm', '--needed', ...pkgIds],
mode: ProcessStartMode.normal,
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR ($helper): компиляция и сборка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR ($helper): сообщения...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR успешно завершен',
percentage: currentStage / (totalStages + 0.2),
logLine: '✅ [AUR] Все пакеты успешно собраны и установлены!',
);
for (final a in aurList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] AUR завершился с ошибкой',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [AUR] Ошибка сборки пакетов (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение AUR',
percentage: currentStage / (totalStages + 0.2),
logLine: '❌ [AUR] Ошибка: $e',
isFailed: true,
);
}
}
// Шаг 3: Flatpak пакеты
if (flatpakList.isNotEmpty) {
currentStage++;
final pkgIds = flatpakList.map((a) => a.id).toList();
final pkgLabels = flatpakList.map((a) => a.name == a.id ? a.id : '${a.name} (${a.id})').toList();
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Установка Flatpak (${pkgLabels.join(", ")})...',
percentage: (currentStage - 0.5) / (totalStages + 0.2),
logLine: '🚀 [Flatpak] Установка из Flathub: ${pkgIds.join(" ")}',
);
try {
final proc = await Process.start(
'flatpak',
['install', '-y', 'flathub', ...pkgIds],
mode: ProcessStartMode.normal,
);
final stdoutSub = proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
final stderrSub = proc.stderr.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in stdoutSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak: скачивание и распаковка...',
percentage: (currentStage - 0.3) / (totalStages + 0.2),
logLine: line,
);
}
await for (final errLine in stderrSub) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak: логи...',
percentage: (currentStage - 0.2) / (totalStages + 0.2),
logLine: errLine,
);
}
final code = await proc.exitCode;
if (code == 0) {
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak успешно завершен',
percentage: 1.0,
logLine: '✅ [Flatpak] Приложения успешно установлены!',
);
for (final a in flatpakList) {
a.isInstalled = true;
_queue.remove(a.uniqueKey);
}
} else {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Flatpak завершился с кодом $code',
percentage: 1.0,
logLine: '❌ [Flatpak] Ошибка установки (код $code)',
isFailed: true,
);
}
} catch (e) {
hasErrors = true;
yield BatchInstallProgress(
currentStep: '[$currentStage/$totalStages] Исключение Flatpak',
percentage: 1.0,
logLine: '❌ [Flatpak] Ошибка: $e',
isFailed: true,
);
}
}
_isInstalling = false;
notifyListeners();
if (hasErrors) {
yield const BatchInstallProgress(
currentStep: 'Установка завершена с ошибками',
percentage: 1.0,
logLine: '⚠️ Некоторые пакеты не удалось установить. Проверьте журнал ошибок выше.',
isCompleted: true,
isFailed: true,
);
} else {
yield const BatchInstallProgress(
currentStep: 'Пакетная установка успешно завершена',
percentage: 1.0,
logLine: '🎉 Все операции успешно завершены!',
isCompleted: true,
isFailed: false,
);
}
}
}
+455
View File
@@ -0,0 +1,455 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import '../rust/api/packages.dart';
import 'aur_service.dart';
class CatalogApp {
final String id;
final String name;
final String summary;
final String category;
final BackendType backend;
final String? iconUrl;
final String? localIconPath;
final IconData fallbackIcon;
final Color accentColor;
bool isInstalled;
final double rating;
final String? version;
final int? votes;
CatalogApp({
required this.id,
required this.name,
required this.summary,
required this.category,
required this.backend,
this.iconUrl,
this.localIconPath,
required this.fallbackIcon,
this.accentColor = const Color(0xFF0A84FF),
this.isInstalled = false,
this.rating = 4.8,
this.version,
this.votes,
});
String get uniqueKey => '${backend.name}:$id';
}
class CatalogService {
static final Map<String, String> _knownIconUrls = {
'telegram-desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
'org.telegram.desktop': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.telegram.desktop.png',
'visual-studio-code-bin': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'com.visualstudio.code': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.visualstudio.code.png',
'firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
'org.mozilla.firefox': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.mozilla.firefox.png',
'google-chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
'com.google.Chrome': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.google.Chrome.png',
'spotify': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
'com.spotify.Client': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.spotify.Client.png',
'discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
'com.discordapp.Discord': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.discordapp.Discord.png',
'steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
'com.valvesoftware.Steam': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.valvesoftware.Steam.png',
'obs-studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
'com.obsproject.Studio': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.obsproject.Studio.png',
'gimp': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
'org.gimp.GIMP': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.gimp.GIMP.png',
'blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
'org.blender.Blender': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.blender.Blender.png',
'krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
'org.kde.krita': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.kde.krita.png',
'vlc': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
'org.videolan.VLC': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.videolan.VLC.png',
'inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
'org.inkscape.Inkscape': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/org.inkscape.Inkscape.png',
'obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
'md.obsidian.Obsidian': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/md.obsidian.Obsidian.png',
'postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
'com.getpostman.Postman': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.getpostman.Postman.png',
'com.usebottles.bottles': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.usebottles.bottles.png',
'com.localsend.localsend_app': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.localsend.localsend_app.png',
'com.github.tchx84.Flatseal': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/com.github.tchx84.Flatseal.png',
'neovim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
'io.neovim.nvim': 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/io.neovim.nvim.png',
};
/// Получение кураторских подборок для витрины App Store
static List<CatalogApp> getFeaturedApps() {
return [
CatalogApp(
id: 'org.telegram.desktop',
name: 'Telegram Desktop',
summary: 'Официальный защищённый мессенджер с облачной синхронизацией, каналами и голосовыми чатами.',
category: 'Сеть и общение',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.telegram.desktop'],
fallbackIcon: CupertinoIcons.bubble_left_bubble_right_fill,
accentColor: const Color(0xFF2AABEE),
rating: 4.9,
),
CatalogApp(
id: 'visual-studio-code-bin',
name: 'Visual Studio Code',
summary: 'Полнофункциональный и расширяемый редактор кода от Microsoft с поддержкой Git и тысяч плагинов.',
category: 'Разработка',
backend: BackendType.aur,
iconUrl: _knownIconUrls['visual-studio-code-bin'],
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF007ACC),
rating: 4.9,
),
CatalogApp(
id: 'org.blender.Blender',
name: 'Blender 3D',
summary: 'Профессиональный пакет для 3D-моделирования, анимации, симуляции и видеорендеринга.',
category: 'Графика и 3D',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.blender.Blender'],
fallbackIcon: CupertinoIcons.cube_box_fill,
accentColor: const Color(0xFFE87D0D),
rating: 4.95,
),
CatalogApp(
id: 'com.valvesoftware.Steam',
name: 'Steam',
summary: 'Крупнейшая цифровая платформа для загрузки, покупки и запуска компьютерных игр.',
category: 'Игры',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.valvesoftware.Steam'],
fallbackIcon: CupertinoIcons.gamecontroller_fill,
accentColor: const Color(0xFF171A21),
rating: 4.8,
),
CatalogApp(
id: 'org.kde.krita',
name: 'Krita',
summary: 'Свободный графический редактор для иллюстраторов, концепт-артистов и аниматоров.',
category: 'Графика',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['org.kde.krita'],
fallbackIcon: CupertinoIcons.paintbrush_fill,
accentColor: const Color(0xFFEE3F80),
rating: 4.9,
),
CatalogApp(
id: 'com.obsproject.Studio',
name: 'OBS Studio',
summary: 'Бесплатная программа с открытым исходным кодом для записи видео и прямых трансляций.',
category: 'Медиа',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.obsproject.Studio'],
fallbackIcon: CupertinoIcons.video_camera_solid,
accentColor: const Color(0xFF302E2E),
rating: 4.85,
),
];
}
/// Категории витрины
static Map<String, List<CatalogApp>> getCuratedCategories() {
return {
'Разработка и IT': [
CatalogApp(
id: 'neovim',
name: 'Neovim',
summary: 'Модальный текстовый редактор нового поколения для консоли.',
category: 'Разработка',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['neovim'],
fallbackIcon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF57A143),
rating: 4.9,
),
CatalogApp(
id: 'postman',
name: 'Postman',
summary: 'Платформа для тестирования, разработки и документирования REST API.',
category: 'Разработка',
backend: BackendType.aur,
iconUrl: _knownIconUrls['postman'],
fallbackIcon: CupertinoIcons.paperplane_fill,
accentColor: const Color(0xFFFF6C37),
rating: 4.7,
),
CatalogApp(
id: 'git',
name: 'Git',
summary: 'Распределённая система управления версиями исходного кода.',
category: 'Разработка',
backend: BackendType.pacman,
fallbackIcon: CupertinoIcons.arrow_branch,
accentColor: const Color(0xFFF05032),
rating: 5.0,
),
CatalogApp(
id: 'docker',
name: 'Docker',
summary: 'Платформа автоматизации развёртывания приложений в контейнерах.',
category: 'Разработка',
backend: BackendType.pacman,
fallbackIcon: CupertinoIcons.layers_alt_fill,
accentColor: const Color(0xFF2496ED),
rating: 4.8,
),
],
'Творчество и Дизайн': [
CatalogApp(
id: 'gimp',
name: 'GIMP',
summary: 'Свободный графический редактор для растровых изображений и фотографий.',
category: 'Графика',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['gimp'],
fallbackIcon: CupertinoIcons.paintbrush_fill,
accentColor: const Color(0xFF5C5543),
rating: 4.6,
),
CatalogApp(
id: 'inkscape',
name: 'Inkscape',
summary: 'Векторный редактор профессионального уровня с открытым кодом.',
category: 'Графика',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['inkscape'],
fallbackIcon: CupertinoIcons.pencil_outline,
accentColor: const Color(0xFF000000),
rating: 4.7,
),
CatalogApp(
id: 'vlc',
name: 'VLC Media Player',
summary: 'Универсальный медиаплеер, воспроизводящий практически любые файлы.',
category: 'Медиа',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['vlc'],
fallbackIcon: CupertinoIcons.play_circle_fill,
accentColor: const Color(0xFFFF8800),
rating: 4.8,
),
],
'Интернет и Общение': [
CatalogApp(
id: 'discord',
name: 'Discord',
summary: 'Голосовые и текстовые чаты для геймеров, сообществ и друзей.',
category: 'Сеть',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['discord'],
fallbackIcon: CupertinoIcons.chat_bubble_2_fill,
accentColor: const Color(0xFF5865F2),
rating: 4.7,
),
CatalogApp(
id: 'firefox',
name: 'Firefox',
summary: 'Быстрый и защищённый веб-браузер с упором на конфиденциальность.',
category: 'Сеть',
backend: BackendType.pacman,
iconUrl: _knownIconUrls['firefox'],
fallbackIcon: CupertinoIcons.globe,
accentColor: const Color(0xFFFF7139),
rating: 4.85,
),
CatalogApp(
id: 'com.localsend.localsend_app',
name: 'LocalSend',
summary: 'Безопасный обмен файлами по локальной сети без интернета (AirDrop для Linux).',
category: 'Сеть',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.localsend.localsend_app'],
fallbackIcon: CupertinoIcons.share,
accentColor: const Color(0xFF00B4D8),
rating: 4.95,
),
],
'Утилиты и Система': [
CatalogApp(
id: 'com.usebottles.bottles',
name: 'Bottles',
summary: 'Запуск Windows-приложений и игр в изолированных префиксах Wine.',
category: 'Утилиты',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.usebottles.bottles'],
fallbackIcon: CupertinoIcons.tuningfork,
accentColor: const Color(0xFF5C5F66),
rating: 4.8,
),
CatalogApp(
id: 'com.github.tchx84.Flatseal',
name: 'Flatseal',
summary: 'Управление разрешениями, песочницей и доступом приложений Flatpak.',
category: 'Утилиты',
backend: BackendType.flatpak,
iconUrl: _knownIconUrls['com.github.tchx84.Flatseal'],
fallbackIcon: CupertinoIcons.lock_shield_fill,
accentColor: const Color(0xFF333333),
rating: 4.9,
),
],
};
}
/// Проверка установленных пакетов в системе
static Future<void> checkInstalledStatus(List<CatalogApp> apps) async {
try {
final pacmanRes = await Process.run('pacman', ['-Qq']);
final installedPacman = pacmanRes.exitCode == 0
? LineSplitter.split(pacmanRes.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
final flatpakRes = await Process.run('flatpak', ['list', '--columns=application']);
final installedFlatpak = flatpakRes.exitCode == 0
? LineSplitter.split(flatpakRes.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
for (final app in apps) {
if (app.backend == BackendType.flatpak) {
app.isInstalled = installedFlatpak.contains(app.id);
} else {
app.isInstalled = installedPacman.contains(app.name) || installedPacman.contains(app.id);
}
}
} catch (_) {}
}
/// Поиск по всем источникам (Pacman, AUR, Flatpak)
static Future<List<CatalogApp>> searchAll(String query) async {
final cleanQuery = query.trim();
if (cleanQuery.isEmpty) return [];
final results = await Future.wait([
_searchPacman(cleanQuery),
_searchAur(cleanQuery),
_searchFlatpak(cleanQuery),
]);
final all = <CatalogApp>[];
for (final list in results) {
all.addAll(list);
}
await checkInstalledStatus(all);
return all;
}
/// Поиск в Pacman
static Future<List<CatalogApp>> _searchPacman(String query) async {
final List<CatalogApp> list = [];
try {
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
if (res.exitCode == 0) {
final lines = res.stdout.toString().split('\n');
for (int i = 0; i < lines.length - 1; i += 2) {
final header = lines[i].trim();
if (header.isEmpty) continue;
final parts = header.split(RegExp(r'\s+'));
if (parts.isEmpty) continue;
final repoName = parts[0];
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
final version = parts.length > 1 ? parts[1] : '';
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
final iconUrl = _resolveIcon(name);
list.add(CatalogApp(
id: name,
name: name,
summary: desc,
category: repoName.split('/').first,
backend: BackendType.pacman,
version: version,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.cube_box_fill,
accentColor: const Color(0xFF1793D1),
));
if (list.length >= 25) break;
}
}
} catch (_) {}
return list;
}
/// Поиск в AUR
static Future<List<CatalogApp>> _searchAur(String query) async {
final List<CatalogApp> list = [];
try {
final aurList = await AurService.search(query);
for (final aur in aurList.take(25)) {
final iconUrl = _resolveIcon(aur.name);
list.add(CatalogApp(
id: aur.name,
name: aur.name,
summary: aur.description.isNotEmpty ? aur.description : 'Пакет AUR (${aur.numVotes} голосов)',
category: 'AUR',
backend: BackendType.aur,
version: aur.version,
votes: aur.numVotes,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.sparkles,
accentColor: const Color(0xFFFF9500),
rating: (aur.popularity > 0
? (3.5 + (aur.popularity.clamp(0, 15) / 10)).clamp(3.5, 5.0)
: 4.2),
));
}
} catch (_) {}
return list;
}
/// Поиск во Flatpak / Flathub
static Future<List<CatalogApp>> _searchFlatpak(String query) async {
final List<CatalogApp> list = [];
try {
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
if (fpRes.exitCode == 0) {
final lines = fpRes.stdout.toString().trim().split('\n');
for (final line in lines.take(20)) {
final parts = line.split('\t');
if (parts.length >= 2) {
final id = parts[0].trim();
final name = parts.length > 1 ? parts[1].trim() : id;
final version = parts.length > 2 ? parts[2].trim() : '';
final desc = parts.length > 3 ? parts[3].trim() : '';
// Flathub AppStream CDN Icon URL
final iconUrl = 'https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/$id.png';
list.add(CatalogApp(
id: id,
name: name,
summary: desc.isNotEmpty ? desc : 'Приложение Flatpak ($id)',
category: 'Flatpak',
backend: BackendType.flatpak,
version: version,
iconUrl: iconUrl,
fallbackIcon: CupertinoIcons.app_badge_fill,
accentColor: const Color(0xFF4A90E2),
));
}
}
}
} catch (_) {}
return list;
}
static String? _resolveIcon(String name) {
if (_knownIconUrls.containsKey(name)) {
return _knownIconUrls[name];
}
final lower = name.toLowerCase();
for (final entry in _knownIconUrls.entries) {
if (entry.key.contains(lower) || lower.contains(entry.key)) {
return entry.value;
}
}
return null;
}
}
+341
View File
@@ -0,0 +1,341 @@
import 'dart:io';
import 'admin_service.dart';
enum IssueSeverity { info, warning, critical }
class DoctorIssue {
final String id;
final String title;
final String description;
final IssueSeverity severity;
final bool canAutoFix;
final String fixButtonLabel;
const DoctorIssue({
required this.id,
required this.title,
required this.description,
required this.severity,
required this.canAutoFix,
this.fixButtonLabel = 'Исправить',
});
}
class PacnewFile {
final String pacnewPath;
final String originalPath;
final String fileName;
const PacnewFile({
required this.pacnewPath,
required this.originalPath,
required this.fileName,
});
Future<String> getDiff() async {
return DoctorService.getDiff(originalPath, pacnewPath);
}
Future<String> readOriginal() async {
try {
return await File(originalPath).readAsString();
} catch (_) {
return '';
}
}
Future<String> readPacnew() async {
try {
return await File(pacnewPath).readAsString();
} catch (_) {
return '';
}
}
}
class DoctorReport {
final int healthScore; // 0 - 100
final List<DoctorIssue> issues;
final List<PacnewFile> pacnewFiles;
final List<String> failedServices;
final bool isKeyringOk;
final bool isDbLocked;
final bool isStaleLock;
final String? activeMirror;
final int? mirrorPingMs;
const DoctorReport({
required this.healthScore,
required this.issues,
required this.pacnewFiles,
required this.failedServices,
required this.isKeyringOk,
required this.isDbLocked,
required this.isStaleLock,
this.activeMirror,
this.mirrorPingMs,
});
bool get hasNoIssues => issues.isEmpty;
bool get dbLockExists => isDbLocked;
bool get keyringHealthy => isKeyringOk;
List<PacnewFile> get pacnewEntries => pacnewFiles;
String get mirrorlistStatus => activeMirror != null
? 'Текущее активное зеркало: $activeMirror'
: 'Зеркала настроены по умолчанию.';
}
class DoctorService {
/// Полная диагностика состояния системы
static Future<DoctorReport> diagnose() async {
final List<DoctorIssue> issues = [];
int penalties = 0;
// 1. Проверка замка базы pacman
final lockFile = File('/var/lib/pacman/db.lck');
final isDbLocked = lockFile.existsSync();
bool isStaleLock = false;
if (isDbLocked) {
// Проверяем, запущен ли pacman
final pgrep = await Process.run('pgrep', ['-x', 'pacman']);
if (pgrep.exitCode != 0) {
isStaleLock = true;
penalties += 35;
issues.add(const DoctorIssue(
id: 'db_lock_stale',
title: 'Зависший замок базы данных pacman (db.lck)',
description:
'Файл /var/lib/pacman/db.lck существует, хотя pacman не запущен. Это блокирует любые операции установки и обновления.',
severity: IssueSeverity.critical,
canAutoFix: true,
fixButtonLabel: 'Снять блокировку',
));
} else {
issues.add(const DoctorIssue(
id: 'db_lock_active',
title: 'База pacman занята другим процессом',
description: 'В данный момент запущен системный процесс pacman.',
severity: IssueSeverity.info,
canAutoFix: false,
));
}
}
// 2. Проверка ключей PGP (Keyring)
bool isKeyringOk = true;
try {
final keyRes = await Process.run('pacman-key', ['-l']);
if (keyRes.exitCode != 0 || keyRes.stdout.toString().trim().isEmpty) {
isKeyringOk = false;
penalties += 25;
issues.add(const DoctorIssue(
id: 'keyring_corrupt',
title: 'Проблема со связкой ключей Arch Linux (Keyring)',
description:
'Связка ключей не инициализирована или повреждена. Это может вызывать ошибки "invalid or corrupted package (PGP signature)".',
severity: IssueSeverity.critical,
canAutoFix: true,
fixButtonLabel: 'Обновить ключи',
));
}
} catch (_) {
isKeyringOk = false;
}
// 3. Поиск файлов .pacnew
final List<PacnewFile> pacnewFiles = [];
try {
final pacnewRes = await Process.run('find', ['/etc', '-name', '*.pacnew']);
if (pacnewRes.exitCode == 0 && pacnewRes.stdout.toString().trim().isNotEmpty) {
final lines = pacnewRes.stdout.toString().trim().split('\n');
for (final line in lines) {
final p = line.trim();
if (p.endsWith('.pacnew')) {
final orig = p.substring(0, p.length - 7);
final name = p.split('/').last;
pacnewFiles.add(PacnewFile(
pacnewPath: p,
originalPath: orig,
fileName: name,
));
}
}
}
} catch (_) {}
if (pacnewFiles.isNotEmpty) {
penalties += (pacnewFiles.length * 8).clamp(8, 24);
issues.add(DoctorIssue(
id: 'pacnew_files',
title: 'Обнаружены неслитые конфигурации (.pacnew: ${pacnewFiles.length} шт.)',
description:
'При обновлениях pacman сохранил новые версии конфигурационных файлов рядом с вашими. Рекомендуется сравнить и объединить их.',
severity: IssueSeverity.warning,
canAutoFix: false,
fixButtonLabel: 'Обзор файлов',
));
}
// 4. Проверка упавших служб systemd
final List<String> failedServices = [];
try {
final sysRes = await Process.run('systemctl', ['--failed', '--no-legend', '--plain']);
if (sysRes.exitCode == 0 && sysRes.stdout.toString().trim().isNotEmpty) {
final lines = sysRes.stdout.toString().trim().split('\n');
for (final line in lines) {
final parts = line.trim().split(RegExp(r'\s+'));
if (parts.isNotEmpty && parts.first.isNotEmpty) {
failedServices.add(parts.first);
}
}
}
} catch (_) {}
if (failedServices.isNotEmpty) {
penalties += (failedServices.length * 10).clamp(10, 30);
issues.add(DoctorIssue(
id: 'failed_services',
title: 'Сбойные службы systemd (${failedServices.length} шт.)',
description:
'В системе есть службы с ошибкой запуска: ${failedServices.take(3).join(", ")}${failedServices.length > 3 ? "..." : ""}.',
severity: IssueSeverity.warning,
canAutoFix: false,
));
}
// 5. Проверка текущего зеркала
String? activeMirror;
int? pingMs;
try {
final mirrorFile = File('/etc/pacman.d/mirrorlist');
if (mirrorFile.existsSync()) {
final lines = mirrorFile.readAsLinesSync();
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.startsWith('Server = ')) {
activeMirror = trimmed.substring(9).split('\$').first;
break;
}
}
}
} catch (_) {}
final healthScore = (100 - penalties).clamp(15, 100);
return DoctorReport(
healthScore: healthScore,
issues: issues,
pacnewFiles: pacnewFiles,
failedServices: failedServices,
isKeyringOk: isKeyringOk,
isDbLocked: isDbLocked,
isStaleLock: isStaleLock,
activeMirror: activeMirror,
mirrorPingMs: pingMs,
);
}
/// Снятие зависшей блокировки db.lck
static Future<bool> removeDbLock({String? password}) async {
try {
final proc = await AdminService.runPrivileged(password, 'rm', ['-f', '/var/lib/pacman/db.lck']);
return (await proc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Обновление связки ключей pacman-key
static Future<bool> fixKeyring({String? password}) async {
try {
final p1 = await AdminService.runPrivileged(password, 'pacman-key', ['--init']);
await p1.exitCode;
final p2 = await AdminService.runPrivileged(password, 'pacman-key', ['--populate', 'archlinux']);
await p2.exitCode;
final p3 = await AdminService.runPrivileged(password, 'pacman', ['-Sy', 'archlinux-keyring', '--noconfirm']);
return (await p3.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Перезапуск упавших служб systemd
static Future<bool> restartFailedServices(List<String> services, {String? password}) async {
if (services.isEmpty) return true;
try {
final proc = await AdminService.runPrivileged(password, 'systemctl', ['reset-failed', ...services]);
await proc.exitCode;
final p2 = await AdminService.runPrivileged(password, 'systemctl', ['restart', ...services]);
return (await p2.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Оптимизация списка зеркал через Reflector
static Future<bool> optimizeMirrors({String? password, String? country}) async {
try {
final List<String> args = [
'--latest', '10',
'--protocol', 'https',
'--sort', 'rate',
'--save', '/etc/pacman.d/mirrorlist',
];
if (country != null && country.isNotEmpty) {
args.addAll(['--country', country]);
}
final proc = await AdminService.runPrivileged(password, 'reflector', args);
return (await proc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Получение текстового diff между оригиналом и .pacnew
static Future<String> getDiff(String originalPath, String pacnewPath) async {
try {
final res = await Process.run('diff', ['-u', originalPath, pacnewPath]);
final output = res.stdout.toString().trim();
return output.isNotEmpty ? output : 'Файлы идентичны по содержимому.';
} catch (e) {
return 'Не удалось получить различия: $e';
}
}
/// Оставить старый файл конфигурации, удалив .pacnew
static Future<bool> resolvePacnewKeepOld(String pacnewPath, {String? password}) async {
try {
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
return (await rmProc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Заменить старый файл новым .pacnew и удалить .pacnew
static Future<bool> resolvePacnewReplaceWithNew(String originalPath, String pacnewPath, {String? password}) async {
try {
final cpProc = await AdminService.runPrivileged(password, 'cp', ['-f', pacnewPath, originalPath]);
if (await cpProc.exitCode != 0) return false;
final rmProc = await AdminService.runPrivileged(password, 'rm', ['-f', pacnewPath]);
return (await rmProc.exitCode) == 0;
} catch (_) {
return false;
}
}
/// Обработка файла .pacnew: удаление или замена
static Future<bool> resolvePacnew(
PacnewFile file, {
required bool replaceWithNew,
required String? password,
}) async {
if (replaceWithNew) {
return resolvePacnewReplaceWithNew(file.originalPath, file.pacnewPath, password: password);
} else {
return resolvePacnewKeepOld(file.pacnewPath, password: password);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'dart:io';
import 'admin_service.dart';
enum SnapshotEngine {
snapper,
timeshift,
none;
String get displayName {
switch (this) {
case SnapshotEngine.snapper:
return 'Snapper (Btrfs)';
case SnapshotEngine.timeshift:
return 'Timeshift';
case SnapshotEngine.none:
return 'Не обнаружен';
}
}
}
class SnapshotResult {
final bool success;
final String message;
const SnapshotResult(this.success, this.message);
}
class SnapshotService {
static SnapshotEngine? _cachedEngine;
static Future<SnapshotEngine> detectEngine() async {
if (_cachedEngine != null) return _cachedEngine!;
try {
final snapperRes = await Process.run('which', ['snapper']);
if (snapperRes.exitCode == 0 && snapperRes.stdout.toString().trim().isNotEmpty) {
_cachedEngine = SnapshotEngine.snapper;
return _cachedEngine!;
}
} catch (_) {}
try {
final timeshiftRes = await Process.run('which', ['timeshift']);
if (timeshiftRes.exitCode == 0 && timeshiftRes.stdout.toString().trim().isNotEmpty) {
_cachedEngine = SnapshotEngine.timeshift;
return _cachedEngine!;
}
} catch (_) {}
_cachedEngine = SnapshotEngine.none;
return _cachedEngine!;
}
static Future<String> getEngineDescription() async {
final engine = await detectEngine();
switch (engine) {
case SnapshotEngine.snapper:
return 'Snapper (обнаружена Btrfs-конфигурация корня /)';
case SnapshotEngine.timeshift:
return 'Timeshift (утилита создания точек восстановления)';
case SnapshotEngine.none:
return 'Утилиты снимков не найдены (установите snapper или timeshift)';
}
}
/// Создание снимка системы перед установкой обновлений
static Future<SnapshotResult> createPreUpdateSnapshot(
String? password, {
List<String>? packages,
String? description,
}) async {
final engine = await detectEngine();
if (engine == SnapshotEngine.none) {
return const SnapshotResult(false, 'Утилиты snapper или timeshift не обнаружены в системе');
}
final comment = description ?? (packages != null && packages.isNotEmpty
? 'Forge: обновление ${packages.length} пакетов (${packages.take(3).join(", ")}${packages.length > 3 ? "..." : ""})'
: 'Forge: обновление системы');
try {
if (engine == SnapshotEngine.snapper) {
final proc = await AdminService.runPrivileged(
password,
'snapper',
['create', '-d', comment],
);
final code = await proc.exitCode;
if (code == 0) {
return SnapshotResult(true, 'Снимок Snapper успешно создан: "$comment"');
} else {
return SnapshotResult(false, 'Ошибка Snapper (код $code)');
}
} else if (engine == SnapshotEngine.timeshift) {
final proc = await AdminService.runPrivileged(
password,
'timeshift',
['--create', '--comments', comment, '--tags', 'O'],
);
final code = await proc.exitCode;
if (code == 0) {
return SnapshotResult(true, 'Снимок Timeshift успешно создан: "$comment"');
} else {
return SnapshotResult(false, 'Ошибка Timeshift (код $code)');
}
}
} catch (e) {
return SnapshotResult(false, 'Исключение при создании снимка: $e');
}
return const SnapshotResult(false, 'Не удалось создать снимок');
}
}
+319
View File
@@ -0,0 +1,319 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import '../rust/api/packages.dart';
import '../rust/frb_generated.dart';
import '../services/app_badge_controller.dart';
import '../services/app_settings_service.dart';
import 'theme/window_theme_style.dart';
import 'screens/cleaner_screen.dart';
import 'screens/discover_screen.dart';
import 'screens/doctor_screen.dart';
import 'screens/history_screen.dart';
import 'screens/installed_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/starter_packs_screen.dart';
import 'screens/updates_screen.dart';
import 'widgets/package_inspector.dart';
import 'widgets/spotlight_search_dialog.dart';
class MainLayout extends StatefulWidget {
const MainLayout({super.key});
@override
State<MainLayout> createState() => _MainLayoutState();
}
class _MainLayoutState extends State<MainLayout> {
int _pageIndex = 0;
@override
void initState() {
super.initState();
_performInitialBadgeScan();
}
Future<void> _performInitialBadgeScan() async {
try {
final updates = await RustBridgeApi.checkUpdates();
AppBadgeController.instance.setUpdatesCount(updates.length);
} catch (_) {}
try {
final scan = await RustBridgeApi.scanCleaner();
AppBadgeController.instance.setCleanerBytes(scan.totalReclaimableBytes);
} catch (_) {}
}
void _onInspectPackage(String name, BackendType backend) {
PackageInspectorSheet.show(
context,
packageName: name,
backend: backend,
);
}
void _openSpotlight() {
SpotlightSearchDialog.show(
context,
onSelectPackage: (name, backend) {
_onInspectPackage(name, backend);
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyK, control: true): _openSpotlight,
const SingleActivator(LogicalKeyboardKey.keyK, meta: true): _openSpotlight,
},
child: Focus(
autofocus: true,
child: MacosWindow(
sidebar: Sidebar(
topOffset: 0.0, // Убираем пустой отступ macOS traffic lights на Linux
minWidth: 200,
startWidth: 220,
maxWidth: 260,
top: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: macosTheme.primaryColor,
borderRadius: BorderRadius.circular(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde ? 4 : 8,
),
boxShadow: [
BoxShadow(
color: macosTheme.primaryColor.withValues(alpha: 0.35),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: MacosIcon(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
? CupertinoIcons.gear_alt_fill
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
? CupertinoIcons.app_badge_fill
: CupertinoIcons.cube_box_fill,
color: CupertinoColors.white,
size: 17,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Forge',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
Text(
AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.kde
? 'KDE Breeze Edition'
: AppSettingsService.instance.effectiveWindowThemeStyle == WindowThemeStyle.gnome
? 'GNOME Adwaita'
: 'macOS Cupertino',
style: const TextStyle(
fontSize: 10.5,
color: CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 12),
// Spotlight Search Trigger Button
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: _openSpotlight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6.5),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.07),
),
),
child: const Row(
children: [
MacosIcon(
CupertinoIcons.search,
size: 13,
color: CupertinoColors.systemGrey,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Поиск пакетов...',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
),
Text(
'⌘K',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemGrey,
),
),
],
),
),
),
],
),
),
builder: (context, scrollController) {
return ListenableBuilder(
listenable: AppBadgeController.instance,
builder: (context, _) {
final badge = AppBadgeController.instance;
return SidebarItems(
currentIndex: _pageIndex,
onChanged: (index) {
setState(() {
_pageIndex = index;
});
},
scrollController: scrollController,
itemSize: SidebarItemSize.large,
items: [
const SidebarItem(
label: Text('УПРАВЛЕНИЕ'),
section: true,
),
SidebarItem(
leading: const MacosIcon(CupertinoIcons.arrow_down_circle_fill),
label: const Text('Обновления'),
trailing: badge.updatesCount > 0
? Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${badge.updatesCount}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
)
: null,
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.square_stack_3d_up_fill),
label: Text('Установленные'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.compass_fill),
label: Text('Каталог'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.square_grid_2x2_fill),
label: Text('Наборы ПО'),
),
const SidebarItem(
label: Text('СИСТЕМА И ОПТИМИЗАЦИЯ'),
section: true,
),
SidebarItem(
leading: const MacosIcon(CupertinoIcons.sparkles),
label: const Text('Очистка'),
trailing: badge.cleanerBytes > 0
? Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: CupertinoColors.systemOrange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
AppBadgeController.formatBytes(badge.cleanerBytes),
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemOrange,
),
),
)
: null,
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.bandage_fill),
label: Text('Диагностика'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.clock_fill),
label: Text('История'),
),
const SidebarItem(
leading: MacosIcon(CupertinoIcons.gear_alt_fill),
label: Text('Настройки'),
),
],
);
},
);
},
bottom: MacosListTile(
leading: const MacosIcon(
CupertinoIcons.command,
color: CupertinoColors.systemGrey,
),
title: const Text(
'Arch / Flatpak / AUR',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
),
),
child: IndexedStack(
index: _pageIndex.clamp(0, 7),
children: [
UpdatesScreen(onInspectPackage: _onInspectPackage),
InstalledScreen(onInspectPackage: _onInspectPackage),
DiscoverScreen(onInspectPackage: _onInspectPackage),
const StarterPacksScreen(),
const CleanerScreen(),
const DoctorScreen(),
const HistoryScreen(),
const SettingsScreen(),
],
),
),
),
);
}
}
+529
View File
@@ -0,0 +1,529 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
import '../../services/admin_service.dart';
import '../../services/app_badge_controller.dart';
import '../widgets/friendly_term.dart';
import '../widgets/storage_breakdown_bar.dart';
class CleanerScreen extends StatefulWidget {
const CleanerScreen({super.key});
@override
State<CleanerScreen> createState() => _CleanerScreenState();
}
class _CleanerScreenState extends State<CleanerScreen> {
CleanerScanResult? _scanResult;
bool _isScanning = true;
bool _isCleaning = false;
bool _cleanCache = true;
bool _cleanOrphans = true;
bool _cleanFlatpak = true;
bool _cleanJournal = true;
ProgressEvent? _currentProgress;
final List<String> _logLines = [];
StreamSubscription<ProgressEvent>? _cleanSub;
final ScrollController _logScrollController = ScrollController();
@override
void initState() {
super.initState();
_scan();
}
@override
void dispose() {
_cleanSub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _scan() async {
setState(() => _isScanning = true);
try {
final res = await RustBridgeApi.scanCleaner();
if (mounted) {
setState(() {
_scanResult = res;
_isScanning = false;
});
AppBadgeController.instance.setCleanerBytes(res.totalReclaimableBytes);
}
} catch (_) {
if (mounted) setState(() => _isScanning = false);
}
}
Future<void> _startClean() async {
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
if (totalBytes <= 0) return;
// Запрашиваем пароль суперпользователя
final password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для очистки кэша пакетов pacman, удаления неиспользуемых пакетов-сирот и сжатия системных логов требуются права суперпользователя.',
);
if (password == null) {
// Отменено пользователем
return;
}
setState(() {
_isCleaning = true;
_logLines.clear();
_currentProgress = null;
});
final stream = RustBridgeApi.runCleaner(
password: password,
cleanCache: _cleanCache,
cleanOrphans: _cleanOrphans,
cleanFlatpakUnused: _cleanFlatpak,
cleanJournal: _cleanJournal,
);
_cleanSub?.cancel();
_cleanSub = stream.listen(
(event) {
setState(() {
_currentProgress = event;
if (event.logLine != null) {
_logLines.add(event.logLine!);
} else if (event.message.isNotEmpty) {
_logLines.add(event.message);
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
setState(() => _isCleaning = false);
_scan();
}
},
onError: (e) {
setState(() {
_isCleaning = false;
_logLines.add('[ERROR] $e');
});
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Очистка системы'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Повторное сканирование',
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
showLabel: false,
tooltipMessage: 'Сканировать диск',
onPressed: _isCleaning ? null : _scan,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isScanning) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 18),
SizedBox(height: 16),
Text(
'Анализ дискового пространства и кэшей...',
style: TextStyle(color: CupertinoColors.systemGrey),
),
],
),
);
}
final totalBytes = _scanResult?.totalReclaimableBytes ?? 0;
final isZero = totalBytes == 0;
final totalStr = CleanerScanResult.formatBytes(totalBytes);
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
// CleanMyMac Hero Banner
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isZero
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: const Color(0xFF0A84FF).withValues(alpha: 0.15),
border: Border.all(
color: isZero
? CupertinoColors.systemGreen.withValues(alpha: 0.4)
: const Color(0xFF0A84FF).withValues(alpha: 0.4),
width: 2,
),
),
child: Center(
child: MacosIcon(
isZero ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.sparkles,
color: isZero ? CupertinoColors.systemGreen : const Color(0xFF0A84FF),
size: 34,
),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isZero
? 'Система полностью очищена'
: 'Доступно для очистки: $totalStr',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const SizedBox(height: 4),
Text(
isZero
? 'Дисковое пространство свободно от пакетов-сирот, старого кэша и избыточных логов.'
: 'Удаление старых архивов пакетов, пакетов-сирот и сжатие журналов системы',
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 16),
PushButton(
controlSize: ControlSize.large,
onPressed: (_isCleaning || isZero) ? null : _startClean,
child: Row(
children: [
if (_isCleaning) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 8),
] else ...[
MacosIcon(
isZero ? CupertinoIcons.checkmark_alt : CupertinoIcons.trash_fill,
size: 14,
),
const SizedBox(width: 8),
],
Text(_isCleaning
? 'Очистка...'
: isZero
? 'Очищено'
: 'Очистить ($totalStr)'),
],
),
),
],
),
),
const SizedBox(height: 24),
// Live Progress
if (_isCleaning || _logLines.isNotEmpty) ...[
_buildLiveProgressPanel(macosTheme),
const SizedBox(height: 24),
],
// macOS Storage Breakdown Bar
StorageBreakdownBar(
pacmanCacheBytes: _scanResult?.pacmanCacheBytes ?? 0,
journalBytes: _scanResult?.journalBytes ?? 0,
),
const SizedBox(height: 24),
const Text(
'КАТЕГОРИИ ОЧИСТКИ',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 10),
// Card 1: Pacman Cache
_buildCleanOptionCard(
title: 'Кэш пакетов Pacman',
friendlyTermKey: 'cache',
subtitle:
'Хранилище скачанных .pkg.tar.zst файлов. Утилита paccache очистит всё, кроме двух последних версий.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.pacmanCacheBytes ?? 0),
icon: CupertinoIcons.archivebox_fill,
iconColor: const Color(0xFF1793D1),
value: _cleanCache,
onChanged: (v) => setState(() => _cleanCache = v),
),
// Card 2: Orphans
_buildCleanOptionCard(
title: 'Пакеты-сироты (Orphans)',
friendlyTermKey: 'orphans',
subtitle:
'Пакеты (${_scanResult?.orphanPackages.length ?? 0} шт.), установленные как зависимости, но больше никем не используемые.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.orphanBytes ?? 0),
icon: CupertinoIcons.person_crop_circle_badge_xmark,
iconColor: CupertinoColors.systemOrange,
value: _cleanOrphans,
onChanged: (v) => setState(() => _cleanOrphans = v),
),
// Card 3: Flatpak Unused
_buildCleanOptionCard(
title: 'Неиспользуемые рантаймы Flatpak',
subtitle:
'Старые версии базовых библиотек и SDK Flathub, оставшиеся после обновления приложений.',
sizeText: 'По расписанию',
icon: CupertinoIcons.app_badge_fill,
iconColor: const Color(0xFF4A90E2),
value: _cleanFlatpak,
onChanged: (v) => setState(() => _cleanFlatpak = v),
),
// Card 4: Journalctl
_buildCleanOptionCard(
title: 'Журналы systemd (Логи)',
subtitle:
'Архивные системные логи служб и ядра. Размер будет безопасно ограничен до 100 МБ.',
sizeText: CleanerScanResult.formatBytes(
_scanResult?.journalBytes ?? 0),
icon: CupertinoIcons.doc_text_fill,
iconColor: CupertinoColors.systemPurple,
value: _cleanJournal,
onChanged: (v) => setState(() => _cleanJournal = v),
),
],
);
},
),
],
);
}
Widget _buildCleanOptionCard({
required String title,
String? friendlyTermKey,
required String subtitle,
required String sizeText,
required IconData icon,
required Color iconColor,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
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: [
Row(
children: [
if (friendlyTermKey != null)
FriendlyTerm(
text: title,
termKey: friendlyTermKey,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
)
else
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const Spacer(),
Text(
sizeText,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Color(0xFF0A84FF),
),
),
],
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 16),
MacosSwitch(
value: value,
onChanged: onChanged,
),
],
),
);
}
Widget _buildLiveProgressPanel(MacosThemeData theme) {
final pct = _currentProgress?.percentage ?? 0.0;
final message = _currentProgress?.message ?? 'Инициализация...';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E20),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isCleaning) ...[
const ProgressCircle(radius: 7),
const SizedBox(width: 8),
],
Text(
message,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
Text(
'${(pct * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: theme.primaryColor,
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ProgressBar(
value: (pct * 100).clamp(0.0, 100.0),
height: 6,
),
),
const SizedBox(height: 14),
Container(
height: 110,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF14171A),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logLines.length,
itemBuilder: (context, idx) {
final line = _logLines[idx];
return Text(
line,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.4,
color: Color(0xFF34D399),
),
);
},
),
),
],
),
);
}
}
+663
View File
@@ -0,0 +1,663 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/aur_service.dart';
import '../../services/batch_install_service.dart';
import '../../services/catalog_service.dart';
import '../widgets/app_icon_image.dart';
import '../widgets/install_queue_bar.dart';
class DiscoverScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const DiscoverScreen({super.key, this.onInspectPackage});
@override
State<DiscoverScreen> createState() => _DiscoverScreenState();
}
class _DiscoverScreenState extends State<DiscoverScreen> {
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
bool _isLoading = true;
bool _isSearching = false;
String _searchQuery = '';
String _selectedSource = 'Все';
String? _aurHelper;
final List<CatalogApp> _featuredApps = CatalogService.getFeaturedApps();
final Map<String, List<CatalogApp>> _curatedCategories = CatalogService.getCuratedCategories();
List<CatalogApp> _searchResults = [];
final List<String> _sourceFilters = ['Все', 'Pacman', 'AUR', 'Flatpak'];
@override
void initState() {
super.initState();
_initCatalog();
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _initCatalog() async {
setState(() => _isLoading = true);
final allCurated = [
..._featuredApps,
..._curatedCategories.values.expand((element) => element),
];
await CatalogService.checkInstalledStatus(allCurated);
final helper = await AurService.getHelper();
if (mounted) {
setState(() {
_aurHelper = helper;
_isLoading = false;
});
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
final trimmed = query.trim();
if (trimmed.isEmpty) {
setState(() {
_searchQuery = '';
_isSearching = false;
_searchResults.clear();
});
return;
}
setState(() {
_searchQuery = trimmed;
_isSearching = true;
});
_searchDebounce = Timer(const Duration(milliseconds: 320), () {
_performSearch(trimmed);
});
}
Future<void> _performSearch(String query) async {
final results = await CatalogService.searchAll(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
titleWidth: 260,
title: const Text(
'Каталог приложений',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 280,
child: MacosTextField(
controller: _searchController,
placeholder: 'Поиск (Pacman, AUR, Flatpak)...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
suffix: _searchQuery.isNotEmpty
? CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
child: const MacosIcon(
CupertinoIcons.clear_circled_solid,
size: 14,
color: CupertinoColors.systemGrey,
),
)
: null,
onChanged: _onSearchChanged,
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 20));
}
return Stack(
children: [
Positioned.fill(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(28, 20, 28, 90),
children: [
// Mode 1: Active Search Results
if (_searchQuery.isNotEmpty) ...[
_buildSearchHeader(macosTheme),
const SizedBox(height: 16),
if (_isSearching)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: ProgressCircle(),
),
)
else if (_searchResults.isEmpty)
_buildEmptyResults()
else
_buildSearchResultsGrid(macosTheme),
] else ...[
// Mode 2: App Store Storefront
_buildHeroBanner(macosTheme),
const SizedBox(height: 28),
// Section 1: Featured Apps
_buildSectionHeader('ИЗБРАННОЕ И РЕКОМЕНДАЦИИ', 'Популярные приложения с быстрой установкой'),
const SizedBox(height: 14),
_buildAppsGrid(_featuredApps, macosTheme),
const SizedBox(height: 32),
// Section 2: Curated Categories
..._curatedCategories.entries.map((cat) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(cat.key.toUpperCase(), 'Подборка проверенного ПО'),
const SizedBox(height: 14),
_buildAppsGrid(cat.value, macosTheme),
const SizedBox(height: 32),
],
);
}),
],
],
),
),
// Floating Queue Cart Bar at Bottom
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: InstallQueueBar(),
),
],
);
},
),
],
);
}
Widget _buildSearchHeader(MacosThemeData macosTheme) {
final filtered = _getFilteredSearchResults();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Результаты поиска по запросу «$_searchQuery»',
style: macosTheme.typography.title1.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text(
'Найдено: ${filtered.length}',
style: const TextStyle(fontSize: 12.5, color: CupertinoColors.systemGrey),
),
],
),
const SizedBox(height: 12),
// Filter pills: Все, Pacman, AUR, Flatpak
Row(
children: _sourceFilters.map((source) {
final isSelected = _selectedSource == source;
int count = 0;
if (source == 'Все') {
count = _searchResults.length;
} else if (source == 'Pacman') {
count = _searchResults.where((a) => a.backend == BackendType.pacman).length;
} else if (source == 'AUR') {
count = _searchResults.where((a) => a.backend == BackendType.aur).length;
} else if (source == 'Flatpak') {
count = _searchResults.where((a) => a.backend == BackendType.flatpak).length;
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => setState(() => _selectedSource = source),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0A84FF)
: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? const Color(0xFF0A84FF)
: macosTheme.dividerColor,
width: 0.8,
),
),
child: Text(
source == 'AUR' && _aurHelper != null
? 'AUR ($_aurHelper) ($count)'
: '$source ($count)',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: isSelected ? CupertinoColors.white : CupertinoColors.systemGrey,
),
),
),
),
);
}).toList(),
),
],
);
}
List<CatalogApp> _getFilteredSearchResults() {
if (_selectedSource == 'Все') return _searchResults;
if (_selectedSource == 'Pacman') {
return _searchResults.where((a) => a.backend == BackendType.pacman).toList();
}
if (_selectedSource == 'AUR') {
return _searchResults.where((a) => a.backend == BackendType.aur).toList();
}
if (_selectedSource == 'Flatpak') {
return _searchResults.where((a) => a.backend == BackendType.flatpak).toList();
}
return _searchResults;
}
Widget _buildEmptyResults() {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
const MacosIcon(CupertinoIcons.search, size: 44, color: CupertinoColors.systemGrey),
const SizedBox(height: 12),
Text(
'По запросу «$_searchQuery» ничего не найдено',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
const Text(
'Попробуйте изменить поисковый запрос или переключить фильтр источников.',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
],
),
),
);
}
Widget _buildSearchResultsGrid(MacosThemeData macosTheme) {
final list = _getFilteredSearchResults();
return _buildAppsGrid(list, macosTheme);
}
Widget _buildHeroBanner(MacosThemeData macosTheme) {
final heroApp = _featuredApps.first;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0F2027),
Color(0xFF203A43),
Color(0xFF2C5364),
],
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.3),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
AppIconImage(
iconUrl: heroApp.iconUrl,
fallbackIcon: heroApp.fallbackIcon,
accentColor: heroApp.accentColor,
size: 76,
),
const SizedBox(width: 22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.6)),
),
child: const Text(
'ВЫБОР РЕДАКЦИИ',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: Color(0xFF64D2FF),
),
),
),
const SizedBox(width: 8),
_buildBackendPill(heroApp.backend),
],
),
const SizedBox(height: 6),
Text(
heroApp.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
color: CupertinoColors.white,
),
),
const SizedBox(height: 4),
Text(
heroApp.summary,
style: const TextStyle(
fontSize: 13,
color: Color(0xFFD1D1D6),
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 16),
ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final inQueue = BatchInstallService.instance.contains(heroApp);
return PushButton(
controlSize: ControlSize.large,
secondary: inQueue,
onPressed: heroApp.isInstalled
? null
: () => BatchInstallService.instance.toggle(heroApp),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(
heroApp.isInstalled
? CupertinoIcons.check_mark_circled_solid
: (inQueue ? CupertinoIcons.check_mark : CupertinoIcons.cart_badge_plus),
size: 15,
),
const SizedBox(width: 6),
Text(
heroApp.isInstalled
? 'Установлено'
: (inQueue ? 'В очереди' : '+ В очередь'),
),
],
),
);
},
),
],
),
);
}
Widget _buildSectionHeader(String title, String subtitle) {
return Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
],
),
],
);
}
Widget _buildAppsGrid(List<CatalogApp> apps, MacosThemeData macosTheme) {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 950 ? 3 : (constraints.maxWidth > 650 ? 2 : 1);
final itemWidth = (constraints.maxWidth - (crossAxisCount - 1) * 14) / crossAxisCount;
return Wrap(
spacing: 14,
runSpacing: 14,
children: apps.map((app) {
return SizedBox(
width: itemWidth,
child: _buildAppCard(app, macosTheme),
);
}).toList(),
);
},
);
}
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.all(14),
onPressed: () => widget.onInspectPackage?.call(app.id, app.backend),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIconImage(
iconUrl: app.iconUrl,
fallbackIcon: app.fallbackIcon,
accentColor: app.accentColor,
size: 50,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
app.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
_buildBackendPill(app.backend),
],
),
const SizedBox(height: 3),
Text(
app.summary,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
// Rating stars
const Icon(CupertinoIcons.star_fill, size: 11, color: Color(0xFFFFCC00)),
const SizedBox(width: 3),
Text(
app.rating.toStringAsFixed(1),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: CupertinoColors.systemGrey),
),
if (app.votes != null) ...[
const SizedBox(width: 6),
Text(
'(${app.votes} 👍)',
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
),
],
const Spacer(),
// Add to Cart / Queue Button
ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final inQueue = BatchInstallService.instance.contains(app);
return PushButton(
controlSize: ControlSize.small,
secondary: inQueue,
onPressed: app.isInstalled
? null
: () => BatchInstallService.instance.toggle(app),
child: Text(
app.isInstalled
? 'Установлено'
: (inQueue ? '✓ В очереди' : '+ В очередь'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: app.isInstalled ? CupertinoColors.systemGreen : null,
),
),
);
},
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBackendPill(BackendType backend) {
Color bg;
Color fg;
String text;
switch (backend) {
case BackendType.pacman:
bg = const Color(0xFF1793D1).withValues(alpha: 0.15);
fg = const Color(0xFF1793D1);
text = 'PACMAN';
break;
case BackendType.aur:
bg = const Color(0xFFFF9500).withValues(alpha: 0.15);
fg = const Color(0xFFFF9500);
text = _aurHelper != null ? 'AUR ($_aurHelper)' : 'AUR';
break;
case BackendType.flatpak:
bg = const Color(0xFF4A90E2).withValues(alpha: 0.15);
fg = const Color(0xFF4A90E2);
text = 'FLATPAK';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5.5, vertical: 1.5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(5),
),
child: Text(
text,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: fg,
),
),
);
}
}
+709
View File
@@ -0,0 +1,709 @@
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/doctor_service.dart';
import '../widgets/friendly_term.dart';
class DoctorScreen extends StatefulWidget {
const DoctorScreen({super.key});
@override
State<DoctorScreen> createState() => _DoctorScreenState();
}
class _DoctorScreenState extends State<DoctorScreen> {
bool _isLoading = true;
bool _isActionRunning = false;
String? _actionTitle;
final List<String> _actionLogs = [];
final ScrollController _logScrollController = ScrollController();
DoctorReport? _report;
@override
void initState() {
super.initState();
_runDiagnostics();
}
@override
void dispose() {
_logScrollController.dispose();
super.dispose();
}
Future<void> _runDiagnostics() async {
setState(() => _isLoading = true);
try {
final report = await DoctorService.diagnose();
if (mounted) {
setState(() {
_report = report;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
void _scrollLogToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
Future<void> _executeAction(String title, Future<bool> Function(String password) action) async {
final password = await AdminService.promptPassword(
context,
title: title,
reason: 'Для выполнения операций диагностики и восстановления требуются права администратора.',
);
if (password == null) return;
setState(() {
_isActionRunning = true;
_actionTitle = title;
_actionLogs.clear();
_actionLogs.add('⚙️ Запуск: $title...');
});
_scrollLogToBottom();
try {
final success = await action(password);
if (mounted) {
setState(() {
if (success) {
_actionLogs.add('✅ Операция успешно выполнена!');
} else {
_actionLogs.add('⚠️ Операция завершилась с ошибками или предупреждениями.');
}
});
_scrollLogToBottom();
}
} catch (e) {
if (mounted) {
setState(() => _actionLogs.add('❌ Ошибка: $e'));
_scrollLogToBottom();
}
} finally {
await _runDiagnostics();
if (mounted) {
setState(() {
_isActionRunning = false;
_actionTitle = null;
});
}
}
}
void _showDiffDialog(PacnewFile entry) async {
final diff = await entry.getDiff();
if (!mounted) return;
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (sheetContext) {
return MacosSheet(
child: Container(
width: 760,
height: 520,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(CupertinoIcons.doc_plaintext, size: 22, color: Color(0xFF0A84FF)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Различия в конфигурации: ${entry.fileName}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
entry.originalPath,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: CupertinoColors.systemGrey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Text('Легенда: ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
Text('- Удаленные/старые строки', style: TextStyle(color: CupertinoColors.systemRed, fontSize: 11)),
SizedBox(width: 14),
Text('+ Новые строки из .pacnew', style: TextStyle(color: CupertinoColors.systemGreen, fontSize: 11)),
],
),
),
const SizedBox(height: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: SelectableText(
diff.isEmpty ? 'Файлы идентичны или не содержат текстовых различий.' : diff,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: Color(0xFFE5E5EA),
height: 1.4,
),
),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () {
Navigator.of(sheetContext).pop();
_executeAction(
'Удаление .pacnew (${entry.fileName})',
(pwd) => DoctorService.resolvePacnewKeepOld(entry.pacnewPath, password: pwd),
);
},
child: const Text('Оставить старый (.pacnew удалить)'),
),
const SizedBox(width: 12),
PushButton(
controlSize: ControlSize.regular,
onPressed: () {
Navigator.of(sheetContext).pop();
_executeAction(
'Применение .pacnew (${entry.fileName})',
(pwd) => DoctorService.resolvePacnewReplaceWithNew(entry.originalPath, entry.pacnewPath, password: pwd),
);
},
child: const Text('Заменить на новый .pacnew'),
),
],
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text(
'Диагностика',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Проверить снова',
icon: const MacosIcon(CupertinoIcons.refresh),
showLabel: false,
tooltipMessage: 'Запустить повторное сканирование системы',
onPressed: _isLoading || _isActionRunning ? null : _runDiagnostics,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle());
}
final report = _report;
if (report == null) {
return const Center(child: Text('Не удалось получить отчёт диагностики'));
}
final isHealthy = report.hasNoIssues;
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
children: [
// Header & Health Score
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: isHealthy
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemOrange.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Center(
child: MacosIcon(
isHealthy ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.exclamationmark_triangle_fill,
color: isHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
size: 30,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isHealthy ? 'Система в отличном состоянии' : 'Обнаружены вопросы, требующие внимания',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
const Text(
'Arch Doctor сканирует целостность базы pacman, блокировки, состояние GPG-ключей, '
'упавшие службы systemd и неслитые конфигурации .pacnew.',
style: TextStyle(
fontSize: 13,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: _isActionRunning ? null : _runDiagnostics,
child: const Row(
children: [
MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
SizedBox(width: 6),
Text('Сканировать'),
],
),
),
],
),
const SizedBox(height: 24),
if (_isActionRunning) ...[
_buildRunningActionBanner(macosTheme),
const SizedBox(height: 24),
],
// Check 1: Pacman DB Lock
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.lock_fill,
iconColor: report.dbLockExists ? CupertinoColors.systemRed : CupertinoColors.systemGreen,
title: 'Файл блокировки базы Pacman (db.lck)',
subtitle: report.dbLockExists
? 'Обнаружен /var/lib/pacman/db.lck! Pacman может быть зависшим или прерванным.'
: 'База данных pacman свободна, блокировки отсутствуют.',
isAlert: report.dbLockExists,
actions: report.dbLockExists
? [
PushButton(
controlSize: ControlSize.small,
onPressed: _isActionRunning
? null
: () => _executeAction('Снятие блокировки pacman (db.lck)', (pwd) => DoctorService.removeDbLock(password: pwd)),
child: const Text('Снять блокировку (удалить db.lck)'),
),
]
: null,
),
const SizedBox(height: 14),
// Check 2: Keyring
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.shield_fill,
iconColor: report.keyringHealthy ? CupertinoColors.systemGreen : CupertinoColors.systemOrange,
title: 'Связка GPG-ключей (Keyring)',
friendlyTermKey: 'keyring',
subtitle: report.keyringHealthy
? 'Ключи доверия Arch Linux инициализированы и готовы к проверке сигнатур пакетов.'
: 'Связка ключей может быть устаревшей, что приводит к ошибкам проверки подписей "invalid or corrupted package".',
isAlert: !report.keyringHealthy,
actions: [
PushButton(
controlSize: ControlSize.small,
secondary: report.keyringHealthy,
onPressed: _isActionRunning
? null
: () => _executeAction('Инициализация и обновление GPG-ключей', (pwd) => DoctorService.fixKeyring(password: pwd)),
child: const Text('Пересобрать связку ключей (pacman-key)'),
),
],
),
const SizedBox(height: 14),
// Check 3: Failed Services
_buildCheckCard(
macosTheme: macosTheme,
icon: CupertinoIcons.gear_alt_fill,
iconColor: report.failedServices.isEmpty ? CupertinoColors.systemGreen : CupertinoColors.systemRed,
title: 'Службы Systemd',
subtitle: report.failedServices.isEmpty
? 'Все активные службы работают штатно, упавших сервисов (failed) нет.'
: 'Обнаружено ${report.failedServices.length} упавших служб: ${report.failedServices.join(', ')}',
isAlert: report.failedServices.isNotEmpty,
actions: report.failedServices.isNotEmpty
? [
PushButton(
controlSize: ControlSize.small,
onPressed: _isActionRunning
? null
: () => _executeAction('Перезапуск упавших служб', (pwd) => DoctorService.restartFailedServices(report.failedServices, password: pwd)),
child: const Text('Попытаться перезапустить службы'),
),
]
: null,
),
const SizedBox(height: 14),
// Check 4: .pacnew files
_buildPacnewCard(macosTheme, report),
const SizedBox(height: 14),
// Check 5: Mirrors & Reflector
_buildMirrorsCard(macosTheme, report),
],
);
},
),
],
);
}
Widget _buildRunningActionBanner(MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const ProgressCircle(),
const SizedBox(width: 12),
Expanded(
child: Text(
_actionTitle ?? 'Выполнение операции...',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 120,
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _actionLogs.length,
itemBuilder: (context, i) => Text(
_actionLogs[i],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFFD1D1D6),
),
),
),
),
],
),
);
}
Widget _buildCheckCard({
required MacosThemeData macosTheme,
required IconData icon,
required Color iconColor,
required String title,
String? friendlyTermKey,
required String subtitle,
required bool isAlert,
List<Widget>? actions,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isAlert ? iconColor.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (friendlyTermKey != null)
FriendlyTerm(
text: title,
termKey: friendlyTermKey,
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
)
else
Text(
title,
style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
),
if (actions != null && actions.isNotEmpty) ...[
const SizedBox(height: 10),
Row(children: actions),
],
],
),
),
],
),
);
}
Widget _buildPacnewCard(MacosThemeData macosTheme, DoctorReport report) {
final hasPacnew = report.pacnewEntries.isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: hasPacnew ? CupertinoColors.systemOrange.withValues(alpha: 0.4) : macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: hasPacnew
? CupertinoColors.systemOrange.withValues(alpha: 0.15)
: CupertinoColors.systemGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
CupertinoIcons.doc_on_clipboard_fill,
color: hasPacnew ? CupertinoColors.systemOrange : CupertinoColors.systemGreen,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FriendlyTerm(
text: 'Конфигурации .pacnew',
termKey: 'pacnew',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
hasPacnew
? 'Найдено ${report.pacnewEntries.length} неслитых конфигураций. При обновлениях пакетов pacman сохраняет новые шаблоны настроек с расширением .pacnew.'
: 'Неслитых .pacnew файлов не обнаружено. Все конфигурации в /etc актуальны.',
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
),
],
),
),
],
),
if (hasPacnew) ...[
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 10),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: report.pacnewEntries.length,
separatorBuilder: (_, _) => const Divider(height: 12),
itemBuilder: (context, idx) {
final entry = report.pacnewEntries[idx];
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.fileName,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
Text(
entry.originalPath,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => _showDiffDialog(entry),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.arrow_right_arrow_left, size: 12),
SizedBox(width: 4),
Text('Сравнить Diff'),
],
),
),
],
);
},
),
],
],
),
);
}
Widget _buildMirrorsCard(MacosThemeData macosTheme, DoctorReport report) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(CupertinoIcons.globe, color: Color(0xFF0A84FF), size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Оптимизация зеркал репозиториев (Reflector)',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold),
),
const SizedBox(height: 3),
Text(
report.mirrorlistStatus,
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey, height: 1.3),
),
const SizedBox(height: 10),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: _isActionRunning
? null
: () => _executeAction('Оптимизация зеркал через Reflector', (pwd) => DoctorService.optimizeMirrors(password: pwd)),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.bolt_fill, size: 12, color: Color(0xFFFF9500)),
SizedBox(width: 5),
Text('Выбрать 10 быстрейших зеркал (Reflector)'),
],
),
),
],
),
),
],
),
);
}
}
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
List<HistoryItem> _items = [];
bool _isLoading = true;
String _searchQuery = '';
HistoryAction? _filterAction;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
setState(() => _isLoading = true);
try {
final list = await RustBridgeApi.getHistory();
if (mounted) {
setState(() {
_items = list;
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _items.where((i) {
final matchesSearch =
i.packageName.toLowerCase().contains(_searchQuery.toLowerCase());
final matchesAction =
_filterAction == null || i.action == _filterAction;
return matchesSearch && matchesAction;
}).toList();
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Журнал транзакций'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<HistoryAction?>(
value: _filterAction,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все действия')),
MacosPopupMenuItem(
value: HistoryAction.upgraded, child: Text('Обновления')),
MacosPopupMenuItem(
value: HistoryAction.installed, child: Text('Установки')),
MacosPopupMenuItem(
value: HistoryAction.removed, child: Text('Удаления')),
],
onChanged: (a) => setState(() => _filterAction = a),
),
),
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 180,
child: MacosTextField(
placeholder: 'Поиск по истории...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
onChanged: (val) => setState(() => _searchQuery = val),
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 18));
}
if (filtered.isEmpty) {
return const Center(
child: Text(
'История транзакций пуста или не найдена',
style: TextStyle(color: CupertinoColors.systemGrey),
),
);
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, idx) {
final item = filtered[idx];
IconData icon;
Color color;
String actionText;
switch (item.action) {
case HistoryAction.upgraded:
icon = CupertinoIcons.arrow_up_circle_fill;
color = const Color(0xFF0A84FF);
actionText = 'Обновлен';
break;
case HistoryAction.installed:
icon = CupertinoIcons.plus_circle_fill;
color = CupertinoColors.systemGreen;
actionText = 'Установлен';
break;
case HistoryAction.removed:
icon = CupertinoIcons.minus_circle_fill;
color = CupertinoColors.systemRed;
actionText = 'Удален';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Row(
children: [
MacosIcon(icon, color: color, size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.packageName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
actionText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
),
const Spacer(),
Text(
item.timestamp,
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
const SizedBox(height: 3),
if (item.oldVersion != null)
Row(
children: [
Text(
item.oldVersion!,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 5),
child: MacosIcon(
CupertinoIcons.arrow_right,
size: 10,
color: CupertinoColors.systemGrey,
),
),
Text(
item.newVersion,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
fontWeight: FontWeight.bold,
color: color,
),
),
],
)
else
Text(
item.newVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
);
},
);
},
),
],
);
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
class InstalledScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const InstalledScreen({
super.key,
this.onInspectPackage,
});
@override
State<InstalledScreen> createState() => _InstalledScreenState();
}
class _InstalledScreenState extends State<InstalledScreen> {
List<PackageInfo> _packages = [];
String _searchQuery = '';
bool _isLoading = true;
BackendType? _filterBackend;
@override
void initState() {
super.initState();
_loadInstalled();
}
Future<void> _loadInstalled() async {
setState(() => _isLoading = true);
try {
final list =
await RustBridgeApi.getInstalledPackages(backend: _filterBackend);
if (mounted) {
setState(() {
_packages = list;
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _packages.where((p) {
return p.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
p.description.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Установленные пакеты'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
// Filter Popup
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<BackendType?>(
value: _filterBackend,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
MacosPopupMenuItem(
value: BackendType.pacman, child: Text('Pacman')),
MacosPopupMenuItem(
value: BackendType.flatpak, child: Text('Flatpak')),
],
onChanged: (b) {
setState(() => _filterBackend = b);
_loadInstalled();
},
),
),
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: 220,
child: MacosTextField(
placeholder: 'Поиск пакета...',
placeholderStyle: const TextStyle(
color: CupertinoColors.systemGrey2,
fontSize: 13,
),
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.search,
size: 14,
color: CupertinoColors.systemGrey,
),
),
onChanged: (val) => setState(() => _searchQuery = val),
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle(radius: 16));
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, idx) {
final pkg = filtered[idx];
final isFlatpak = pkg.backend == BackendType.flatpak;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
alignment: Alignment.centerLeft,
pressedOpacity: 0.7,
onPressed: () {
widget.onInspectPackage?.call(pkg.name, pkg.backend);
},
child: Row(
children: [
MacosIcon(
isFlatpak
? CupertinoIcons.app_badge_fill
: CupertinoIcons.cube_box_fill,
size: 20,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pkg.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Text(
pkg.version,
style: const TextStyle(
fontFamily: 'monospace',
color: CupertinoColors.systemGrey,
fontSize: 12,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isFlatpak
? const Color(0xFF4A90E2)
.withValues(alpha: 0.12)
: const Color(0xFF1793D1)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Text(
pkg.backend.displayName,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
),
),
],
),
const SizedBox(height: 3),
Text(
pkg.description,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 14),
Text(
pkg.formattedSize,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 8),
const MacosIcon(
CupertinoIcons.chevron_right,
size: 13,
color: CupertinoColors.systemGrey,
),
],
),
),
);
},
);
},
),
],
);
}
}
+447
View File
@@ -0,0 +1,447 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
import '../../services/app_settings_service.dart';
import '../../services/snapshot_service.dart';
import '../widgets/friendly_term.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _usePolkit = true;
String _selectedBackend = 'pacman';
bool _createSnapshotBeforeUpdate = false;
String _windowTheme = 'auto';
SnapshotEngine _snapshotEngine = SnapshotEngine.none;
bool _isCreatingSnapshot = false;
String? _snapshotMessage;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final settings = AppSettingsService.instance;
await settings.load();
final engine = await SnapshotService.detectEngine();
if (mounted) {
setState(() {
_usePolkit = settings.usePolkit;
_selectedBackend = settings.preferredBackend;
_createSnapshotBeforeUpdate = settings.createSnapshotBeforeUpdate;
_windowTheme = settings.windowTheme;
_snapshotEngine = engine;
});
}
}
Future<void> _createManualSnapshot() async {
final password = await AdminService.promptPassword(
context,
title: 'Создание точки восстановления системы',
reason: 'Для создания системного снимка btrfs требуются права суперпользователя.',
);
if (password == null) return;
setState(() {
_isCreatingSnapshot = true;
_snapshotMessage = null;
});
final res = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge manual snapshot',
);
if (mounted) {
setState(() {
_isCreatingSnapshot = false;
_snapshotMessage = res.message;
});
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Настройки'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
),
children: [
ContentArea(
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
Text(
'Настройки системы',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 20),
// Group 0: Desktop Environment & Window Theme Style
_buildSectionHeader('СТИЛЬ ИНТЕРФЕЙСА И ОКНА (DESKTOP ENVIRONMENT)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Оформление окна и темы',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
const SizedBox(height: 3),
Text(
AppSettingsService.instance.effectiveWindowThemeStyle.description,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
const SizedBox(width: 14),
MacosPopupButton<String>(
value: _windowTheme,
items: [
MacosPopupMenuItem(
value: 'auto',
child: Text('Автоматически (${AppSettingsService.detectDesktopEnvironmentName()})'),
),
const MacosPopupMenuItem(
value: 'kde',
child: Text('KDE Plasma (Breeze)'),
),
const MacosPopupMenuItem(
value: 'gnome',
child: Text('GNOME (Adwaita)'),
),
const MacosPopupMenuItem(
value: 'macos',
child: Text('macOS (Apple Cupertino)'),
),
],
onChanged: (val) async {
if (val != null) {
setState(() => _windowTheme = val);
await AppSettingsService.instance.setWindowTheme(val);
}
},
),
],
),
const SizedBox(height: 12),
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'Активный профиль: ${AppSettingsService.instance.effectiveWindowThemeStyle.displayName}',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: AppSettingsService.instance.effectiveWindowThemeStyle.primaryColor,
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Group 1: Privileges
_buildSectionHeader('БЕЗОПАСНОСТЬ И АВТОРИЗАЦИЯ'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Использовать PolicyKit (pkexec)',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
SizedBox(height: 2),
Text(
'Нативный диалог ввода пароля администратора в рабочем окружении Linux',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosSwitch(
value: _usePolkit,
onChanged: (val) async {
setState(() => _usePolkit = val);
await AppSettingsService.instance.setUsePolkit(val);
},
),
],
),
),
const SizedBox(height: 24),
// Group 2: Package Backend
_buildSectionHeader('ЯДРО ПАКЕТНОГО МЕНЕДЖЕРА'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Активный бэкенд',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
SizedBox(height: 2),
Text(
'Интерфейс ядра взаимодействия с репозиториями ОС',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosPopupButton<String>(
value: _selectedBackend,
items: const [
MacosPopupMenuItem(
value: 'pacman',
child: Text('Pacman / ALPM (Arch)'),
),
MacosPopupMenuItem(
value: 'flatpak',
child: Text('Flatpak (D-Bus)'),
),
MacosPopupMenuItem(
value: 'apt',
child: Text('APT (Debian/Ubuntu)'),
),
],
onChanged: (val) async {
if (val != null) {
setState(() => _selectedBackend = val);
await AppSettingsService.instance.setPreferredBackend(val);
}
},
),
],
),
),
const SizedBox(height: 24),
// Group 3: Recovery Snapshots
_buildSectionHeader('ТОЧКИ ВОССТАНОВЛЕНИЯ СИСТЕМЫ (SNAPSHOTS)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: macosTheme.dividerColor,
width: 0.8,
),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
FriendlyTerm(
text: 'Создавать снимки перед обновлениями',
termKey: 'snapshots',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13.5,
),
),
],
),
const SizedBox(height: 2),
Text(
_snapshotEngine == SnapshotEngine.snapper
? 'Обнаружен движок: Snapper (Btrfs root). Автоматически сохранять слепок системы до апгрейда.'
: _snapshotEngine == SnapshotEngine.timeshift
? 'Обнаружен движок: Timeshift. Автоматически сохранять слепок системы до апгрейда.'
: 'Утилиты Snapper или Timeshift не обнаружены в системе. Снимки недоступны.',
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
),
MacosSwitch(
value: _createSnapshotBeforeUpdate,
onChanged: _snapshotEngine == SnapshotEngine.none
? null
: (val) async {
setState(() => _createSnapshotBeforeUpdate = val);
await AppSettingsService.instance.setCreateSnapshotBeforeUpdate(val);
},
),
],
),
if (_snapshotEngine != SnapshotEngine.none) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
children: [
const Expanded(
child: Text(
'Ручное создание контрольной точки:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: _isCreatingSnapshot ? null : _createManualSnapshot,
child: _isCreatingSnapshot
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(),
SizedBox(width: 6),
Text('Создание...'),
],
)
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.camera_fill, size: 13),
SizedBox(width: 5),
Text('Создать снимок сейчас'),
],
),
),
],
),
if (_snapshotMessage != null) ...[
const SizedBox(height: 8),
Text(
_snapshotMessage!,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGreen,
fontFamily: 'monospace',
),
),
],
],
],
),
),
],
);
},
),
],
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
);
}
}
@@ -0,0 +1,589 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
class StarterPackItem {
final String name;
final String description;
bool isInstalled;
bool isSelected;
StarterPackItem({
required this.name,
required this.description,
this.isInstalled = false,
this.isSelected = true,
});
}
class StarterPack {
final String id;
final String title;
final String description;
final IconData icon;
final Color accentColor;
final List<StarterPackItem> items;
StarterPack({
required this.id,
required this.title,
required this.description,
required this.icon,
required this.accentColor,
required this.items,
});
int get installedCount => items.where((i) => i.isInstalled).length;
bool get isFullyInstalled => installedCount == items.length;
}
class StarterPacksScreen extends StatefulWidget {
const StarterPacksScreen({super.key});
@override
State<StarterPacksScreen> createState() => _StarterPacksScreenState();
}
class _StarterPacksScreenState extends State<StarterPacksScreen> {
bool _isLoading = true;
bool _isInstalling = false;
String? _installingPackTitle;
final List<String> _installLogs = [];
final ScrollController _logScrollController = ScrollController();
late final List<StarterPack> _packs = [
StarterPack(
id: 'codecs',
title: 'Мультимедиа кодеки и шрифты',
description: 'Базовый набор GStreamer кодеков для воспроизведения любых аудио/видео форматов и популярные шрифты.',
icon: CupertinoIcons.music_albums_fill,
accentColor: const Color(0xFFFF9500),
items: [
StarterPackItem(name: 'gst-plugins-good', description: 'Набор свободных кодеков высокого качества'),
StarterPackItem(name: 'gst-plugins-bad', description: 'Дополнительные современные кодеки'),
StarterPackItem(name: 'gst-plugins-ugly', description: 'Популярные проприетарные кодеки (MP3/H.264)'),
StarterPackItem(name: 'gst-libav', description: 'Плагин FFmpeg для GStreamer'),
StarterPackItem(name: 'noto-fonts', description: 'Универсальные шрифты Google Noto'),
StarterPackItem(name: 'noto-fonts-emoji', description: 'Цветные эмодзи для всех приложений'),
StarterPackItem(name: 'ttf-dejavu', description: 'Классические четкие шрифты TrueType'),
],
),
StarterPack(
id: 'gaming',
title: 'Игровой набор (Gaming Suite)',
description: 'Все необходимое для запуска игр в Linux: Steam, менеджер Lutris, оптимизатор GameMode и мониторинг FPS.',
icon: CupertinoIcons.gamecontroller_fill,
accentColor: const Color(0xFFFF2D55),
items: [
StarterPackItem(name: 'steam', description: 'Крупнейшая платформа цифровой дистрибуции игр'),
StarterPackItem(name: 'gamemode', description: 'Демон оптимизации приоритетов CPU/GPU для игр'),
StarterPackItem(name: 'mangohud', description: 'Экранный оверлей FPS, температур и нагрузки'),
StarterPackItem(name: 'wine-staging', description: 'Слой совместимости для запуска Windows-игр и программ'),
],
),
StarterPack(
id: 'office',
title: 'Офис и продуктивность',
description: 'Полноценный офисный пакет LibreOffice, просмотрщик PDF, калькулятор и скриншотер с аннотациями.',
icon: CupertinoIcons.doc_text_fill,
accentColor: const Color(0xFF007AFF),
items: [
StarterPackItem(name: 'libreoffice-fresh', description: 'Текстовый процессор, таблицы и презентации'),
StarterPackItem(name: 'evince', description: 'Легкий и быстрый просмотр документов PDF/DjVu'),
StarterPackItem(name: 'flameshot', description: 'Мощный инструмент создания скриншотов со стрелками и текстом'),
StarterPackItem(name: 'qalculate-gtk', description: 'Универсальный научный и валютный калькулятор'),
],
),
StarterPack(
id: 'developer',
title: 'Разработчик и системные утилиты',
description: 'Инструменты компиляции, контроль версий Git, современный терминальный редактор и контейнеры.',
icon: CupertinoIcons.chevron_left_slash_chevron_right,
accentColor: const Color(0xFF5856D6),
items: [
StarterPackItem(name: 'base-devel', description: 'Компиляторы GCC, make, patch и утилиты сборки'),
StarterPackItem(name: 'git', description: 'Распределенная система контроля версий'),
StarterPackItem(name: 'neovim', description: 'Быстрый и расширяемый терминальный текстовый редактор'),
StarterPackItem(name: 'jq', description: 'Утилита для разбора и форматирования JSON в консоли'),
StarterPackItem(name: 'htop', description: 'Интерактивный просмотрщик системных процессов'),
StarterPackItem(name: 'curl', description: 'Инструмент командной строки для передачи данных по URL'),
],
),
StarterPack(
id: 'media',
title: 'Графика и видеомонтаж',
description: 'Программы для творческой работы: монтаж, растровое и векторное рисование, запись экрана.',
icon: CupertinoIcons.film_fill,
accentColor: const Color(0xFFAF52DE),
items: [
StarterPackItem(name: 'vlc', description: 'Медиаплеер, читающий любые форматы без настроек'),
StarterPackItem(name: 'gimp', description: 'Свободный аналог Photoshop для редактирования фото'),
StarterPackItem(name: 'inkscape', description: 'Профессиональный редактор векторной графики SVG'),
StarterPackItem(name: 'obs-studio', description: 'Запись экрана и онлайн-трансляции'),
],
),
StarterPack(
id: 'internet',
title: 'Интернет и коммуникация',
description: 'Браузер, мессенджер, почтовый клиент и безопасный торрент-клиент.',
icon: CupertinoIcons.bubble_left_bubble_right_fill,
accentColor: const Color(0xFF34C759),
items: [
StarterPackItem(name: 'telegram-desktop', description: 'Официальный клиент мессенджера Telegram'),
StarterPackItem(name: 'firefox', description: 'Быстрый и приватный веб-браузер с открытым кодом'),
StarterPackItem(name: 'thunderbird', description: 'Почтовый клиент, календарь и адресная книга'),
StarterPackItem(name: 'transmission-gtk', description: 'Минималистичный BitTorrent клиент без рекламы'),
],
),
];
@override
void initState() {
super.initState();
_scanInstalled();
}
@override
void dispose() {
_logScrollController.dispose();
super.dispose();
}
Future<void> _scanInstalled() async {
setState(() => _isLoading = true);
try {
final res = await Process.run('pacman', ['-Qq']);
final installedSet = res.exitCode == 0
? LineSplitter.split(res.stdout as String).map((e) => e.trim()).toSet()
: <String>{};
for (final pack in _packs) {
for (final item in pack.items) {
item.isInstalled = installedSet.contains(item.name);
item.isSelected = !item.isInstalled;
}
}
} catch (_) {}
if (mounted) {
setState(() => _isLoading = false);
}
}
Future<void> _installPack(StarterPack pack) async {
final toInstall = pack.items.where((i) => !i.isInstalled && i.isSelected).map((i) => i.name).toList();
if (toInstall.isEmpty) return;
final password = await AdminService.promptPassword(
context,
title: 'Установка набора «${pack.title}»',
reason: 'Для установки ${toInstall.length} пакетов требуются права суперпользователя.',
);
if (password == null) return;
setState(() {
_isInstalling = true;
_installingPackTitle = pack.title;
_installLogs.clear();
_installLogs.add('🚀 Начало установки набора: ${pack.title}...');
_installLogs.add('📦 Выбранные пакеты: ${toInstall.join(', ')}');
});
try {
final process = await Process.start(
'sudo',
['-S', 'pacman', '-S', '--noconfirm', '--needed', ...toInstall],
mode: ProcessStartMode.normal,
);
process.stdin.writeln(password);
await process.stdin.flush();
process.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (mounted) {
setState(() => _installLogs.add(line));
_scrollLogToBottom();
}
});
process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (mounted) {
setState(() => _installLogs.add('[STDERR] $line'));
_scrollLogToBottom();
}
});
final code = await process.exitCode;
if (mounted) {
setState(() {
if (code == 0) {
_installLogs.add('✅ Пакеты успешно установлены!');
} else {
_installLogs.add('❌ Установка завершилась с кодом ошибки $code');
}
});
}
} catch (e) {
if (mounted) {
setState(() => _installLogs.add('❌ Ошибка: $e'));
}
} finally {
await _scanInstalled();
if (mounted) {
setState(() {
_isInstalling = false;
_installingPackTitle = null;
});
}
}
}
void _scrollLogToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
return MacosScaffold(
toolBar: ToolBar(
title: const Text(
'Наборы ПО',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
ToolBarIconButton(
label: 'Обновить',
icon: const MacosIcon(CupertinoIcons.refresh),
showLabel: false,
tooltipMessage: 'Проверить установленные пакеты',
onPressed: _isLoading || _isInstalling ? null : _scanInstalled,
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(child: ProgressCircle());
}
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Стартовые наборы (Starter Packs)',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6),
const Text(
'Готовые проверенные комплекты программ для комфортного старта в Arch Linux. '
'Выберите необходимые пакеты и установите их в 1 клик.',
style: TextStyle(
fontSize: 13,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
const SizedBox(height: 24),
if (_isInstalling) ...[
_buildInstallingBanner(macosTheme),
const SizedBox(height: 24),
],
// Grid or Column of starter packs
..._packs.map((pack) => _buildPackCard(pack, macosTheme)),
],
);
},
),
],
);
}
Widget _buildInstallingBanner(MacosThemeData macosTheme) {
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0A84FF).withValues(alpha: 0.3)),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const ProgressCircle(),
const SizedBox(width: 12),
Expanded(
child: Text(
'Установка: ${_installingPackTitle ?? ""}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 140,
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _installLogs.length,
itemBuilder: (context, i) => Text(
_installLogs[i],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFFD1D1D6),
),
),
),
),
],
),
);
}
Widget _buildPackCard(StarterPack pack, MacosThemeData macosTheme) {
final neededCount = pack.items.where((i) => !i.isInstalled && i.isSelected).length;
return Container(
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.8),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: pack.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
pack.icon,
color: pack.accentColor,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pack.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: -0.3,
),
),
const Spacer(),
// Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pack.isFullyInstalled
? CupertinoColors.systemGreen.withValues(alpha: 0.15)
: CupertinoColors.systemGrey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
pack.isFullyInstalled
? '✓ Установлен'
: '${pack.installedCount}/${pack.items.length} уст.',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: pack.isFullyInstalled
? CupertinoColors.systemGreen
: CupertinoColors.systemGrey,
),
),
),
],
),
const SizedBox(height: 4),
Text(
pack.description,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
height: 1.3,
),
),
],
),
),
],
),
),
const Divider(height: 1),
// Items list
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: pack.items.length,
separatorBuilder: (_, _) => const Divider(height: 1, indent: 46),
itemBuilder: (context, index) {
final item = pack.items[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
MacosCheckbox(
value: item.isInstalled || item.isSelected,
onChanged: item.isInstalled || _isInstalling
? null
: (val) {
setState(() {
item.isSelected = val;
});
},
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
decoration: item.isInstalled ? TextDecoration.none : null,
),
),
if (item.isInstalled) ...[
const SizedBox(width: 8),
const Text(
'Установлен',
style: TextStyle(
fontSize: 10,
color: CupertinoColors.systemGreen,
fontWeight: FontWeight.bold,
),
),
],
],
),
const SizedBox(height: 1),
Text(
item.description,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
);
},
),
const Divider(height: 1),
// Action bottom bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (!pack.isFullyInstalled)
Text(
'Выбрано для установки: $neededCount',
style: const TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const Spacer(),
PushButton(
controlSize: ControlSize.regular,
secondary: pack.isFullyInstalled,
onPressed: pack.isFullyInstalled || neededCount == 0 || _isInstalling
? null
: () => _installPack(pack),
child: Text(
pack.isFullyInstalled
? 'Все пакеты установлены'
: 'Установить выбранные ($neededCount)',
),
),
],
),
),
],
),
);
}
}
+996
View File
@@ -0,0 +1,996 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
import '../../services/admin_service.dart';
import '../../services/app_badge_controller.dart';
import '../../services/app_settings_service.dart';
import '../../services/arch_news_service.dart';
import '../../services/snapshot_service.dart';
import '../widgets/friendly_term.dart';
class UpdatesScreen extends StatefulWidget {
final Function(String name, BackendType backend)? onInspectPackage;
const UpdatesScreen({
super.key,
this.onInspectPackage,
});
@override
State<UpdatesScreen> createState() => _UpdatesScreenState();
}
class _UpdatesScreenState extends State<UpdatesScreen> {
List<PackageUpdate> _updates = [];
final Set<String> _selectedPackages = {};
bool _isLoading = true;
bool _isUpdating = false;
bool _isDryRun = true;
BackendType? _filterBackend;
ProgressEvent? _currentProgress;
final List<String> _logLines = [];
StreamSubscription<ProgressEvent>? _progressSub;
final ScrollController _logScrollController = ScrollController();
List<ArchNewsItem> _newsAlerts = [];
List<ArchNewsItem> _allNews = [];
@override
void initState() {
super.initState();
_loadUpdates();
}
@override
void dispose() {
_progressSub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _loadUpdates() async {
setState(() => _isLoading = true);
try {
final updates = await RustBridgeApi.checkUpdates(backend: _filterBackend);
final news = await ArchNewsService.fetchNews();
final interventions = ArchNewsService.checkInterventions(
updates.map((e) => e.name).toList(),
news,
);
if (mounted) {
setState(() {
_updates = updates;
_selectedPackages.clear();
_selectedPackages.addAll(updates.map((e) => e.name));
_allNews = news;
_newsAlerts = interventions;
_isLoading = false;
});
AppBadgeController.instance.setUpdatesCount(updates.length);
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _startUpgrade() async {
if (_selectedPackages.isEmpty) return;
String? password;
final targetBackend = _filterBackend ?? BackendType.pacman;
// Если симуляция выключена, запрашиваем пароль администратора
if (!_isDryRun && (targetBackend == BackendType.pacman || targetBackend == BackendType.aur)) {
password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для установки системных обновлений (${_selectedPackages.length} пакетов) требуются привилегии суперпользователя.',
);
if (password == null) {
// Отменено пользователем
return;
}
}
setState(() {
_isUpdating = true;
_logLines.clear();
_currentProgress = null;
});
// Создание снимка системы (Snapshot), если включено в настройках
if (!_isDryRun && AppSettingsService.instance.createSnapshotBeforeUpdate && password != null) {
setState(() {
_logLines.add('📸 Создание точки восстановления системы перед обновлением...');
});
try {
final snapRes = await SnapshotService.createPreUpdateSnapshot(
password,
description: 'Forge pre-update (${_selectedPackages.length} pkgs)',
);
setState(() {
_logLines.add(snapRes.success ? '✅ ${snapRes.message}' : '⚠️ ${snapRes.message}');
});
} catch (e) {
setState(() {
_logLines.add('⚠️ Ошибка создания снимка: $e (продолжаем обновление)');
});
}
}
final stream = RustBridgeApi.applyUpdates(
targetBackend,
_selectedPackages.toList(),
isDryRun: _isDryRun,
password: password,
);
_progressSub?.cancel();
_progressSub = stream.listen(
(event) {
setState(() {
_currentProgress = event;
if (event.logLine != null) {
_logLines.add(event.logLine!);
} else if (event.message.isNotEmpty) {
_logLines.add(event.message);
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
if (event.step == UpdateStep.done || event.step == UpdateStep.failed) {
setState(() => _isUpdating = false);
_loadUpdates();
}
},
onError: (error) {
setState(() {
_isUpdating = false;
_logLines.add('[ERROR] $error');
});
},
);
}
int get _totalSelectedSize => _updates
.where((u) => _selectedPackages.contains(u.name))
.fold(0, (sum, u) => sum + u.downloadSizeBytes);
String _formatBytes(int bytes) {
if (bytes <= 0) return '—';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
@override
Widget build(BuildContext context) {
final macosTheme = MacosTheme.of(context);
final sizeText =
_totalSelectedSize > 0 ? ' • ${_formatBytes(_totalSelectedSize)}' : '';
return MacosScaffold(
toolBar: ToolBar(
title: const Text('Обновления'),
leading: MacosTooltip(
message: 'Боковая панель',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.sidebar_left),
onPressed: () => MacosWindowScope.of(context).toggleSidebar(),
),
),
actions: [
// Filter Popup
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: MacosPopupButton<BackendType?>(
value: _filterBackend,
items: const [
MacosPopupMenuItem(value: null, child: Text('Все бэкенды')),
MacosPopupMenuItem(
value: BackendType.pacman, child: Text('Pacman (Arch)')),
MacosPopupMenuItem(
value: BackendType.flatpak, child: Text('Flatpak')),
],
onChanged: (b) {
setState(() => _filterBackend = b);
_loadUpdates();
},
),
),
),
ToolBarIconButton(
label: 'Новости Arch',
icon: Stack(
clipBehavior: Clip.none,
children: [
const MacosIcon(CupertinoIcons.news),
if (_newsAlerts.isNotEmpty)
Positioned(
right: -2,
top: -2,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: CupertinoColors.systemRed,
shape: BoxShape.circle,
),
),
),
],
),
showLabel: false,
tooltipMessage: _newsAlerts.isNotEmpty
? 'Требуется ручное вмешательство Arch Linux!'
: 'Новости Arch Linux',
onPressed: () => _showArchNewsDialog(context),
),
ToolBarIconButton(
label: 'Проверить обновления',
icon: const MacosIcon(CupertinoIcons.arrow_clockwise),
showLabel: false,
tooltipMessage: 'Проверить обновления',
onPressed: _isUpdating ? null : _loadUpdates,
),
CustomToolbarItem(
inToolbarBuilder: (context) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: PushButton(
controlSize: ControlSize.regular,
onPressed: _isUpdating || _selectedPackages.isEmpty
? null
: _startUpgrade,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isUpdating) ...[
const ProgressCircle(radius: 6),
const SizedBox(width: 6),
] else ...[
const MacosIcon(CupertinoIcons.arrow_down_to_line, size: 13),
const SizedBox(width: 6),
],
Text(_isUpdating
? 'Обновление...'
: 'Обновить (${_selectedPackages.length})'),
],
),
),
),
),
],
),
children: [
ContentArea(
builder: (context, scrollController) {
if (_isLoading) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 16),
SizedBox(height: 16),
Text(
'Проверка репозиториев...',
style: TextStyle(color: CupertinoColors.systemGrey),
),
],
),
);
}
return ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20),
children: [
// Header with Controls
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Доступные обновления',
style: macosTheme.typography.largeTitle.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
'Найдено: ${_updates.length} • Выбрано: ${_selectedPackages.length}$sizeText',
style: macosTheme.typography.subheadline.copyWith(
color: CupertinoColors.systemGrey,
),
),
],
),
),
// Dry Run Switch
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.1),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FriendlyTerm(
text: 'Dry-Run',
termKey: 'dryrun',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Text(
'(Симуляция)',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 8),
MacosSwitch(
value: _isDryRun,
size: ControlSize.small,
onChanged: (v) => setState(() => _isDryRun = v),
),
],
),
),
],
),
const SizedBox(height: 20),
// Arch News Intervention Alert Banner
if (_newsAlerts.isNotEmpty) ...[
_buildInterventionBanner(macosTheme),
const SizedBox(height: 20),
],
// Live Progress Panel
if (_isUpdating || _logLines.isNotEmpty) ...[
_buildLiveProgressPanel(macosTheme),
const SizedBox(height: 20),
],
// List of Packages
if (_updates.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(
CupertinoIcons.checkmark_seal_fill,
size: 56,
color: CupertinoColors.systemGreen,
),
const SizedBox(height: 14),
Text(
'Все компоненты системы обновлены',
style: macosTheme.typography.title2.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
)
else
..._updates.map((pkg) => _buildPackageCard(pkg, macosTheme)),
],
);
},
),
],
);
}
Widget _buildLiveProgressPanel(MacosThemeData theme) {
final pct = _currentProgress?.percentage ?? 0.0;
final message = _currentProgress?.message ?? 'Инициализация...';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.dividerColor,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isUpdating) ...[
const ProgressCircle(radius: 7),
const SizedBox(width: 8),
],
Text(
message,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const Spacer(),
Text(
'${(pct * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: theme.primaryColor,
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ProgressBar(
value: (pct * 100).clamp(0.0, 100.0),
height: 6,
),
),
const SizedBox(height: 14),
// macOS Dark Terminal
Container(
height: 130,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF14171A),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logLines.length,
itemBuilder: (context, idx) {
final line = _logLines[idx];
final isErr =
line.startsWith('[ERROR]') || line.startsWith('[STDERR]');
return Text(
line,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.4,
color: isErr
? CupertinoColors.systemRed
: const Color(0xFF34D399),
),
);
},
),
),
],
),
);
}
Widget _buildPackageCard(PackageUpdate pkg, MacosThemeData theme) {
final isSelected = _selectedPackages.contains(pkg.name);
final isFlatpak = pkg.backend == BackendType.flatpak;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: const Color(0xFF222224),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: isSelected
? const Color(0xFF0A84FF).withValues(alpha: 0.45)
: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
pressedOpacity: 0.7,
alignment: Alignment.centerLeft,
onPressed: () {
widget.onInspectPackage?.call(pkg.name, pkg.backend);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, right: 12),
child: MacosCheckbox(
value: isSelected,
onChanged: _isUpdating
? null
: (val) {
setState(() {
if (val == true) {
_selectedPackages.add(pkg.name);
} else {
_selectedPackages.remove(pkg.name);
}
});
},
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pkg.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
// Backend Tag
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: isFlatpak
? const Color(0xFF4A90E2).withValues(alpha: 0.15)
: const Color(0xFF1793D1).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
pkg.backend.displayName,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isFlatpak
? const Color(0xFF4A90E2)
: const Color(0xFF1793D1),
),
),
),
if (pkg.isSecurityUpdate) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: CupertinoColors.systemRed
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: CupertinoColors.systemRed
.withValues(alpha: 0.4),
width: 0.7,
),
),
child: const Text(
'Безопасность',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemRed,
),
),
),
],
],
),
const SizedBox(height: 3),
Text(
pkg.description,
style: const TextStyle(
fontSize: 12.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
pkg.currentVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(
CupertinoIcons.arrow_right,
size: 11,
color: CupertinoColors.systemGrey,
),
),
Text(
pkg.newVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF0A84FF),
),
),
const Spacer(),
const MacosIcon(
CupertinoIcons.chevron_right,
size: 13,
color: CupertinoColors.systemGrey,
),
],
),
],
),
),
],
),
),
);
}
Widget _buildInterventionBanner(MacosThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.systemRed.withValues(alpha: 0.35),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(
CupertinoIcons.exclamationmark_triangle_fill,
color: CupertinoColors.systemRed,
size: 20,
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Требуется ручное вмешательство перед обновлением!',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.5,
color: CupertinoColors.systemRed,
),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => _showArchNewsDialog(context),
child: const Text('Инструкции'),
),
],
),
const SizedBox(height: 8),
const Text(
'Arch Linux опубликовал предупреждение для одного или нескольких обновляемых пакетов. Неправильное обновление может нарушить работу системы.',
style: TextStyle(
fontSize: 12,
color: CupertinoColors.white,
height: 1.3,
),
),
const SizedBox(height: 10),
..._newsAlerts.map(
(item) => Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Text('• ', style: TextStyle(color: CupertinoColors.systemRed)),
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () async {
final uri = Uri.tryParse(item.link);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
child: const Text(
'Читать на сайте ↗',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF0A84FF),
),
),
),
],
),
),
),
],
),
);
}
void _showArchNewsDialog(BuildContext context) {
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (context) => _ArchNewsSheet(
news: _allNews,
alerts: _newsAlerts,
onRefresh: () async {
final news = await ArchNewsService.fetchNews(forceRefresh: true);
final interventions = ArchNewsService.checkInterventions(
_updates.map((e) => e.name).toList(),
news,
);
if (mounted) {
setState(() {
_allNews = news;
_newsAlerts = interventions;
});
}
},
),
);
}
}
class _ArchNewsSheet extends StatefulWidget {
final List<ArchNewsItem> news;
final List<ArchNewsItem> alerts;
final Future<void> Function() onRefresh;
const _ArchNewsSheet({
required this.news,
required this.alerts,
required this.onRefresh,
});
@override
State<_ArchNewsSheet> createState() => _ArchNewsSheetState();
}
class _ArchNewsSheetState extends State<_ArchNewsSheet> {
bool _isRefreshing = false;
@override
Widget build(BuildContext context) {
return Container(
width: 620,
height: 520,
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.12),
width: 0.8,
),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.news,
size: 20,
color: Color(0xFF1793D1),
),
const SizedBox(width: 10),
const Text(
'Новости Arch Linux & Вмешательства',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const Spacer(),
MacosTooltip(
message: 'Обновить ленту',
child: MacosIconButton(
icon: _isRefreshing
? const ProgressCircle(radius: 7)
: const MacosIcon(CupertinoIcons.arrow_clockwise, size: 14),
onPressed: _isRefreshing
? null
: () async {
setState(() => _isRefreshing = true);
await widget.onRefresh();
if (mounted) setState(() => _isRefreshing = false);
},
),
),
const SizedBox(width: 6),
MacosTooltip(
message: 'Закрыть',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
),
// Body
Expanded(
child: widget.news.isEmpty
? const Center(
child: Text(
'Нет доступных новостей или отсутствует соединение с сетью',
style: TextStyle(color: CupertinoColors.systemGrey),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: widget.news.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, idx) {
final item = widget.news[idx];
final isAlert = item.isManualIntervention;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isAlert
? CupertinoColors.systemRed.withValues(alpha: 0.08)
: const Color(0xFF262628),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isAlert
? CupertinoColors.systemRed.withValues(alpha: 0.3)
: CupertinoColors.white.withValues(alpha: 0.06),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (isAlert) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.systemRed
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Вмешательство',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemRed,
),
),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
item.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
),
const SizedBox(width: 8),
Text(
item.pubDate,
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
item.description,
maxLines: 6,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey2,
height: 1.35,
),
),
],
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () async {
final uri = Uri.tryParse(item.link);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
child: const Text(
'Открыть на archlinux.org ↗',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF0A84FF),
),
),
),
),
],
),
);
},
),
),
],
),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
enum WindowThemeStyle {
macos,
gnome,
kde;
String get displayName {
switch (this) {
case WindowThemeStyle.macos:
return 'macOS (Apple Cupertino)';
case WindowThemeStyle.gnome:
return 'GNOME (Adwaita / Libadwaita)';
case WindowThemeStyle.kde:
return 'KDE Plasma (Breeze)';
}
}
String get description {
switch (this) {
case WindowThemeStyle.macos:
return 'Классический полупрозрачный акрил, скругления squircle и фирменный акцент Apple Blue.';
case WindowThemeStyle.gnome:
return 'Строгая плоская тёмная палитра Libadwaita, центрированные акценты и Adwaita Blue.';
case WindowThemeStyle.kde:
return 'Стиль KDE Breeze: контрастная тёмная палитра, тонкие четкие рамки и акцент Breeze Cyan.';
}
}
Color get primaryColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF0A84FF);
case WindowThemeStyle.gnome:
return const Color(0xFF3584E4);
case WindowThemeStyle.kde:
return const Color(0xFF3DAEE9);
}
}
Color get canvasColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF1E1E1E);
case WindowThemeStyle.gnome:
return const Color(0xFF242424);
case WindowThemeStyle.kde:
return const Color(0xFF232629);
}
}
Color get cardColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0xFF262628);
case WindowThemeStyle.gnome:
return const Color(0xFF303030);
case WindowThemeStyle.kde:
return const Color(0xFF31363B);
}
}
Color get dividerColor {
switch (this) {
case WindowThemeStyle.macos:
return const Color(0x2EFFFFFF);
case WindowThemeStyle.gnome:
return const Color(0x22FFFFFF);
case WindowThemeStyle.kde:
return const Color(0xFF4D5358);
}
}
MacosThemeData buildThemeData() {
return MacosThemeData.dark().copyWith(
primaryColor: primaryColor,
canvasColor: canvasColor,
dividerColor: dividerColor,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class AppIconImage extends StatelessWidget {
final String? iconUrl;
final String? localIconPath;
final IconData fallbackIcon;
final Color accentColor;
final double size;
const AppIconImage({
super.key,
this.iconUrl,
this.localIconPath,
required this.fallbackIcon,
this.accentColor = const Color(0xFF0A84FF),
this.size = 52,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(size * 0.23);
Widget? imageWidget;
if (iconUrl != null && iconUrl!.isNotEmpty) {
imageWidget = Image.network(
iconUrl!,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return _buildFallback(borderRadius);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: borderRadius,
),
child: const Center(
child: ProgressCircle(radius: 8),
),
);
},
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
child: imageWidget ?? _buildFallback(borderRadius),
),
);
}
Widget _buildFallback(BorderRadius borderRadius) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: borderRadius,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
accentColor.withValues(alpha: 0.85),
accentColor,
],
),
),
child: Center(
child: Icon(
fallbackIcon,
color: CupertinoColors.white,
size: size * 0.52,
),
),
);
}
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show SelectableText;
import 'package:macos_ui/macos_ui.dart';
import '../../services/admin_service.dart';
import '../../services/batch_install_service.dart';
class BatchInstallSheet extends StatefulWidget {
const BatchInstallSheet({super.key});
static Future<void> show(BuildContext context) {
return showMacosSheet<void>(
context: context,
barrierDismissible: false,
builder: (_) => const BatchInstallSheet(),
);
}
@override
State<BatchInstallSheet> createState() => _BatchInstallSheetState();
}
class _BatchInstallSheetState extends State<BatchInstallSheet> {
final List<String> _logs = [];
final ScrollController _logScrollController = ScrollController();
StreamSubscription<BatchInstallProgress>? _sub;
String _currentStep = 'Инициализация пакетной установки...';
double _percentage = 0.0;
bool _isCompleted = false;
bool _isFailed = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startInstallation();
});
}
@override
void dispose() {
_sub?.cancel();
_logScrollController.dispose();
super.dispose();
}
Future<void> _startInstallation() async {
final batch = BatchInstallService.instance;
final needRoot = batch.pacmanApps.isNotEmpty || batch.aurApps.isNotEmpty;
String? password;
if (needRoot) {
password = await AdminService.promptPassword(
context,
title: 'Установка выбранных приложений (${batch.count} шт.)',
reason: 'Для установки пакетов через pacman и сборки через AUR требуются права суперпользователя. Пароль будет запрошен только один раз.',
);
if (password == null) {
if (mounted) Navigator.of(context).pop();
return;
}
}
final stream = batch.runBatchInstall(password: password);
_sub = stream.listen(
(progress) {
if (!mounted) return;
setState(() {
_currentStep = progress.currentStep;
_percentage = progress.percentage;
if (progress.logLine != null) {
_logs.add(progress.logLine!);
}
_isCompleted = progress.isCompleted;
if (progress.isFailed) {
_isFailed = true;
}
});
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
},
onError: (err) {
if (!mounted) return;
setState(() {
_logs.add('❌ Критическая ошибка: $err');
_isFailed = true;
_isCompleted = true;
});
},
);
}
@override
Widget build(BuildContext context) {
final statusColor = _isFailed
? CupertinoColors.systemRed
: _isCompleted
? CupertinoColors.systemGreen
: const Color(0xFF0A84FF);
final statusIcon = _isFailed
? CupertinoIcons.exclamationmark_triangle_fill
: _isCompleted
? CupertinoIcons.check_mark_circled_solid
: CupertinoIcons.arrow_down_circle_fill;
final statusTitle = _isFailed
? (_isCompleted ? 'Установка завершена с ошибками' : 'Ошибка при установке')
: _isCompleted
? 'Установка успешно завершена!'
: 'Комплексная пакетная установка';
return MacosSheet(
child: Container(
width: 780,
height: 540,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
statusIcon,
color: statusColor,
size: 20,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 2),
Text(
_currentStep,
style: TextStyle(
fontSize: 12,
color: _isFailed ? CupertinoColors.systemRed : CupertinoColors.systemGrey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (_isCompleted || _isFailed)
PushButton(
controlSize: ControlSize.regular,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 16),
// Progress Bar
ProgressBar(value: (_percentage * 100).clamp(0, 100)),
const SizedBox(height: 16),
// Console output
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161618),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logs.length,
itemBuilder: (context, i) {
final line = _logs[i];
Color textColor = const Color(0xFFE5E5EA);
if (line.contains('✅') || line.contains('🎉')) {
textColor = CupertinoColors.systemGreen;
} else if (line.contains('❌') || line.contains('[STDERR]')) {
textColor = const Color(0xFFFF6961);
} else if (line.contains('🚀') || line.contains('📸')) {
textColor = const Color(0xFF0A84FF);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: SelectableText(
line,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: textColor,
height: 1.35,
),
),
);
},
),
),
),
],
),
),
);
}
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
class FriendlyTermExplanation {
final String title;
final String humanSummary;
final String whyItMatters;
final String safetyNote;
final String? example;
final IconData icon;
const FriendlyTermExplanation({
required this.title,
required this.humanSummary,
required this.whyItMatters,
required this.safetyNote,
this.example,
this.icon = CupertinoIcons.question_circle_fill,
});
}
class FriendlyTermsDictionary {
static final Map<String, FriendlyTermExplanation> _terms = {
'aur': const FriendlyTermExplanation(
title: 'Что такое AUR (Arch User Repository)?',
icon: CupertinoIcons.cube_box_fill,
humanSummary:
'Это огромная онлайн-библиотека программ, поддерживаемая сообществом Arch Linux. Здесь можно найти почти любую программу, которой нет в стандартных репозиториях (например, Google Chrome, Яндекс Музыку, драйверы или темы).',
whyItMatters:
'Forge автоматически скачивает инструкции сборки (PKGBUILD) и компилирует пакеты прямо на вашем компьютере с помощью хелпера (yay или paru).',
safetyNote:
'Пакеты создаются пользователями. Forge показывает количество голосов и популярность пакета, чтобы вы могли легко выбирать проверенный софт.',
example: 'Пакеты вроде visual-studio-code-bin или google-chrome устанавливаются именно из AUR.',
),
'orphans': const FriendlyTermExplanation(
title: 'Кто такие «Пакеты-сироты» (Orphans)?',
icon: CupertinoIcons.person_crop_circle_badge_xmark,
humanSummary:
'Это вспомогательные библиотеки и файлы, которые когда-то автоматически установились ради другой программы, но после её удаления остались в системе и больше никем не используются.',
whyItMatters:
'Они не вредят системе, но бесцельно занимают драгоценное дисковое пространство.',
safetyNote:
'Удалять пакеты-сироты в Forge полностью безопасно: удаляются только те файлы, от которых не зависит ни одна установленная сейчас программа.',
example: 'Вы удалили видеоредактор, а 15 библиотек для работы с видеокодеками остались. Это и есть сироты.',
),
'cache': const FriendlyTermExplanation(
title: 'Что такое Кэш пакетов Pacman?',
icon: CupertinoIcons.archivebox_fill,
humanSummary:
'Каждый раз, когда вы устанавливаете или обновляете программу, pacman скачивает её установочный архив (.pkg.tar.zst) в папку /var/cache/pacman/pkg и оставляет его там.',
whyItMatters:
'Это позволяет в любой момент откатить программу на старую версию без доступа к интернету. Но со временем кэш может разрастись до 20-50 гигабайт.',
safetyNote:
'Очистка в Forge оставляет две последние версии каждого пакета на случай отката, освобождая всё остальное место.',
example: 'Если свежее обновление сломало плеер, Forge позволит вернуть прошлую версию из этого кэша за 1 клик.',
),
'dryrun': const FriendlyTermExplanation(
title: 'Что такое Dry-Run (Симуляция)?',
icon: CupertinoIcons.shield_lefthalf_fill,
humanSummary:
'Режим репетиции или примерки. Менеджер пакетов проверяет все зависимости, загружает списки файлов, но НЕ вносит никаких реальных изменений на ваш диск.',
whyItMatters:
'Позволяет заранее увидеть, какие пакеты установятся, сколько места потребуется и не возникнет ли конфликтов библиотек, без риска что-то сломать.',
safetyNote:
'Абсолютно безопасно. В Forge включен по умолчанию на экране обновлений, чтобы вы всегда контролировали процесс.',
example: 'Вы можете нажать «Обновить» и посмотреть журнал, не рискуя стабильностью системы.',
),
'pacnew': const FriendlyTermExplanation(
title: 'Что такое файлы .pacnew?',
icon: CupertinoIcons.doc_text_fill,
humanSummary:
'Когда вы редактируете системный файл (например, конфиг сети или авторизации), а разработчики выпускают обновление этого конфига — Arch Linux специально НЕ перезаписывает ваш файл, чтобы не сбросить ваши настройки, а кладет рядом файл с расширением .pacnew.',
whyItMatters:
'Если их не объединять месяцами, система может продолжать использовать устаревший синтаксис конфигов.',
safetyNote:
'Forge позволяет наглядно сравнить различия старого и нового файлов и в 1 клик заменить или оставить текущий.',
example: '/etc/pam.d/su и рядом /etc/pam.d/su.pacnew с новыми системными правилами безопасности.',
),
'snapshots': const FriendlyTermExplanation(
title: 'Что такое Снимки системы (Snapshots)?',
icon: CupertinoIcons.camera_fill,
humanSummary:
'Мгновенная «фотография» всего системного диска до байта (через Btrfs / Snapper или Timeshift), занимающая минимум места.',
whyItMatters:
'Если после крупного обновления система перестанет загружаться или сломается графика, вы сможете мгновенно вернуть всё в состояние «до обновления» прямо из меню запуска компьютера.',
safetyNote:
'Снимки можно включить в Настройках Forge. Они создаются автоматически в фоновом режиме за 1 секунду перед обновлением.',
example: 'Обновили 100 пакетов, что-то пошло не так — откатились назад за 10 секунд.',
),
'keyring': const FriendlyTermExplanation(
title: 'Что такое Ключи PGP (Keyring)?',
icon: CupertinoIcons.lock_shield_fill,
humanSummary:
'Цифровая подпись разработчиков Arch Linux. Каждый официальный пакет подписан персональным криптографическим ключом мейнтейнера, чтобы никто не мог подсунуть вам вирус.',
whyItMatters:
'Если связка ключей устарела, pacman отказывается устанавливать программы и пишет: «invalid or corrupted package (PGP signature)».',
safetyNote:
'Раздел «Диагностика» в Forge умеет автоматически освежать эти ключи одной кнопкой.',
example: 'Ключи обновляются через pacman-key --refresh-keys.',
),
};
static FriendlyTermExplanation? get(String key) => _terms[key.toLowerCase()];
}
class FriendlyTerm extends StatelessWidget {
final String text;
final String termKey;
final String? customTitle;
final String? customExplanation;
final TextStyle? style;
final bool showQuestionIcon;
const FriendlyTerm({
super.key,
required this.text,
required this.termKey,
this.customTitle,
this.customExplanation,
this.style,
this.showQuestionIcon = true,
});
void _showExplanation(BuildContext context) {
final exp = FriendlyTermsDictionary.get(termKey) ??
FriendlyTermExplanation(
title: customTitle ?? text,
humanSummary: customExplanation ?? 'Пояснение для термина $text.',
whyItMatters: 'Понимание этого параметра помогает уверенно управлять вашей системой Arch Linux.',
safetyNote: 'Это стандартная концепция управления пакетами.',
);
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (ctx) => _FriendlyTermSheet(explanation: exp),
);
}
@override
Widget build(BuildContext context) {
final defaultStyle = MacosTheme.of(context).typography.body;
final mergedStyle = (style ?? defaultStyle).copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dashed,
decorationColor: const Color(0xFF0A84FF).withValues(alpha: 0.6),
);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _showExplanation(context),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(text, style: mergedStyle),
if (showQuestionIcon) ...[
const SizedBox(width: 4),
const MacosIcon(
CupertinoIcons.question_circle,
size: 13,
color: Color(0xFF0A84FF),
),
],
],
),
),
);
}
}
class _FriendlyTermSheet extends StatelessWidget {
final FriendlyTermExplanation explanation;
const _FriendlyTermSheet({required this.explanation});
@override
Widget build(BuildContext context) {
return Container(
width: 500,
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.12),
width: 0.8,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: CupertinoColors.white.withValues(alpha: 0.08),
width: 0.8,
),
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: MacosIcon(
explanation.icon,
color: const Color(0xFF0A84FF),
size: 20,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
explanation.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
),
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Content Body
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCard(
title: 'ПРОСТЫМИ СЛОВАМИ',
content: explanation.humanSummary,
icon: CupertinoIcons.lightbulb_fill,
iconColor: CupertinoColors.systemYellow,
),
const SizedBox(height: 12),
_buildCard(
title: 'ЗАЧЕМ ЭТО НУЖНО',
content: explanation.whyItMatters,
icon: CupertinoIcons.checkmark_seal_fill,
iconColor: const Color(0xFF0A84FF),
),
const SizedBox(height: 12),
_buildCard(
title: 'БЕЗОПАСНОСТЬ',
content: explanation.safetyNote,
icon: CupertinoIcons.shield_fill,
iconColor: CupertinoColors.systemGreen,
),
if (explanation.example != null) ...[
const SizedBox(height: 12),
_buildCard(
title: 'ПРИМЕР',
content: explanation.example!,
icon: CupertinoIcons.play_circle_fill,
iconColor: CupertinoColors.systemPurple,
),
],
],
),
),
// Bottom Close
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Align(
alignment: Alignment.centerRight,
child: PushButton(
controlSize: ControlSize.regular,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Понятно'),
),
),
),
],
),
);
}
Widget _buildCard({
required String title,
required String content,
required IconData icon,
required Color iconColor,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF262628),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.06),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MacosIcon(icon, size: 12, color: iconColor),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 0.6,
color: iconColor,
),
),
],
),
const SizedBox(height: 6),
Text(
content,
style: const TextStyle(
fontSize: 12.5,
height: 1.35,
color: CupertinoColors.white,
),
),
],
),
);
}
}
+300
View File
@@ -0,0 +1,300 @@
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/batch_install_service.dart';
import 'app_icon_image.dart';
import 'batch_install_sheet.dart';
class InstallQueueBar extends StatelessWidget {
const InstallQueueBar({super.key});
void _showQueueDetails(BuildContext context) {
final batch = BatchInstallService.instance;
showMacosSheet(
context: context,
barrierDismissible: true,
builder: (sheetContext) {
return MacosSheet(
child: Container(
width: 540,
height: 480,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(CupertinoIcons.bag_fill, size: 20, color: Color(0xFF0A84FF)),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Очередь комплексной установки',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
PushButton(
controlSize: ControlSize.small,
secondary: true,
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 14),
const Text(
'Выбранные приложения из разных источников будут установлены за один сеанс с однократным запросом пароля:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 14),
Expanded(
child: ListenableBuilder(
listenable: batch,
builder: (context, _) {
if (batch.isEmpty) {
return const Center(
child: Text('Очередь пуста', style: TextStyle(color: CupertinoColors.systemGrey)),
);
}
return ListView.separated(
itemCount: batch.queueApps.length,
separatorBuilder: (_, _) => Container(
height: 0.8,
color: CupertinoColors.systemGrey.withValues(alpha: 0.2),
),
itemBuilder: (context, i) {
final app = batch.queueApps[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
AppIconImage(
iconUrl: app.iconUrl,
fallbackIcon: app.fallbackIcon,
accentColor: app.accentColor,
size: 34,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
app.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
Text(
app.summary,
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _backendColor(app.backend).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
app.backend.name.toUpperCase(),
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: _backendColor(app.backend),
),
),
),
const SizedBox(width: 8),
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.xmark, size: 14, color: CupertinoColors.systemGrey),
onPressed: () => batch.remove(app),
),
],
),
);
},
);
},
),
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () {
batch.clear();
Navigator.of(sheetContext).pop();
},
child: const Text('Очистить всё'),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: () {
Navigator.of(sheetContext).pop();
BatchInstallSheet.show(context);
},
child: const Text('Перейти к установке'),
),
],
),
],
),
),
);
},
);
}
static Color _backendColor(BackendType b) {
switch (b) {
case BackendType.pacman:
return const Color(0xFF1793D1);
case BackendType.aur:
return const Color(0xFFFF9500);
case BackendType.flatpak:
return const Color(0xFF4A90E2);
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: BatchInstallService.instance,
builder: (context, _) {
final batch = BatchInstallService.instance;
if (batch.isEmpty) return const SizedBox.shrink();
final macosTheme = MacosTheme.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(28, 0, 28, 16),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: const Color(0xFF0A84FF).withValues(alpha: 0.4),
width: 1,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.25),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFF0A84FF).withValues(alpha: 0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: MacosIcon(
CupertinoIcons.cart_fill,
color: CupertinoColors.white,
size: 19,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'В очереди: ${batch.count} ${batch.count == 1 ? "приложение" : "приложений"}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
const SizedBox(width: 10),
// Source badges
if (batch.pacmanApps.isNotEmpty)
_buildMiniBadge('Pacman: ${batch.pacmanApps.length}', const Color(0xFF1793D1)),
if (batch.aurApps.isNotEmpty)
_buildMiniBadge('AUR: ${batch.aurApps.length}', const Color(0xFFFF9500)),
if (batch.flatpakApps.isNotEmpty)
_buildMiniBadge('Flatpak: ${batch.flatpakApps.length}', const Color(0xFF4A90E2)),
],
),
const SizedBox(height: 2),
const Text(
'Все выбранные пакеты установятся за 1 нажатие с 1 запросом пароля',
style: TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => _showQueueDetails(context),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
MacosIcon(CupertinoIcons.list_bullet, size: 13),
SizedBox(width: 5),
Text('Список'),
],
),
),
const SizedBox(width: 10),
PushButton(
controlSize: ControlSize.regular,
onPressed: () => BatchInstallSheet.show(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(CupertinoIcons.bolt_fill, size: 14, color: Color(0xFFFFD60A)),
const SizedBox(width: 6),
Text('Установить всё (${batch.count})'),
],
),
),
],
),
);
},
);
}
Widget _buildMiniBadge(String text, Color color) {
return Container(
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(5),
),
child: Text(
text,
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
}
+928
View File
@@ -0,0 +1,928 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../rust/api/packages.dart';
import '../../rust/frb_generated.dart';
import '../../services/admin_service.dart';
/// Модальное адаптивное окно просмотра свойств пакета («поверх окна каталога»)
class PackageInspectorSheet extends StatefulWidget {
final String initialPackageName;
final BackendType initialBackend;
const PackageInspectorSheet({
super.key,
required this.initialPackageName,
required this.initialBackend,
});
static Future<void> show(
BuildContext context, {
required String packageName,
required BackendType backend,
}) {
return showMacosSheet(
context: context,
barrierDismissible: true,
builder: (context) => PackageInspectorSheet(
initialPackageName: packageName,
initialBackend: backend,
),
);
}
@override
State<PackageInspectorSheet> createState() => _PackageInspectorSheetState();
}
class _PackageInspectorSheetState extends State<PackageInspectorSheet> {
late String _currentPackageName;
late BackendType _currentBackend;
final List<({String name, BackendType backend})> _history = [];
PackageDetails? _details;
List<CachedPackageVersion> _cachedVersions = [];
List<String> _packageFiles = [];
bool _isLoading = true;
bool _isDeleting = false;
bool _isDowngrading = false;
int _selectedTab = 0; // 0: Обзор, 1: Зависимости, 2: Файлы, 3: Откат
String _fileSearchFilter = '';
@override
void initState() {
super.initState();
_currentPackageName = widget.initialPackageName;
_currentBackend = widget.initialBackend;
_loadDetails();
}
Future<void> _loadDetails() async {
setState(() {
_isLoading = true;
_fileSearchFilter = '';
});
try {
final data = await RustBridgeApi.getPackageDetails(
_currentBackend,
_currentPackageName,
);
List<CachedPackageVersion> cached = [];
List<String> files = [];
if (_currentBackend == BackendType.pacman) {
try {
cached = await RustBridgeApi.getCachedPackageVersions(_currentPackageName);
} catch (_) {}
try {
final res = await Process.run('pacman', ['-Qlq', _currentPackageName]);
if (res.exitCode == 0) {
files = (res.stdout as String)
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
} catch (_) {}
}
if (mounted) {
setState(() {
_details = data;
_cachedVersions = cached;
_packageFiles = files;
_isLoading = false;
});
}
} catch (_) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _navigateTo(String name, BackendType backend) {
setState(() {
_history.add((name: _currentPackageName, backend: _currentBackend));
_currentPackageName = name;
_currentBackend = backend;
});
_loadDetails();
}
void _goBack() {
if (_history.isEmpty) return;
final prev = _history.removeLast();
setState(() {
_currentPackageName = prev.name;
_currentBackend = prev.backend;
});
_loadDetails();
}
Future<void> _openProjectUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
void _confirmUninstall() {
showMacosAlertDialog(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.trash,
color: CupertinoColors.systemRed,
size: 48,
),
title: Text('Удалить $_currentPackageName?'),
message: Text(
'Вы уверены, что хотите удалить пакет $_currentPackageName? Связанные конфигурации и неиспользуемые зависимости могут быть затронуты.',
style: TextStyle(
color: MacosTheme.of(ctx).typography.body.color,
),
),
primaryButton: PushButton(
controlSize: ControlSize.large,
color: CupertinoColors.systemRed,
onPressed: () async {
Navigator.of(ctx).pop();
String? password;
if (_currentBackend != BackendType.flatpak) {
password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для удаления системного пакета $_currentPackageName требуются права суперпользователя.',
);
if (password == null) return;
}
setState(() => _isDeleting = true);
await RustBridgeApi.uninstallPackage(
_currentBackend,
_currentPackageName,
password: password,
);
if (mounted) {
setState(() => _isDeleting = false);
Navigator.of(context).pop();
}
},
child: const Text('Удалить', style: TextStyle(color: CupertinoColors.white)),
),
secondaryButton: PushButton(
controlSize: ControlSize.large,
secondary: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Отмена'),
),
),
);
}
Future<void> _downgradeTo(CachedPackageVersion target) async {
final confirmed = await showMacosAlertDialog<bool>(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.arrow_counterclockwise_circle_fill,
color: CupertinoColors.systemOrange,
size: 48,
),
title: Text('Откатить $_currentPackageName?'),
message: Text(
'Вы действительно хотите откатить пакет до версии ${target.version} из локального кэша?\nФайл: ${target.filePath.split('/').last}',
style: TextStyle(
color: MacosTheme.of(ctx).typography.body.color,
),
),
primaryButton: PushButton(
controlSize: ControlSize.large,
color: CupertinoColors.systemOrange,
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Откатить', style: TextStyle(color: CupertinoColors.white)),
),
secondaryButton: PushButton(
controlSize: ControlSize.large,
secondary: true,
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
),
);
if (confirmed != true) return;
if (!mounted) return;
final password = await AdminService.promptPassword(
context,
title: 'Forge запрашивает права администратора',
reason: 'Для отката пакета $_currentPackageName версии ${target.version} требуются права суперпользователя.',
);
if (password == null) return;
if (!mounted) return;
setState(() => _isDowngrading = true);
final stream = RustBridgeApi.downgradePackage(
_currentPackageName,
target.filePath,
password: password,
);
bool success = false;
await for (final event in stream) {
if (event.step == UpdateStep.done) {
success = true;
}
}
if (mounted) {
setState(() => _isDowngrading = false);
if (success) {
_loadDetails();
} else {
showMacosAlertDialog(
context: context,
builder: (ctx) => MacosAlertDialog(
appIcon: const MacosIcon(
CupertinoIcons.exclamationmark_triangle,
size: 48,
color: CupertinoColors.systemRed,
),
title: const Text('Ошибка отката'),
message: const Text('Не удалось установить выбранную версию пакета. Проверьте права и целостность архива.'),
primaryButton: PushButton(
controlSize: ControlSize.large,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
),
);
}
}
}
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes >= 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} else if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
final screenSize = MediaQuery.sizeOf(context);
final sheetWidth = (screenSize.width * 0.78).clamp(580.0, 840.0);
final sheetHeight = (screenSize.height * 0.84).clamp(500.0, 720.0);
return MacosSheet(
child: Container(
width: sheetWidth,
height: sheetHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: theme.canvasColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.45),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: Column(
children: [
_buildHeader(theme),
Expanded(
child: _isLoading
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ProgressCircle(radius: 16),
SizedBox(height: 14),
Text(
'Загрузка информации о пакете...',
style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
),
],
),
)
: _details == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MacosIcon(CupertinoIcons.exclamationmark_triangle, size: 40),
const SizedBox(height: 12),
Text('Пакет $_currentPackageName не найден в ${_currentBackend.name}'),
const SizedBox(height: 16),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
)
: _buildContent(theme),
),
],
),
),
);
}
Widget _buildHeader(MacosThemeData theme) {
final d = _details;
final isFlatpak = _currentBackend == BackendType.flatpak;
final isAur = _currentBackend == BackendType.aur;
final backendColor = isFlatpak
? const Color(0xFF4A90E2)
: isAur
? const Color(0xFFFF9500)
: const Color(0xFF1793D1);
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 18, 14),
decoration: BoxDecoration(
color: theme.canvasColor,
border: Border(
bottom: BorderSide(color: theme.dividerColor, width: 0.8),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_history.isNotEmpty) ...[
MacosIconButton(
icon: const MacosIcon(CupertinoIcons.chevron_left, size: 18),
onPressed: _goBack,
),
const SizedBox(width: 8),
],
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: backendColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: backendColor.withValues(alpha: 0.35),
width: 0.8,
),
),
child: Center(
child: MacosIcon(
isFlatpak
? CupertinoIcons.app_badge_fill
: isAur
? CupertinoIcons.layers_alt_fill
: CupertinoIcons.cube_box_fill,
color: backendColor,
size: 22,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
_currentPackageName,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: backendColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(5),
),
child: Text(
_currentBackend.name.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: backendColor,
),
),
),
if (d != null && d.repository.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(5),
),
child: Text(
d.repository,
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
),
),
],
],
),
const SizedBox(height: 3),
Text(
d != null ? 'Версия: ${d.version}' : 'Свойства и метаданные пакета',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
if (d?.url != null)
MacosTooltip(
message: 'Открыть домашнюю страницу пакета',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.globe, size: 16),
onPressed: () => _openProjectUrl(d!.url!),
),
),
MacosTooltip(
message: 'Скопировать название пакета',
child: MacosIconButton(
icon: const MacosIcon(CupertinoIcons.doc_on_clipboard, size: 15),
onPressed: () {
Clipboard.setData(ClipboardData(text: _currentPackageName));
},
),
),
const SizedBox(width: 8),
PushButton(
controlSize: ControlSize.regular,
secondary: true,
onPressed: () => Navigator.of(context).pop(),
child: const Text('Закрыть'),
),
],
),
);
}
Widget _buildContent(MacosThemeData theme) {
final d = _details!;
final tabs = [
'Обзор',
'Зависимости (${d.dependsOn.length})',
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
'Файлы (${_packageFiles.length})',
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
'Откат кэша (${_cachedVersions.length})',
];
return Column(
children: [
// Navigation bar & Action button
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: theme.canvasColor,
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 0.8)),
),
child: Row(
children: [
// Segmented Tabs
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(tabs.length, (idx) {
final isSelected = _selectedTab == idx;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
color: isSelected ? theme.primaryColor.withValues(alpha: 0.18) : CupertinoColors.transparent,
borderRadius: BorderRadius.circular(7),
onPressed: () => setState(() => _selectedTab = idx),
child: Text(
tabs[idx],
style: TextStyle(
fontSize: 12.5,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? theme.primaryColor : CupertinoColors.systemGrey,
),
),
),
);
}),
),
),
),
// Action buttons
PushButton(
controlSize: ControlSize.regular,
color: CupertinoColors.systemRed.withValues(alpha: 0.18),
onPressed: _isDeleting ? null : _confirmUninstall,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isDeleting)
const ProgressCircle(radius: 5)
else
const MacosIcon(CupertinoIcons.trash, size: 13, color: CupertinoColors.systemRed),
const SizedBox(width: 5),
const Text('Удалить', style: TextStyle(color: CupertinoColors.systemRed)),
],
),
),
],
),
),
// Tab Content
Expanded(
child: IndexedStack(
index: _selectedTab.clamp(0, tabs.length - 1),
children: [
_buildOverviewTab(theme, d),
_buildDependenciesTab(theme, d),
if (_currentBackend == BackendType.pacman && _packageFiles.isNotEmpty)
_buildFilesTab(theme, d),
if (_currentBackend == BackendType.pacman && _cachedVersions.isNotEmpty)
_buildDowngradeTab(theme, d),
],
),
),
],
);
}
Widget _buildOverviewTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
// Description Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.5), 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),
Text(
d.description.isNotEmpty ? d.description : 'Описание не предоставлено.',
style: const TextStyle(fontSize: 13.5, height: 1.45),
),
],
),
),
const SizedBox(height: 18),
// Metadata grid
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_buildInfoCard(theme, 'Размер установки', d.formattedInstalledSize, CupertinoIcons.cube_box),
if (d.downloadSizeBytes > 0)
_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),
if (d.buildDate != null)
_buildInfoCard(theme, 'Дата сборки', d.buildDate!, CupertinoIcons.calendar),
_buildInfoCard(theme, 'Репозиторий', d.repository, CupertinoIcons.archivebox),
_buildInfoCard(theme, 'Пакетный менеджер', d.backend.displayName, CupertinoIcons.layers),
],
),
],
);
}
Widget _buildInfoCard(MacosThemeData theme, String title, String value, IconData icon) {
const width = 230.0;
return Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.4), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MacosIcon(icon, size: 12, color: CupertinoColors.systemGrey),
const SizedBox(width: 6),
Text(
title,
style: const TextStyle(fontSize: 10.5, color: CupertinoColors.systemGrey, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildDependenciesTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
// 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(),
),
const SizedBox(height: 24),
// Required by
_buildSectionHeader('ИСПОЛЬЗУЕТСЯ ПАКЕТАМИ (${d.requiredBy.length})'),
const SizedBox(height: 10),
if (d.requiredBy.isEmpty)
const Text('Ни один установленный пакет не зависит от этого компонента.', style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 12.5))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: d.requiredBy.map((req) {
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _navigateTo(req, d.backend),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.dividerColor.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.dividerColor.withValues(alpha: 0.6), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
req,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, color: CupertinoColors.white),
),
const SizedBox(width: 4),
const MacosIcon(CupertinoIcons.arrow_right, size: 10, color: CupertinoColors.systemGrey),
],
),
),
);
}).toList(),
),
],
);
}
Widget _buildFilesTab(MacosThemeData theme, PackageDetails d) {
final filtered = _fileSearchFilter.isEmpty
? _packageFiles
: _packageFiles.where((f) => f.toLowerCase().contains(_fileSearchFilter.toLowerCase())).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: MacosTextField(
placeholder: 'Фильтровать файлы пакета (${_packageFiles.length} всего)...',
prefix: const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: MacosIcon(CupertinoIcons.search, size: 13, color: CupertinoColors.systemGrey),
),
onChanged: (val) => setState(() => _fileSearchFilter = val),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
itemCount: filtered.length,
itemBuilder: (context, idx) {
final path = filtered[idx];
final isDir = path.endsWith('/');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.5),
child: Row(
children: [
MacosIcon(
isDir ? CupertinoIcons.folder_fill : CupertinoIcons.doc,
size: 13,
color: isDir ? const Color(0xFF0A84FF) : CupertinoColors.systemGrey,
),
const SizedBox(width: 8),
Expanded(
child: Text(
path,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
color: isDir ? CupertinoColors.white : CupertinoColors.systemGrey,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
),
),
],
);
}
Widget _buildDowngradeTab(MacosThemeData theme, PackageDetails d) {
return ListView(
padding: const EdgeInsets.all(22),
children: [
_buildSectionHeader('СОХРАНЕННЫЕ ВЕРСИИ В /var/cache/pacman/pkg (${_cachedVersions.length})'),
const SizedBox(height: 6),
const Text(
'Вы можете откатить данный пакет до любой ранее установленной версии из локального архива в один клик:',
style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey),
),
const SizedBox(height: 14),
..._cachedVersions.map((cv) {
final isCurrent = cv.version == d.version;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.1) : theme.dividerColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCurrent ? const Color(0xFF0A84FF).withValues(alpha: 0.35) : theme.dividerColor.withValues(alpha: 0.5),
width: 0.8,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
cv.version,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w500,
color: isCurrent ? const Color(0xFF0A84FF) : CupertinoColors.white,
),
),
if (isCurrent) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text('Текущая', style: TextStyle(fontSize: 10, color: Color(0xFF0A84FF), fontWeight: FontWeight.bold)),
),
],
],
),
const SizedBox(height: 3),
Text(
'${_formatBytes(cv.fileSizeBytes)} • ${cv.modifiedTime.toLocal().toString().split('.').first}',
style: const TextStyle(fontSize: 11, color: CupertinoColors.systemGrey),
),
],
),
),
if (!isCurrent)
PushButton(
controlSize: ControlSize.regular,
color: CupertinoColors.systemOrange.withValues(alpha: 0.25),
onPressed: _isDowngrading ? null : () => _downgradeTo(cv),
child: _isDowngrading
? const ProgressCircle(radius: 5)
: const Text('Откатить', style: TextStyle(color: CupertinoColors.systemOrange, fontWeight: FontWeight.bold)),
),
],
),
);
}),
],
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: CupertinoColors.systemGrey,
),
);
}
}
/// Совместимая обёртка для прежних мест вызова
class PackageInspector extends StatelessWidget {
final String packageName;
final BackendType backend;
final VoidCallback onClose;
final Function(String name, BackendType backend)? onNavigateToPackage;
const PackageInspector({
super.key,
required this.packageName,
required this.backend,
required this.onClose,
this.onNavigateToPackage,
});
@override
Widget build(BuildContext context) {
return PackageInspectorSheet(
initialPackageName: packageName,
initialBackend: backend,
);
}
}
@@ -0,0 +1,531 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/aur_service.dart';
class SpotlightItem {
final String name;
final String version;
final String description;
final BackendType backend;
final bool isInstalled;
final int? aurVotes;
const SpotlightItem({
required this.name,
required this.version,
required this.description,
required this.backend,
this.isInstalled = false,
this.aurVotes,
});
}
class SpotlightSearchDialog extends StatefulWidget {
final Function(String name, BackendType backend) onSelectPackage;
const SpotlightSearchDialog({
super.key,
required this.onSelectPackage,
});
static Future<void> show(
BuildContext context, {
required Function(String name, BackendType backend) onSelectPackage,
}) {
return showMacosSheet<void>(
context: context,
barrierDismissible: true,
builder: (_) => SpotlightSearchDialog(onSelectPackage: onSelectPackage),
);
}
@override
State<SpotlightSearchDialog> createState() => _SpotlightSearchDialogState();
}
class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
Timer? _debounceTimer;
bool _isSearching = false;
List<SpotlightItem> _results = [];
int _selectedIndex = 0;
String _activeFilter = 'Все';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
if (query.trim().isEmpty) {
setState(() {
_results.clear();
_isSearching = false;
_selectedIndex = 0;
});
return;
}
_debounceTimer = Timer(const Duration(milliseconds: 250), () {
_performSearch(query.trim());
});
}
Future<void> _performSearch(String query) async {
setState(() => _isSearching = true);
final List<SpotlightItem> items = [];
try {
// 1. Поиск в официальных репозиториях Pacman
if (_activeFilter == 'Все' || _activeFilter == 'Pacman') {
final res = await Process.run('pacman', ['-Ss', query], environment: {'LC_ALL': 'C'});
if (res.exitCode == 0) {
final lines = res.stdout.toString().split('\n');
for (int i = 0; i < lines.length - 1; i += 2) {
final header = lines[i].trim();
if (header.isEmpty) continue;
final parts = header.split(RegExp(r'\s+'));
if (parts.isEmpty) continue;
final repoName = parts[0];
final name = repoName.contains('/') ? repoName.split('/').last : repoName;
final version = parts.length > 1 ? parts[1] : '';
final isInst = header.contains('[installed]');
final desc = (i + 1 < lines.length) ? lines[i + 1].trim() : '';
items.add(SpotlightItem(
name: name,
version: version,
description: desc,
backend: BackendType.pacman,
isInstalled: isInst,
));
if (items.length >= 15) break;
}
}
}
// 2. Поиск в AUR
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
final aurList = await AurService.search(query);
for (final aur in aurList.take(15)) {
items.add(SpotlightItem(
name: aur.name,
version: aur.version,
description: aur.description,
backend: BackendType.aur,
aurVotes: aur.numVotes,
));
}
}
// 3. Поиск во Flatpak
if (_activeFilter == 'Все' || _activeFilter == 'Flatpak') {
final fpRes = await Process.run('flatpak', ['search', query, '--columns=app,name,version,description']);
if (fpRes.exitCode == 0) {
final lines = fpRes.stdout.toString().trim().split('\n');
for (final line in lines.take(10)) {
final parts = line.split('\t');
if (parts.length >= 2) {
final id = parts[0].trim();
final name = parts[1].trim().isNotEmpty ? parts[1].trim() : id;
final ver = parts.length > 2 ? parts[2].trim() : 'latest';
final desc = parts.length > 3 ? parts[3].trim() : '';
items.add(SpotlightItem(
name: name,
version: ver,
description: desc,
backend: BackendType.flatpak,
));
}
}
}
}
} catch (_) {}
if (mounted) {
setState(() {
_results = items;
_isSearching = false;
_selectedIndex = 0;
});
}
}
void _selectCurrent() {
if (_results.isNotEmpty && _selectedIndex < _results.length) {
final item = _results[_selectedIndex];
Navigator.of(context).pop();
widget.onSelectPackage(item.name, item.backend);
}
}
void _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_results.isNotEmpty) {
setState(() {
_selectedIndex = (_selectedIndex + 1).clamp(0, _results.length - 1);
});
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (_results.isNotEmpty) {
setState(() {
_selectedIndex = (_selectedIndex - 1).clamp(0, _results.length - 1);
});
}
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
_selectCurrent();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) {
final theme = MacosTheme.of(context);
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: _handleKeyEvent,
child: Center(
child: Container(
width: 580,
constraints: const BoxConstraints(maxHeight: 520),
margin: const EdgeInsets.only(top: 60),
decoration: BoxDecoration(
color: const Color(0xFF1E1E20).withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.16),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.65),
blurRadius: 40,
offset: const Offset(0, 16),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Search Input Row
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: Row(
children: [
const MacosIcon(
CupertinoIcons.search,
size: 20,
color: Color(0xFF0A84FF),
),
const SizedBox(width: 12),
Expanded(
child: CupertinoTextField(
controller: _searchController,
focusNode: _focusNode,
onChanged: _onSearchChanged,
onSubmitted: (_) => _selectCurrent(),
placeholder: 'Поиск пакетов в Arch Linux, AUR, Flathub...',
placeholderStyle: const TextStyle(
color: CupertinoColors.systemGrey,
fontSize: 15,
),
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 15,
),
decoration: null,
),
),
if (_isSearching)
const ProgressCircle(radius: 8)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(5),
),
child: const Text(
'ESC',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: CupertinoColors.systemGrey,
),
),
),
],
),
),
// Filter Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: ['Все', 'Pacman', 'AUR', 'Flatpak'].map((filter) {
final isSel = filter == _activeFilter;
return Padding(
padding: const EdgeInsets.only(right: 6, bottom: 8),
child: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
setState(() => _activeFilter = filter);
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: isSel ? const Color(0xFF0A84FF) : const Color(0xFF2C2C2E),
borderRadius: BorderRadius.circular(12),
),
child: Text(
filter,
style: TextStyle(
fontSize: 11,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? CupertinoColors.white : CupertinoColors.systemGrey,
),
),
),
),
);
}).toList(),
),
),
Container(
height: 0.8,
color: CupertinoColors.white.withValues(alpha: 0.08),
),
// Results List
if (_results.isEmpty)
Container(
height: 160,
alignment: Alignment.center,
child: Text(
_searchController.text.isEmpty
? 'Начните вводить название утилиты или приложения...'
: _isSearching
? 'Поиск...'
: 'Ничего не найдено',
style: const TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
),
)
else
Flexible(
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: _results.length,
itemBuilder: (context, idx) {
final item = _results[idx];
final isSelected = idx == _selectedIndex;
return GestureDetector(
onTap: () {
Navigator.of(context).pop();
widget.onSelectPackage(item.name, item.backend);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF0A84FF).withValues(alpha: 0.25)
: CupertinoColors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
_buildBackendIcon(item.backend),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
item.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.5,
color: CupertinoColors.white,
),
),
const SizedBox(width: 8),
Text(
item.version,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const Spacer(),
if (item.aurVotes != null) ...[
const MacosIcon(
CupertinoIcons.star_fill,
size: 11,
color: CupertinoColors.systemYellow,
),
const SizedBox(width: 3),
Text(
'${item.aurVotes}',
style: const TextStyle(
fontSize: 11,
color: CupertinoColors.systemYellow,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),
],
_buildBackendBadge(item.backend),
],
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
item.description,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
);
},
),
),
// Bottom hint
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.canvasColor.withValues(alpha: 0.5),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
child: const Row(
children: [
Text(
'↑↓ для навигации • Enter чтобы открыть • ESC для выхода',
style: TextStyle(
fontSize: 11,
color: CupertinoColors.systemGrey,
),
),
],
),
),
],
),
),
),
);
}
Widget _buildBackendIcon(BackendType backend) {
IconData icon;
Color color;
switch (backend) {
case BackendType.pacman:
icon = CupertinoIcons.cube_box_fill;
color = const Color(0xFF1793D1);
break;
case BackendType.aur:
icon = CupertinoIcons.bolt_fill;
color = const Color(0xFFFF9500);
break;
case BackendType.flatpak:
icon = CupertinoIcons.app_badge_fill;
color = const Color(0xFFAF52DE);
break;
}
return Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: MacosIcon(icon, color: color, size: 15),
),
);
}
Widget _buildBackendBadge(BackendType backend) {
Color color;
String label;
switch (backend) {
case BackendType.pacman:
color = const Color(0xFF1793D1);
label = 'Pacman';
break;
case BackendType.aur:
color = const Color(0xFFFF9500);
label = 'AUR';
break;
case BackendType.flatpak:
color = const Color(0xFFAF52DE);
label = 'Flatpak';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
}
@@ -0,0 +1,292 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../services/app_badge_controller.dart';
class StorageInfo {
final int totalBytes;
final int freeBytes;
final int pacmanCacheBytes;
final int journalBytes;
final int flatpakBytes;
final int pacmanInstalledBytes;
const StorageInfo({
required this.totalBytes,
required this.freeBytes,
required this.pacmanCacheBytes,
required this.journalBytes,
required this.flatpakBytes,
required this.pacmanInstalledBytes,
});
int get usedBytes => (totalBytes - freeBytes).clamp(0, totalBytes);
int get otherBytes {
final accounted = pacmanCacheBytes + journalBytes + flatpakBytes + pacmanInstalledBytes;
return (usedBytes - accounted).clamp(0, usedBytes);
}
}
class StorageBreakdownBar extends StatefulWidget {
final int pacmanCacheBytes;
final int journalBytes;
const StorageBreakdownBar({
super.key,
required this.pacmanCacheBytes,
required this.journalBytes,
});
@override
State<StorageBreakdownBar> createState() => _StorageBreakdownBarState();
}
class _StorageBreakdownBarState extends State<StorageBreakdownBar> {
StorageInfo? _storageInfo;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadDiskStats();
}
@override
void didUpdateWidget(StorageBreakdownBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pacmanCacheBytes != widget.pacmanCacheBytes ||
oldWidget.journalBytes != widget.journalBytes) {
_loadDiskStats();
}
}
Future<void> _loadDiskStats() async {
try {
int total = 0;
int free = 0;
// 1. Root partition size via df -B1 /
final dfRes = await Process.run('df', ['-B1', '/']);
if (dfRes.exitCode == 0) {
final lines = dfRes.stdout.toString().trim().split('\n');
if (lines.length >= 2) {
final parts = lines[1].trim().split(RegExp(r'\s+'));
if (parts.length >= 4) {
total = int.tryParse(parts[1]) ?? 0;
free = int.tryParse(parts[3]) ?? 0;
}
}
}
// 2. Flatpak size estimation (/var/lib/flatpak)
int flatpak = 0;
try {
final fpRes = await Process.run('du', ['-sb', '/var/lib/flatpak']);
if (fpRes.exitCode == 0) {
final first = fpRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
flatpak = int.tryParse(first) ?? 0;
}
} catch (_) {}
// 3. Pacman installed size estimation
int pacmanInstalled = 0;
try {
final usrRes = await Process.run('du', ['-sb', '/usr']);
if (usrRes.exitCode == 0) {
final first = usrRes.stdout.toString().trim().split(RegExp(r'\s+')).first;
final usrBytes = int.tryParse(first) ?? 0;
pacmanInstalled = (usrBytes * 0.85).toInt();
}
} catch (_) {}
if (mounted) {
setState(() {
_storageInfo = StorageInfo(
totalBytes: total > 0 ? total : 500 * 1024 * 1024 * 1024,
freeBytes: free > 0 ? free : 350 * 1024 * 1024 * 1024,
pacmanCacheBytes: widget.pacmanCacheBytes,
journalBytes: widget.journalBytes,
flatpakBytes: flatpak,
pacmanInstalledBytes: pacmanInstalled,
);
_isLoading = false;
});
}
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading || _storageInfo == null) {
return const SizedBox.shrink();
}
final info = _storageInfo!;
final total = info.totalBytes > 0 ? info.totalBytes : 1;
final pctPacman = (info.pacmanInstalledBytes / total).clamp(0.0, 1.0);
final pctFlatpak = (info.flatpakBytes / total).clamp(0.0, 1.0);
final pctCache = (info.pacmanCacheBytes / total).clamp(0.0, 1.0);
final pctJournal = (info.journalBytes / total).clamp(0.0, 1.0);
final pctOther = (info.otherBytes / total).clamp(0.0, 1.0);
final pctFree = (info.freeBytes / total).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.08),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MacosIcon(
CupertinoIcons.circle_grid_hex_fill,
color: Color(0xFF0A84FF),
size: 16,
),
const SizedBox(width: 8),
const Text(
'Распределение дискового пространства',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const Spacer(),
Text(
'Свободно ${AppBadgeController.formatBytes(info.freeBytes)} из ${AppBadgeController.formatBytes(info.totalBytes)}',
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.systemGrey,
),
),
],
),
const SizedBox(height: 14),
// macOS Storage Segmented Bar
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: SizedBox(
height: 14,
child: Row(
children: [
if (pctPacman > 0)
Flexible(
flex: (pctPacman * 1000).toInt(),
child: Container(color: const Color(0xFF0A84FF)),
),
if (pctFlatpak > 0)
Flexible(
flex: (pctFlatpak * 1000).toInt(),
child: Container(color: const Color(0xFFAF52DE)),
),
if (pctCache > 0)
Flexible(
flex: (pctCache * 1000).toInt(),
child: Container(color: const Color(0xFFFF9500)),
),
if (pctJournal > 0)
Flexible(
flex: (pctJournal * 1000).toInt(),
child: Container(color: const Color(0xFFFFD60A)),
),
if (pctOther > 0)
Flexible(
flex: (pctOther * 1000).toInt(),
child: Container(color: const Color(0xFF48484A)),
),
if (pctFree > 0)
Flexible(
flex: (pctFree * 1000).toInt(),
child: Container(color: const Color(0xFF2C2C2E)),
),
],
),
),
),
const SizedBox(height: 14),
// Legend
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem(
color: const Color(0xFF0A84FF),
label: 'Пакеты Pacman',
size: AppBadgeController.formatBytes(info.pacmanInstalledBytes),
),
_buildLegendItem(
color: const Color(0xFFAF52DE),
label: 'Flatpak',
size: AppBadgeController.formatBytes(info.flatpakBytes),
),
_buildLegendItem(
color: const Color(0xFFFF9500),
label: 'Кэш пакетов',
size: AppBadgeController.formatBytes(info.pacmanCacheBytes),
),
_buildLegendItem(
color: const Color(0xFFFFD60A),
label: 'Логи systemd',
size: AppBadgeController.formatBytes(info.journalBytes),
),
_buildLegendItem(
color: const Color(0xFF2C2C2E),
label: 'Свободно',
size: AppBadgeController.formatBytes(info.freeBytes),
),
],
),
],
),
);
}
Widget _buildLegendItem({
required Color color,
required String label,
required String size,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 11.5,
color: CupertinoColors.systemGrey,
),
),
const SizedBox(width: 4),
Text(
size,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
],
);
}
}