diff --git a/packages/talker_flutter/lib/src/controller/talker_view_controller.dart b/packages/talker_flutter/lib/src/controller/talker_view_controller.dart index 9b43f686..2a2a0f32 100644 --- a/packages/talker_flutter/lib/src/controller/talker_view_controller.dart +++ b/packages/talker_flutter/lib/src/controller/talker_view_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:talker_flutter/src/utils/download_logs/donwload_logs.dart'; import 'package:talker_flutter/talker_flutter.dart'; @@ -8,9 +10,12 @@ class TalkerViewController extends ChangeNotifier { required Talker talker, bool expandedLogs = true, isLogOrderReversed = true, - }) : _expandedLogs = expandedLogs, + }) : _talker = talker, + _expandedLogs = expandedLogs, _isLogOrderReversed = isLogOrderReversed; + final Talker _talker; + /// Filter for selecting specific logs and errors on [TalkerScreen] and [TalkerView] /// by their keys [TalkerData.key] and by string query [TalkerFilter.searchQuery] /// Works only on screen (don't affect [Talker.filter]) @@ -19,6 +24,18 @@ class TalkerViewController extends ChangeNotifier { bool _expandedLogs; bool _isLogOrderReversed; + /// Whether log streaming is paused. + /// When paused, the view shows a frozen snapshot of logs. + bool _isPaused = false; + + /// Frozen snapshot of logs taken when pause was activated. + List _frozenLogs = []; + + Timer? _searchDebounceTimer; + + /// Duration for debouncing search query updates. + static const _searchDebounceDuration = Duration(milliseconds: 300); + /// Filter for selecting specific logs and errors TalkerFilter get filter => _uiFilter; set filter(TalkerFilter val) { @@ -41,12 +58,35 @@ class TalkerViewController extends ChangeNotifier { notifyListeners(); } - /// Method for updating a search query based on errors and logs - void updateFilterSearchQuery(String query) { - _uiFilter = _uiFilter.copyWith(searchQuery: query); + /// Whether log streaming is paused + bool get isPaused => _isPaused; + + /// Frozen snapshot of logs (empty when not paused) + List get frozenLogs => _frozenLogs; + + /// Toggle pause/resume. When pausing, freezes the current log history. + /// When resuming, clears the frozen snapshot and returns to live logs. + void togglePause() { + if (_isPaused) { + _frozenLogs = []; + _isPaused = false; + } else { + _frozenLogs = List.from(_talker.history); + _isPaused = true; + } notifyListeners(); } + /// Method for updating a search query based on errors and logs. + /// Uses debouncing to avoid excessive rebuilds during rapid typing. + void updateFilterSearchQuery(String query) { + _searchDebounceTimer?.cancel(); + _searchDebounceTimer = Timer(_searchDebounceDuration, () { + _uiFilter = _uiFilter.copyWith(searchQuery: query); + notifyListeners(); + }); + } + /// Method adds an key to the filter void addFilterKey(String key) { _uiFilter = _uiFilter.copyWith( @@ -67,4 +107,10 @@ class TalkerViewController extends ChangeNotifier { /// Redefinition [notifyListeners] void update() => notifyListeners(); + + @override + void dispose() { + _searchDebounceTimer?.cancel(); + super.dispose(); + } } diff --git a/packages/talker_flutter/lib/src/ui/talker_builder.dart b/packages/talker_flutter/lib/src/ui/talker_builder.dart index f5bea29d..402e0bbe 100644 --- a/packages/talker_flutter/lib/src/ui/talker_builder.dart +++ b/packages/talker_flutter/lib/src/ui/talker_builder.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:talker_flutter/talker_flutter.dart'; @@ -6,7 +8,9 @@ typedef TalkerWidgetBuilder = Widget Function( List data, ); -class TalkerBuilder extends StatelessWidget { +/// Listens to [Talker.stream] and rebuilds with throttling to prevent +/// excessive widget rebuilds during high-frequency logging. +class TalkerBuilder extends StatefulWidget { const TalkerBuilder({ Key? key, required this.talker, @@ -16,13 +20,59 @@ class TalkerBuilder extends StatelessWidget { final Talker talker; final TalkerWidgetBuilder builder; + @override + State createState() => _TalkerBuilderState(); +} + +class _TalkerBuilderState extends State { + StreamSubscription? _subscription; + Timer? _throttleTimer; + + /// Trailing-edge throttle window. During high-frequency logging, + /// at most one rebuild fires per window (after the burst settles). + static const _throttleDuration = Duration(milliseconds: 500); + + @override + void initState() { + super.initState(); + _subscribe(); + } + + @override + void didUpdateWidget(covariant TalkerBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.talker != widget.talker) { + _unsubscribe(); + _subscribe(); + } + } + + @override + void dispose() { + _unsubscribe(); + super.dispose(); + } + + void _subscribe() { + _subscription = widget.talker.stream.listen((_) { + // Trailing-edge throttle: reset the timer on every event, + // so setState fires only once after the burst settles. + _throttleTimer?.cancel(); + _throttleTimer = Timer(_throttleDuration, () { + if (mounted) setState(() {}); + }); + }); + } + + void _unsubscribe() { + _throttleTimer?.cancel(); + _throttleTimer = null; + _subscription?.cancel(); + _subscription = null; + } + @override Widget build(BuildContext context) { - return StreamBuilder( - stream: talker.stream, - builder: (BuildContext context, _) { - return builder(context, talker.history); - }, - ); + return widget.builder(context, widget.talker.history); } } diff --git a/packages/talker_flutter/lib/src/ui/talker_screen.dart b/packages/talker_flutter/lib/src/ui/talker_screen.dart index f4805d14..bbffc00f 100644 --- a/packages/talker_flutter/lib/src/ui/talker_screen.dart +++ b/packages/talker_flutter/lib/src/ui/talker_screen.dart @@ -13,6 +13,7 @@ class TalkerScreen extends StatelessWidget { this.customSettings = const [], this.isLogsExpanded = true, this.isLogOrderReversed = true, + this.maxDisplayedLogs = 500, }) : super(key: key); /// Talker implementation @@ -40,6 +41,11 @@ class TalkerScreen extends StatelessWidget { ///{@macro talker_flutter_is_log_order_reversed} final bool isLogOrderReversed; + /// Maximum number of log entries to display. + /// Set to 0 to display all logs. + /// Defaults to 500. + final int maxDisplayedLogs; + @override Widget build(BuildContext context) { return Scaffold( @@ -53,6 +59,7 @@ class TalkerScreen extends StatelessWidget { customSettings: customSettings, isLogsExpanded: isLogsExpanded, isLogOrderReversed: isLogOrderReversed, + maxDisplayedLogs: maxDisplayedLogs, ), ); } diff --git a/packages/talker_flutter/lib/src/ui/talker_view.dart b/packages/talker_flutter/lib/src/ui/talker_view.dart index 3545bcd6..4f360065 100644 --- a/packages/talker_flutter/lib/src/ui/talker_view.dart +++ b/packages/talker_flutter/lib/src/ui/talker_view.dart @@ -20,6 +20,7 @@ class TalkerView extends StatefulWidget { this.customSettings = const [], this.isLogsExpanded = true, this.isLogOrderReversed = true, + this.maxDisplayedLogs = 500, }) : super(key: key); /// Talker implementation @@ -55,6 +56,13 @@ class TalkerView extends StatefulWidget { /// {@endtemplate} final bool isLogOrderReversed; + /// Maximum number of log entries to display in the list. + /// Older entries beyond this limit are not rendered, which prevents + /// UI freezes when the log history grows large. + /// Set to 0 to display all logs (not recommended for large histories). + /// Defaults to 500. + final int maxDisplayedLogs; + @override State createState() => _TalkerViewState(); } @@ -67,6 +75,38 @@ class _TalkerViewState extends State { isLogOrderReversed: widget.isLogOrderReversed, ); + // Cached filtered results to avoid recomputing on every build + List _cachedFiltered = []; + int _lastSourceLength = -1; + int _lastFilterHash = -1; + + int get _filterHash => Object.hash( + _controller.filter.enabledKeys.length, + _controller.filter.searchQuery, + _controller.isPaused, + _controller.filter.enabledKeys.hashCode, + ); + + List _getFilteredLogs(List source) { + final currentHash = _filterHash; + if (source.length == _lastSourceLength && currentHash == _lastFilterHash) { + return _cachedFiltered; + } + + _lastSourceLength = source.length; + _lastFilterHash = currentHash; + + // Cap the source list to prevent processing thousands of entries + final maxLogs = widget.maxDisplayedLogs; + final capped = maxLogs > 0 && source.length > maxLogs + ? source.sublist(source.length - maxLogs) + : source; + + _cachedFiltered = + capped.where((e) => _controller.filter.filter(e)).toList(); + return _cachedFiltered; + } + @override Widget build(BuildContext context) { final talkerTheme = widget.theme; @@ -75,51 +115,18 @@ class _TalkerViewState extends State { child: AnimatedBuilder( animation: _controller, builder: (context, child) { + // When paused, use frozen snapshot; otherwise use live history + // via TalkerBuilder which throttles stream updates. + if (_controller.isPaused) { + return _buildLogList( + _controller.frozenLogs, + talkerTheme, + ); + } return TalkerBuilder( talker: widget.talker, builder: (context, data) { - final filteredElements = _getFilteredLogs(data); - final keys = data.map((e) => e.key).toList(); - final uniqKeys = keys.toSet().toList(); - - return CustomScrollView( - controller: widget.scrollController, - physics: const BouncingScrollPhysics(), - slivers: [ - TalkerViewAppBar( - keys: keys, - uniqKeys: uniqKeys, - title: widget.appBarTitle, - leading: widget.appBarLeading, - talker: widget.talker, - talkerTheme: talkerTheme, - controller: _controller, - onMonitorTap: () => _openTalkerMonitor(context), - onActionsTap: () => _openActions(context), - onSettingsTap: () => _openSettings(context, talkerTheme), - onToggleKey: _onToggleKey, - ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) { - final data = _getListItem(filteredElements, i); - if (widget.itemsBuilder != null) { - return widget.itemsBuilder!.call(context, data); - } - return TalkerDataCard( - data: data, - backgroundColor: widget.theme.cardColor, - onCopyTap: () => _copyTalkerDataItemText(data), - expanded: _controller.expandedLogs, - color: data.getFlutterColor(widget.theme), - ); - }, - childCount: filteredElements.length, - ), - ), - ], - ); + return _buildLogList(data, talkerTheme); }, ); }, @@ -127,8 +134,68 @@ class _TalkerViewState extends State { ); } - List _getFilteredLogs(List data) => - data.where((e) => _controller.filter.filter(e)).toList(); + Widget _buildLogList(List data, TalkerScreenTheme talkerTheme) { + final filteredElements = _getFilteredLogs(data); + + // Compute keys from capped source for accurate filter chip counts + final maxLogs = widget.maxDisplayedLogs; + final cappedData = maxLogs > 0 && data.length > maxLogs + ? data.sublist(data.length - maxLogs) + : data; + final keys = cappedData.map((e) => e.key).toList(); + final uniqKeys = keys.toSet().toList(); + + return CustomScrollView( + controller: widget.scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + TalkerViewAppBar( + keys: keys, + uniqKeys: uniqKeys, + title: _controller.isPaused + ? '${widget.appBarTitle ?? 'Interrupted'} (Paused)' + : widget.appBarTitle, + leading: widget.appBarLeading, + talker: widget.talker, + talkerTheme: talkerTheme, + controller: _controller, + onMonitorTap: () => _openTalkerMonitor(context), + onActionsTap: () => _openActions(context), + onSettingsTap: () => _openSettings(context, talkerTheme), + onToggleKey: _onToggleKey, + onPauseTap: _controller.togglePause, + isPaused: _controller.isPaused, + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final index = _controller.isLogOrderReversed + ? filteredElements.length - 1 - i + : i; + final data = filteredElements[index]; + if (widget.itemsBuilder != null) { + return RepaintBoundary( + child: widget.itemsBuilder!.call(context, data), + ); + } + return RepaintBoundary( + child: TalkerDataCard( + key: ValueKey(index), + data: data, + backgroundColor: widget.theme.cardColor, + onCopyTap: () => _copyTalkerDataItemText(data), + expanded: _controller.expandedLogs, + color: data.getFlutterColor(widget.theme), + ), + ); + }, + childCount: filteredElements.length, + ), + ), + ], + ); + } void _onToggleKey(String key, bool selected) { final action = @@ -136,15 +203,6 @@ class _TalkerViewState extends State { action(key); } - TalkerData _getListItem( - List filteredElements, - int i, - ) { - final data = filteredElements[ - _controller.isLogOrderReversed ? filteredElements.length - 1 - i : i]; - return data; - } - void _openSettings(BuildContext context, TalkerScreenTheme theme) { final talker = ValueNotifier(widget.talker); @@ -241,6 +299,7 @@ class _TalkerViewState extends State { void _cleanHistory() { widget.talker.cleanHistory(); + _lastSourceLength = -1; // Invalidate cache _controller.update(); } @@ -257,8 +316,8 @@ class _TalkerViewState extends State { void _copyFilteredLogs(BuildContext context) { Clipboard.setData(ClipboardData( - text: _getFilteredLogs(widget.talker.history) - .text(timeFormat: widget.talker.settings.timeFormat))); + text: _cachedFiltered.text( + timeFormat: widget.talker.settings.timeFormat))); _showSnackBar(context, 'All filtered logs copied in buffer'); } } diff --git a/packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart b/packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart index 5a1e5e76..f5373335 100644 --- a/packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart +++ b/packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart @@ -17,6 +17,8 @@ class TalkerViewAppBar extends StatefulWidget { required this.onSettingsTap, required this.onActionsTap, required this.onToggleKey, + required this.onPauseTap, + required this.isPaused, }) : super(key: key); final String? title; @@ -33,6 +35,9 @@ class TalkerViewAppBar extends StatefulWidget { final VoidCallback onMonitorTap; final VoidCallback onSettingsTap; final VoidCallback onActionsTap; + final VoidCallback onPauseTap; + + final bool isPaused; final Function(String key, bool selected) onToggleKey; @@ -107,11 +112,23 @@ class _TalkerViewAppBarState extends State leading: widget.leading, iconTheme: IconThemeData(color: widget.talkerTheme.textColor), actions: [ + UnconstrainedBox( + child: IconButton( + onPressed: widget.onPauseTap, + icon: Icon( + widget.isPaused ? Icons.play_arrow : Icons.pause, + color: widget.isPaused + ? Colors.yellow + : widget.talkerTheme.textColor, + ), + ), + ), UnconstrainedBox( child: _MonitorButton( talker: widget.talker, onPressed: widget.onMonitorTap, talkerTheme: widget.talkerTheme, + keys: widget.keys, ), ), UnconstrainedBox( @@ -267,53 +284,53 @@ class _SearchTextField extends StatelessWidget { } } +/// Monitor button that shows a red dot when errors exist in the log history. +/// Uses the already-available keys list from the parent instead of spawning +/// its own TalkerBuilder (which would cause extra full-history scans). class _MonitorButton extends StatelessWidget { const _MonitorButton({ Key? key, required this.talker, required this.onPressed, required this.talkerTheme, + required this.keys, }) : super(key: key); final Talker talker; final TalkerScreenTheme talkerTheme; final VoidCallback onPressed; + final List keys; @override Widget build(BuildContext context) { - return TalkerBuilder( - talker: talker, - builder: (context, data) { - final haveErrors = data - .where((e) => e is TalkerError || e is TalkerException) - .isNotEmpty; - return Stack( - children: [ - Center( - child: IconButton( - onPressed: onPressed, - icon: Icon( - Icons.monitor_heart_outlined, - color: talkerTheme.textColor, - ), - ), + final haveErrors = keys.any( + (k) => k == TalkerKey.error || k == TalkerKey.exception, + ); + return Stack( + children: [ + Center( + child: IconButton( + onPressed: onPressed, + icon: Icon( + Icons.monitor_heart_outlined, + color: talkerTheme.textColor, ), - if (haveErrors) - Positioned( - right: 6, - top: 8, - child: Container( - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - height: 7, - width: 7, - ), + ), + ), + if (haveErrors) + Positioned( + right: 6, + top: 8, + child: Container( + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, ), - ], - ); - }, + height: 7, + width: 7, + ), + ), + ], ); } }