feat(logs): copy selected entries #511

This commit is contained in:
zarzet
2026-08-11 16:12:05 +07:00
parent e750c195ca
commit dacc5abde0
3 changed files with 394 additions and 141 deletions
+289 -141
View File
@@ -24,6 +24,8 @@ class _LogScreenState extends State<LogScreen> {
String _selectedLevel = 'ALL';
String _searchQuery = '';
bool _autoScroll = true;
bool _selectionMode = false;
final Set<LogEntry> _selectedEntries = <LogEntry>{};
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
@@ -45,6 +47,13 @@ class _LogScreenState extends State<LogScreen> {
void _onLogUpdate() {
if (mounted) {
if (_selectionMode) {
final currentEntries = LogBuffer().entries.toSet();
_selectedEntries.removeWhere(
(entry) => !currentEntries.contains(entry),
);
if (_selectedEntries.isEmpty) _selectionMode = false;
}
setState(() {});
if (_autoScroll && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -84,6 +93,65 @@ class _LogScreenState extends State<LogScreen> {
}
}
void _enterSelectionMode(LogEntry entry) {
setState(() {
_selectionMode = true;
_autoScroll = false;
_selectedEntries.add(entry);
});
}
void _toggleEntrySelection(LogEntry entry) {
if (!_selectionMode) return;
setState(() {
if (!_selectedEntries.add(entry)) {
_selectedEntries.remove(entry);
}
if (_selectedEntries.isEmpty) _selectionMode = false;
});
}
void _exitSelectionMode() {
setState(() {
_selectionMode = false;
_selectedEntries.clear();
});
}
void _toggleSelectAllVisible() {
final visibleLogs = _filteredLogs;
final allVisibleSelected =
visibleLogs.isNotEmpty && visibleLogs.every(_selectedEntries.contains);
setState(() {
if (allVisibleSelected) {
_selectedEntries.removeAll(visibleLogs);
} else {
_selectedEntries.addAll(visibleLogs);
}
_selectionMode = _selectedEntries.isNotEmpty;
});
}
Future<void> _copySelectedLogs() async {
final selectedInLogOrder = LogBuffer().entries
.where(_selectedEntries.contains)
.toList(growable: false);
if (selectedInLogOrder.isEmpty) return;
await Clipboard.setData(
ClipboardData(text: formatLogEntries(selectedInLogOrder)),
);
if (!mounted) return;
_exitSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}
void _shareLogs() async {
final logs = await LogBuffer().exportWithDeviceInfo();
SharePlus.instance.share(
@@ -104,6 +172,8 @@ class _LogScreenState extends State<LogScreen> {
),
FilledButton(
onPressed: () {
_selectionMode = false;
_selectedEntries.clear();
LogBuffer().clear();
Navigator.pop(context);
},
@@ -135,63 +205,104 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs;
return PopScope(
canPop: true,
canPop: !_selectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _selectionMode) _exitSelectionMode();
},
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
AppSliverHeader.page(
title: context.l10n.logTitle,
actions: [
IconButton(
icon: Icon(
_autoScroll
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
tooltip: _autoScroll
? context.l10n.logAutoScrollOn
: context.l10n.logAutoScrollOff,
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: context.l10n.logCopyLogs,
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'share',
child: ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
title: _selectionMode
? context.l10n.selectionSelected(_selectedEntries.length)
: context.l10n.logTitle,
leading: _selectionMode
? IconButton(
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
onPressed: _exitSelectionMode,
)
: null,
actions: _selectionMode
? [
IconButton(
icon: Icon(
logs.isNotEmpty &&
logs.every(_selectedEntries.contains)
? Icons.deselect
: Icons.select_all,
),
tooltip:
logs.isNotEmpty &&
logs.every(_selectedEntries.contains)
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
onPressed: _toggleSelectAllVisible,
),
),
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
IconButton(
icon: const Icon(Icons.copy),
tooltip: context.l10n.logCopyLogs,
onPressed: _selectedEntries.isEmpty
? null
: _copySelectedLogs,
),
),
],
),
],
]
: [
IconButton(
icon: Icon(
_autoScroll
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
tooltip: _autoScroll
? context.l10n.logAutoScrollOn
: context.l10n.logAutoScrollOff,
onPressed: () =>
setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: context.l10n.logCopyLogs,
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: MaterialLocalizations.of(
context,
).showMenuTooltip,
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'share',
child: ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
SliverToBoxAdapter(
@@ -374,6 +485,10 @@ class _LogScreenState extends State<LogScreen> {
entry: log,
levelColor: _getLevelColor(log.level, colorScheme),
showDivider: index < logs.length - 1,
selectionMode: _selectionMode,
selected: _selectedEntries.contains(log),
onTap: () => _toggleEntrySelection(log),
onLongPress: () => _enterSelectionMode(log),
);
}),
],
@@ -392,11 +507,19 @@ class _LogEntryTile extends StatelessWidget {
final LogEntry entry;
final Color levelColor;
final bool showDivider;
final bool selectionMode;
final bool selected;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _LogEntryTile({
required this.entry,
required this.levelColor,
this.showDivider = true,
required this.selectionMode,
required this.selected,
required this.onTap,
required this.onLongPress,
});
@override
@@ -407,104 +530,129 @@ class _LogEntryTile extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isError
? colorScheme.errorContainer.withValues(alpha: 0.2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
Semantics(
container: true,
selected: selected,
onTap: selectionMode ? onTap : null,
onLongPress: onLongPress,
child: InkWell(
excludeFromSemantics: true,
onTap: selectionMode ? onTap : null,
onLongPress: onLongPress,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: selected
? colorScheme.primaryContainer.withValues(alpha: 0.5)
: isError
? colorScheme.errorContainer.withValues(alpha: 0.2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.formattedTime,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
entry.level,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: levelColor,
),
),
),
if (entry.isFromGo) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
context.l10n.actionGo,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.teal,
Row(
children: [
Text(
entry.formattedTime,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
entry.level,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: levelColor,
),
),
),
if (entry.isFromGo) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
context.l10n.actionGo,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.teal,
),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
entry.tag,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
if (selectionMode) ...[
const SizedBox(width: 8),
Icon(
selected
? Icons.check_circle
: Icons.radio_button_unchecked,
size: 20,
color: selected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
],
],
),
const SizedBox(height: 6),
Text(
entry.previewMessage,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
height: 1.4,
),
),
if (entry.previewError != null) ...[
const SizedBox(height: 4),
Text(
entry.previewError!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.error,
height: 1.3,
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
entry.tag,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
Text(
entry.previewMessage,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
height: 1.4,
),
),
if (entry.previewError != null) ...[
const SizedBox(height: 4),
Text(
entry.previewError!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.error,
height: 1.3,
),
),
],
],
),
),
),
if (showDivider)
+4
View File
@@ -101,6 +101,10 @@ class LogEntry {
}
}
String formatLogEntries(Iterable<LogEntry> entries) {
return entries.map((entry) => entry.toString()).join('\n');
}
class LogBuffer extends ChangeNotifier {
static final LogBuffer _instance = LogBuffer._internal();
factory LogBuffer() => _instance;
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/logger.dart';
void main() {
test('selected log export keeps chronological lines only', () {
final entries = [
LogEntry(
timestamp: DateTime(2026, 8, 11, 10, 20, 30, 40),
level: 'INFO',
tag: 'Search',
message: 'first',
),
LogEntry(
timestamp: DateTime(2026, 8, 11, 10, 20, 31, 50),
level: 'ERROR',
tag: 'Download',
message: 'second',
error: 'failed',
isFromGo: true,
),
];
expect(
formatLogEntries(entries),
'[10:20:30.040] [INFO] [Search] first\n'
'[10:20:31.050] [ERROR] [Go] [Download] second | failed',
);
});
testWidgets('long press selects multiple log rows for copying', (
tester,
) async {
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
clipboardText = (call.arguments as Map<Object?, Object?>)['text']
?.toString();
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
LogBuffer().clear();
addTearDown(LogBuffer().clear);
LogBuffer().add(
LogEntry(
timestamp: DateTime(2026, 8, 11, 10),
level: 'ERROR',
tag: 'One',
message: 'first selected message',
),
);
LogBuffer().add(
LogEntry(
timestamp: DateTime(2026, 8, 11, 10, 0, 1),
level: 'ERROR',
tag: 'Two',
message: 'second selected message',
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LogScreen(),
),
);
await tester.longPress(find.text('first selected message'));
await tester.pump();
expect(find.text('1 selected'), findsOneWidget);
await tester.ensureVisible(find.text('second selected message'));
await tester.pump();
await tester.tap(find.text('second selected message'));
await tester.pump();
expect(find.text('2 selected'), findsOneWidget);
await tester.tap(find.byIcon(Icons.copy));
await tester.pump();
expect(clipboardText, contains('first selected message'));
expect(clipboardText, contains('second selected message'));
expect(find.text('2 selected'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}