- Update logcat console milestone documentation - Implement export tools archive for m-logcat-console task - Update console page features - Update console page and widget tests
398 lines
12 KiB
Dart
398 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../models/adb_device.dart';
|
|
import '../../services/adb_service.dart';
|
|
|
|
class ConsolePage extends StatefulWidget {
|
|
const ConsolePage({super.key, this.device, this.logcatStarter});
|
|
|
|
final AdbDevice? device;
|
|
final AdbLogcatSessionStarter? logcatStarter;
|
|
|
|
@override
|
|
State<ConsolePage> createState() => _ConsolePageState();
|
|
}
|
|
|
|
class _ConsolePageState extends State<ConsolePage> {
|
|
final List<String> _logLines = [];
|
|
final TextEditingController _filterController = TextEditingController();
|
|
AdbLogcatSession? _session;
|
|
StreamSubscription<String>? _subscription;
|
|
bool _isLoading = false;
|
|
String? _errorMsg;
|
|
int _sessionGeneration = 0;
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _isPaused = false;
|
|
_LogcatLevelFilter _levelFilter = _LogcatLevelFilter.all;
|
|
|
|
void _scrollToBottom() {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients && !_isPaused) {
|
|
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startSession();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ConsolePage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.device != oldWidget.device ||
|
|
widget.logcatStarter != oldWidget.logcatStarter) {
|
|
_stopSession();
|
|
_logLines.clear();
|
|
_isPaused = false;
|
|
_startSession();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopSession();
|
|
_filterController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startSession() async {
|
|
final starter = widget.logcatStarter;
|
|
final device = widget.device;
|
|
if (starter == null || device == null) {
|
|
return;
|
|
}
|
|
|
|
_sessionGeneration++;
|
|
final currentGen = _sessionGeneration;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMsg = null;
|
|
});
|
|
|
|
try {
|
|
final session = await starter(serial: device.serial);
|
|
if (currentGen != _sessionGeneration) {
|
|
await session.stop();
|
|
return;
|
|
}
|
|
if (!mounted) {
|
|
await session.stop();
|
|
return;
|
|
}
|
|
_session = session;
|
|
_subscription = session.lines.listen(
|
|
(line) {
|
|
if (!mounted || currentGen != _sessionGeneration) return;
|
|
setState(() {
|
|
_logLines.add(line);
|
|
if (_logLines.length > 1000) {
|
|
_logLines.removeRange(0, _logLines.length - 1000);
|
|
}
|
|
});
|
|
if (!_isPaused) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && currentGen == _sessionGeneration) {
|
|
_scrollToBottom();
|
|
}
|
|
});
|
|
}
|
|
},
|
|
onError: (err) {
|
|
if (!mounted || currentGen != _sessionGeneration) return;
|
|
setState(() {
|
|
_errorMsg = err.toString();
|
|
});
|
|
},
|
|
);
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted || currentGen != _sessionGeneration) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMsg = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _stopSession() {
|
|
_sessionGeneration++;
|
|
_subscription?.cancel();
|
|
_subscription = null;
|
|
_session?.stop();
|
|
_session = null;
|
|
}
|
|
|
|
List<String> get _displayLines {
|
|
final sourceLines = (widget.device == null || widget.logcatStarter == null)
|
|
? _sampleLogLines
|
|
: _logLines;
|
|
|
|
return sourceLines
|
|
.where(_matchesLevelFilter)
|
|
.where(_matchesQueryFilter)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
bool _matchesLevelFilter(String line) {
|
|
return switch (_levelFilter) {
|
|
_LogcatLevelFilter.all => true,
|
|
_LogcatLevelFilter.debug =>
|
|
_LogcatLevel.fromLine(line) == _LogcatLevel.debug,
|
|
_LogcatLevelFilter.info =>
|
|
_LogcatLevel.fromLine(line) == _LogcatLevel.info,
|
|
_LogcatLevelFilter.warn =>
|
|
_LogcatLevel.fromLine(line) == _LogcatLevel.warn,
|
|
_LogcatLevelFilter.error =>
|
|
_LogcatLevel.fromLine(line) == _LogcatLevel.error,
|
|
};
|
|
}
|
|
|
|
bool _matchesQueryFilter(String line) {
|
|
final query = _filterController.text.trim().toLowerCase();
|
|
if (query.isEmpty) return true;
|
|
|
|
return line.toLowerCase().contains(query);
|
|
}
|
|
|
|
bool get _hasLiveLogs =>
|
|
widget.device != null &&
|
|
widget.logcatStarter != null &&
|
|
_displayLines.isNotEmpty;
|
|
|
|
Future<void> _copyVisibleLogsToClipboard() async {
|
|
if (widget.device == null || widget.logcatStarter == null) return;
|
|
final lines = _displayLines;
|
|
if (lines.isEmpty) return;
|
|
|
|
await Clipboard.setData(ClipboardData(text: lines.join('\n')));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('로그 ${lines.length}줄 복사됨')));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (widget.device != null) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
key: const ValueKey('console-selected-device-label'),
|
|
children: [
|
|
const Icon(Icons.phone_android, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${widget.device!.displayName} (${widget.device!.serial})',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
] else ...[
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
key: ValueKey('console-no-device-label'),
|
|
children: [
|
|
Icon(Icons.phone_android, size: 18, color: Colors.grey),
|
|
SizedBox(width: 8),
|
|
Text('device 미선택', style: TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 160, maxWidth: 320),
|
|
child: TextField(
|
|
controller: _filterController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
labelText: '패키지, 태그 또는 텍스트',
|
|
prefixIcon: const Icon(Icons.filter_alt),
|
|
suffixIcon: IconButton(
|
|
tooltip: '필터 초기화',
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () {
|
|
_filterController.clear();
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SegmentedButton<_LogcatLevelFilter>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: _LogcatLevelFilter.all,
|
|
label: Text('ALL'),
|
|
),
|
|
ButtonSegment(
|
|
value: _LogcatLevelFilter.debug,
|
|
label: Text('DEBUG'),
|
|
),
|
|
ButtonSegment(
|
|
value: _LogcatLevelFilter.info,
|
|
label: Text('INFO'),
|
|
),
|
|
ButtonSegment(
|
|
value: _LogcatLevelFilter.warn,
|
|
label: Text('WARN'),
|
|
),
|
|
ButtonSegment(
|
|
value: _LogcatLevelFilter.error,
|
|
label: Text('ERROR'),
|
|
),
|
|
],
|
|
selected: {_levelFilter},
|
|
onSelectionChanged: (selection) {
|
|
setState(() {
|
|
_levelFilter = selection.single;
|
|
});
|
|
},
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton.filledTonal(
|
|
tooltip: _isPaused ? '재개' : '일시정지',
|
|
onPressed: () {
|
|
setState(() {
|
|
_isPaused = !_isPaused;
|
|
if (!_isPaused) {
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => _scrollToBottom(),
|
|
);
|
|
}
|
|
});
|
|
},
|
|
icon: Icon(_isPaused ? Icons.play_arrow : Icons.pause),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.outlined(
|
|
tooltip: '지우기',
|
|
onPressed: () {
|
|
setState(_logLines.clear);
|
|
},
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.outlined(
|
|
tooltip: '로그 복사',
|
|
onPressed: _hasLiveLogs
|
|
? _copyVisibleLogsToClipboard
|
|
: null,
|
|
icon: const Icon(Icons.content_copy),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
Expanded(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF151A18),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: colorScheme.outlineVariant),
|
|
),
|
|
child: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _errorMsg != null
|
|
? Center(
|
|
child: Text(
|
|
'에러 발생: $_errorMsg',
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _displayLines.length,
|
|
itemBuilder: (context, index) {
|
|
final line = _displayLines[index];
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Text(
|
|
line,
|
|
style: TextStyle(
|
|
color: _lineColor(line),
|
|
fontFamily: 'Menlo',
|
|
fontSize: 13,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _lineColor(String line) {
|
|
return switch (_LogcatLevel.fromLine(line)) {
|
|
_LogcatLevel.debug => const Color(0xFF8DE1D7),
|
|
_LogcatLevel.info => const Color(0xFFBDEFA8),
|
|
_LogcatLevel.warn => const Color(0xFFFFD28A),
|
|
_LogcatLevel.error => const Color(0xFFFFA3A3),
|
|
null => const Color(0xFFE6EEE9),
|
|
};
|
|
}
|
|
}
|
|
|
|
enum _LogcatLevel {
|
|
debug,
|
|
info,
|
|
warn,
|
|
error;
|
|
|
|
static _LogcatLevel? fromLine(String line) {
|
|
final match = RegExp(r'\s([DIWEF])\s').firstMatch(line);
|
|
|
|
return switch (match?.group(1)) {
|
|
'D' => _LogcatLevel.debug,
|
|
'I' => _LogcatLevel.info,
|
|
'W' => _LogcatLevel.warn,
|
|
'E' || 'F' => _LogcatLevel.error,
|
|
_ => null,
|
|
};
|
|
}
|
|
}
|
|
|
|
enum _LogcatLevelFilter { all, debug, info, warn, error }
|
|
|
|
const _sampleLogLines = [
|
|
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity',
|
|
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed',
|
|
'06-08 10:41:22.921 5521 5540 W NetworkMonitor: vpn route not available, using usb install cache',
|
|
'06-08 10:41:23.105 5521 5540 I ArtifactStore: app-debug.apk verified',
|
|
'06-08 10:41:23.411 5521 5521 E Sample: placeholder error row for filter styling',
|
|
];
|