appsok/lib/src/features/console/console_page.dart
toki b9f3adaadf feat: Logcat Console export tools implementation
- Update logcat console milestone documentation
- Implement export tools archive for m-logcat-console task
- Update console page features
- Update console page and widget tests
2026-06-14 10:51:40 +09:00

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',
];