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 createState() => _ConsolePageState(); } class _ConsolePageState extends State { final List _logLines = []; final TextEditingController _filterController = TextEditingController(); AdbLogcatSession? _session; StreamSubscription? _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 _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 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 _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', ];