feat: Initial commit of Forge package manager (Flutter + Rust)
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('“', '"')
|
||||
.replaceAll('”', '"')
|
||||
.replaceAll('–', '–')
|
||||
.replaceAll('—', '—')
|
||||
.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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
|
||||
// Форматируем списки и абзацы
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, 'Не удалось создать снимок');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user