- Changed window size to mobile phone format (400x800) - Removed width condition for ActiveProxyFooter - now always visible - Added run-umbrix.sh launch script with icon copying - Stats cards now display on all screen sizes
431 lines
20 KiB
Dart
431 lines
20 KiB
Dart
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
import 'package:fpdart/fpdart.dart';
|
||
import 'package:gap/gap.dart';
|
||
import 'package:umbrix/core/localization/translations.dart';
|
||
import 'package:umbrix/core/model/failures.dart';
|
||
import 'package:umbrix/core/preferences/general_preferences.dart';
|
||
import 'package:umbrix/core/telegram/telegram_logger.dart';
|
||
import 'package:umbrix/core/telegram_config.dart';
|
||
import 'package:umbrix/core/widget/adaptive_icon.dart';
|
||
import 'package:umbrix/features/common/nested_app_bar.dart';
|
||
import 'package:umbrix/features/config_option/data/config_option_repository.dart';
|
||
import 'package:umbrix/features/log/data/log_data_providers.dart';
|
||
import 'package:umbrix/features/log/model/log_level.dart';
|
||
import 'package:umbrix/features/log/overview/logs_overview_notifier.dart';
|
||
import 'package:umbrix/utils/utils.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:sliver_tools/sliver_tools.dart';
|
||
|
||
class LogsOverviewPage extends HookConsumerWidget with PresLogger {
|
||
const LogsOverviewPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final t = ref.watch(translationsProvider);
|
||
final state = ref.watch(logsOverviewNotifierProvider);
|
||
final notifier = ref.watch(logsOverviewNotifierProvider.notifier);
|
||
|
||
final debug = ref.watch(debugModeNotifierProvider);
|
||
final pathResolver = ref.watch(logPathResolverProvider);
|
||
|
||
final filterController = useTextEditingController(text: state.filter);
|
||
|
||
// UMBRIX: Кнопки логов доступны всегда (не только в debug режиме)
|
||
final List<PopupMenuEntry> popupButtons = [
|
||
PopupMenuItem(
|
||
child: Text(t.logs.shareCoreLogs),
|
||
onTap: () async {
|
||
await UriUtils.tryShareOrLaunchFile(
|
||
Uri.parse(pathResolver.coreFile().path),
|
||
fileOrDir: pathResolver.directory.uri,
|
||
);
|
||
},
|
||
),
|
||
PopupMenuItem(
|
||
child: Text(t.logs.shareAppLogs),
|
||
onTap: () async {
|
||
await UriUtils.tryShareOrLaunchFile(
|
||
Uri.parse(pathResolver.appFile().path),
|
||
fileOrDir: pathResolver.directory.uri,
|
||
);
|
||
},
|
||
),
|
||
];
|
||
|
||
// Добавляем кнопку отправки в Telegram только если настроен
|
||
if (TelegramConfig.isConfigured) {
|
||
popupButtons.addAll([
|
||
const PopupMenuDivider(),
|
||
PopupMenuItem(
|
||
child: const Row(
|
||
children: [
|
||
Icon(FluentIcons.send_20_regular, size: 18),
|
||
Gap(8),
|
||
Text('Отправить разработчику'),
|
||
],
|
||
),
|
||
onTap: () async {
|
||
// Показываем диалог с подтверждением
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Отправить логи?'),
|
||
content: const Text(
|
||
'Логи будут отправлены анонимно разработчику Umbrix через Telegram. '
|
||
'Это поможет улучшить приложение.\n\n'
|
||
'Отправляется только общая информация об ОС, без личных данных.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: const Text('Отправить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true) {
|
||
// Показываем индикатор загрузки
|
||
if (!context.mounted) return;
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => const Center(
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
);
|
||
|
||
try {
|
||
final telegramLogger = TelegramLogger();
|
||
final deviceInfo = TelegramLogger.getAnonymousDeviceInfo();
|
||
|
||
// Отправляем файл логов
|
||
final success = await telegramLogger.sendLogsAsFile(
|
||
pathResolver.appFile(),
|
||
deviceInfo: deviceInfo,
|
||
);
|
||
|
||
if (!context.mounted) return;
|
||
Navigator.of(context).pop(); // Закрываем индикатор
|
||
|
||
// Показываем результат
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
success ? '✅ Логи успешно отправлены! Спасибо за помощь!' : '❌ Ошибка отправки. Проверьте интернет-соединение.',
|
||
),
|
||
duration: const Duration(seconds: 3),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
Navigator.of(context).pop(); // Закрываем индикатор
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('❌ Ошибка отправки логов'),
|
||
duration: Duration(seconds: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
return Scaffold(
|
||
body: NestedScrollView(
|
||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||
return <Widget>[
|
||
SliverOverlapAbsorber(
|
||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||
sliver: MultiSliver(
|
||
children: [
|
||
NestedAppBar(
|
||
forceElevated: innerBoxIsScrolled,
|
||
title: Text(t.logs.pageTitle),
|
||
actions: [
|
||
if (state.paused)
|
||
IconButton(
|
||
onPressed: notifier.resume,
|
||
icon: const Icon(FluentIcons.play_20_regular),
|
||
tooltip: t.logs.resumeTooltip,
|
||
iconSize: 20,
|
||
)
|
||
else
|
||
IconButton(
|
||
onPressed: notifier.pause,
|
||
icon: const Icon(FluentIcons.pause_20_regular),
|
||
tooltip: t.logs.pauseTooltip,
|
||
iconSize: 20,
|
||
),
|
||
IconButton(
|
||
onPressed: notifier.clear,
|
||
icon: const Icon(FluentIcons.delete_lines_20_regular),
|
||
tooltip: t.logs.clearTooltip,
|
||
iconSize: 20,
|
||
),
|
||
if (popupButtons.isNotEmpty)
|
||
PopupMenuButton(
|
||
icon: Icon(AdaptiveIcon(context).more),
|
||
itemBuilder: (context) {
|
||
return popupButtons;
|
||
},
|
||
),
|
||
],
|
||
),
|
||
SliverPinnedHeader(
|
||
child: Container(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
child: Column(
|
||
children: [
|
||
// Карточка настроек логирования
|
||
Container(
|
||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
FluentIcons.settings_20_regular,
|
||
size: 20,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
const Gap(8),
|
||
Text(
|
||
'Настройки логирования',
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Gap(12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Уровень детализации',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
const Gap(4),
|
||
Text(
|
||
'Влияет на объём записываемой информации',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Gap(2),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
FluentIcons.shield_checkmark_20_regular,
|
||
size: 14,
|
||
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
|
||
),
|
||
const Gap(4),
|
||
Text(
|
||
'Макс. 1000 записей • 5 МБ на файл',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Gap(12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.primaryContainer,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: DropdownButton<LogLevel>(
|
||
value: ref.watch(ConfigOptions.logLevel),
|
||
onChanged: (value) {
|
||
if (value != null) {
|
||
ref.read(ConfigOptions.logLevel.notifier).update(value);
|
||
}
|
||
},
|
||
underline: const SizedBox(),
|
||
isDense: true,
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 13,
|
||
),
|
||
dropdownColor: Theme.of(context).colorScheme.primaryContainer,
|
||
items: LogLevel.choices.map((level) {
|
||
return DropdownMenuItem(
|
||
value: level,
|
||
child: Text(level.name.toUpperCase()),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Фильтры поиска
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Flexible(
|
||
child: TextFormField(
|
||
controller: filterController,
|
||
onChanged: notifier.filterMessage,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
hintText: t.logs.filterHint,
|
||
prefixIcon: const Icon(FluentIcons.search_20_regular, size: 18),
|
||
),
|
||
),
|
||
),
|
||
const Gap(12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: DropdownButton<Option<LogLevel>>(
|
||
value: optionOf(state.levelFilter),
|
||
onChanged: (v) {
|
||
if (v == null) return;
|
||
notifier.filterLevel(v.toNullable());
|
||
},
|
||
underline: const SizedBox(),
|
||
borderRadius: BorderRadius.circular(8),
|
||
isDense: true,
|
||
items: [
|
||
DropdownMenuItem(
|
||
value: none(),
|
||
child: Text(t.logs.allLevelsFilter),
|
||
),
|
||
...LogLevel.choices.map(
|
||
(e) => DropdownMenuItem(
|
||
value: some(e),
|
||
child: Text(e.name),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
},
|
||
body: Builder(
|
||
builder: (context) {
|
||
return CustomScrollView(
|
||
primary: false,
|
||
reverse: true,
|
||
slivers: <Widget>[
|
||
switch (state.logs) {
|
||
AsyncData(value: final logs) => SliverList.builder(
|
||
itemCount: logs.length,
|
||
itemBuilder: (context, index) {
|
||
final log = logs[index];
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 4,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (log.level != null)
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
log.level!.name.toUpperCase(),
|
||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||
color: log.level!.color,
|
||
),
|
||
),
|
||
if (log.time != null)
|
||
Text(
|
||
log.time!.toString(),
|
||
style: Theme.of(context).textTheme.labelSmall,
|
||
),
|
||
],
|
||
),
|
||
Text(
|
||
log.message,
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (index != 0)
|
||
const Divider(
|
||
indent: 16,
|
||
endIndent: 16,
|
||
height: 4,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
AsyncError(:final error) => SliverErrorBodyPlaceholder(
|
||
t.presentShortError(error),
|
||
),
|
||
_ => const SliverLoadingBodyPlaceholder(),
|
||
},
|
||
SliverOverlapInjector(
|
||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||
context,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|