Files
Forge/lib/src/ui/screens/cleaner_screen.dart
T

530 lines
19 KiB
Dart

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),
),
);
},
),
),
],
),
);
}
}