diff --git a/packages/devtools_app/lib/src/extensibility/discoverable.dart b/packages/devtools_app/lib/src/extensibility/discoverable.dart new file mode 100644 index 00000000000..aef17a8b4d8 --- /dev/null +++ b/packages/devtools_app/lib/src/extensibility/discoverable.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import '../primitives/simple_items.dart'; +import '../screens/memory/memory_controller_discoverable.dart'; +import '../screens/performance/performance_controller_discoverable.dart'; +import '../shared/globals.dart'; +import 'services_proxy.dart'; + +export '../screens/memory/memory_controller_discoverable.dart'; +export '../screens/performance/performance_controller_discoverable.dart'; + +class StructuredLogEvent { + StructuredLogEvent(this.type, {this.data}); + final String type; + final Object? data; +} + +class VMEvent extends StructuredLogEvent { + VMEvent(type, {data}) : super(type, data: data); +} + +class DevToolsUserEvent extends StructuredLogEvent { + DevToolsUserEvent(type, {data}) : super(type, data: data); +} + +/// An event manager class. Clients can listen for classes of events, optionally +/// filtered by a string type. This can be used to decouple events sources and +/// event listeners. +class EventsManager { + EventsManager() { + _controller = StreamController.broadcast(); + setGlobal(EventsManager, this); + } + + late StreamController _controller; + + /// Listen for events. Clients can pass in an optional [type] + /// which filters the events to only those specific ones. + /// To stop listening to events, keep a reference to the resulting + /// [StreamSubscription] and cancel it. + Stream onEvent({String? type}) { + if (type == null) { + return _controller.stream; + } else { + return _controller.stream.where( + (StructuredLogEvent event) => + event.type == type || event.type.startsWith(type), + ); + } + } + + /// Add an event to the event bus. + void addEvent(StructuredLogEvent event) { + _controller.add(event); + } + + /// Close (destroy) this [StructuredLogEventsManager]. This is generally not used + /// outside of a testing context. All stream listeners will be closed and the + /// bus will not fire any more events. + void close() { + unawaited(_controller.close()); + } +} + +class DiscoverableDevToolsApp { + DiscoverableDevToolsApp() { + vmServicesProxy = VMServicesProxy(); + setGlobal(DiscoverableDevToolsApp, this); + frameworkController.onPageChange.listen((event) { + _selectedPageId = event.id; + }); + } + + late VMServicesProxy vmServicesProxy; + + /// Get the available screen ids from [ScreenIds] class. + void selectPage(String pageId) { + frameworkController.notifyShowPageId(pageId); + } + + String? _selectedPageId; + String? get selectedPageId => _selectedPageId; + + DiscoverableMemoryPage? memoryPage; + DiscoverablePerformancePage? performancePage; + // TODO: add of the other pages here + + static const pageChangedEventKeyPrefix = 'page-changed.'; + static final memoryPageChangedEventKey = + '$pageChangedEventKeyPrefix${DiscoverableMemoryPage.id}'; + static final performancePageChangedEventKey = + '$pageChangedEventKeyPrefix${DiscoverablePerformancePage.id}'; +} + +abstract class DiscoverablePage {} diff --git a/packages/devtools_app/lib/src/extensibility/services_proxy.dart b/packages/devtools_app/lib/src/extensibility/services_proxy.dart new file mode 100644 index 00000000000..7a4830b7bf2 --- /dev/null +++ b/packages/devtools_app/lib/src/extensibility/services_proxy.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:devtools_shared/devtools_shared.dart'; +import 'package:flutter/foundation.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../primitives/auto_dispose.dart'; +import '../primitives/message_bus.dart'; +import '../primitives/utils.dart'; +import '../screens/inspector/diagnostics_node.dart'; +import '../screens/inspector/inspector_service.dart'; +import '../screens/logging/logging_controller.dart' + show + FrameInfo, + ImageSizesForFrame, + NavigationInfo, + ServiceExtensionStateChangedInfo; // TODO: consider extracting these from the logging controller into a shared place +import '../service/vm_service_wrapper.dart'; +import '../shared/globals.dart'; + +// Adapted from [logging_controller.dart] +class VMServicesProxy extends DisposableController + with AutoDisposeControllerMixin { + VMServicesProxy() { + autoDisposeStreamSubscription( + serviceManager.onConnectionAvailable.listen(_handleConnectionStart), + ); + if (serviceManager.connectedAppInitialized) { + _handleConnectionStart(serviceManager.service!); + } + autoDisposeStreamSubscription( + serviceManager.onConnectionClosed.listen(_handleConnectionStop), + ); + _handleBusEvents(); + } + + void _handleConnectionStart(VmServiceWrapper service) async { + // Log stdout events. + final _StdoutEventHandler stdoutHandler = _StdoutEventHandler('stdout'); + autoDisposeStreamSubscription( + service.onStdoutEventWithHistory.listen(stdoutHandler.handle), + ); + + // Log stderr events. + final _StdoutEventHandler stderrHandler = + _StdoutEventHandler('stderr', isError: true); + autoDisposeStreamSubscription( + service.onStderrEventWithHistory.listen(stderrHandler.handle), + ); + + // Log GC events. + autoDisposeStreamSubscription(service.onGCEvent.listen(_handleGCEvent)); + + // Log `dart:developer` `log` events. + autoDisposeStreamSubscription( + service.onLoggingEventWithHistory.listen(_handleDeveloperLogEvent), + ); + + // Log Flutter extension events. + autoDisposeStreamSubscription( + service.onExtensionEventWithHistory.listen(_handleExtensionEvent), + ); + } + + void _handleExtensionEvent(Event e) async { + // Events to show without a summary in the table. + const Set untitledEvents = { + 'Flutter.FirstFrame', + 'Flutter.FrameworkInitialization', + }; + + // TODO(jacobr): make the list of filtered events configurable. + const Set filteredEvents = { + // Suppress these events by default as they just add noise to the log + ServiceExtensionStateChangedInfo.eventName, + }; + + if (filteredEvents.contains(e.extensionKind)) { + return; + } + + if (e.extensionKind == FrameInfo.eventName) { + final FrameInfo frame = FrameInfo.from(e.extensionData!.data); + + final String frameId = '#${frame.number}'; + final String frameInfoText = + '$frameId ${frame.elapsedMs!.toStringAsFixed(1).padLeft(4)}ms '; + + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.extensionData!.data, + 'timestamp': e.timestamp, + 'summary': frameInfoText, + }, + ), + ); + } else if (e.extensionKind == ImageSizesForFrame.eventName) { + final images = ImageSizesForFrame.from(e.extensionData!.data); + + for (final image in images) { + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': image.rawJson, + 'timestamp': e.timestamp, + 'summary': image.summary, + }, + ), + ); + } + } else if (e.extensionKind == NavigationInfo.eventName) { + final NavigationInfo navInfo = NavigationInfo.from(e.extensionData!.data); + + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.json, + 'timestamp': e.timestamp, + 'summary': navInfo.routeDescription, + }, + ), + ); + } else if (untitledEvents.contains(e.extensionKind)) { + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.json, + 'timestamp': e.timestamp, + 'summary': '', + }, + ), + ); + } else if (e.extensionKind == ServiceExtensionStateChangedInfo.eventName) { + final ServiceExtensionStateChangedInfo changedInfo = + ServiceExtensionStateChangedInfo.from(e.extensionData!.data); + + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.json, + 'timestamp': e.timestamp, + 'summary': '${changedInfo.extension}: ${changedInfo.value}', + }, + ), + ); + } else if (e.extensionKind == 'Flutter.Error') { + // TODO(pq): add tests for error extension handling once framework changes + // are landed. + final RemoteDiagnosticsNode node = RemoteDiagnosticsNode( + e.extensionData!.data, + _objectGroup, + false, + null, + ); + // Workaround the fact that the error objects from the server don't have + // style error. + node.style = DiagnosticsTreeStyle.error; + // if (_verboseDebugging) { + // logger.log('node toStringDeep:######\n${node.toStringDeep()}\n###'); + // } + + final RemoteDiagnosticsNode summary = _findFirstSummary(node) ?? node; + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.extensionData!.data, + 'timestamp': e.timestamp, + 'summary': summary.toDiagnosticsNode().toString(), + }, + ), + ); + } else { + eventsManager.addEvent( + VMEvent( + e.extensionKind!.toLowerCase(), + data: { + 'data': e.json, + 'timestamp': e.timestamp, + 'summary': e.json.toString(), + }, + ), + ); + } + } + + ObjectGroup get _objectGroup => + serviceManager.consoleService.objectGroup as ObjectGroup; + + void _handleGCEvent(Event e) { + final HeapSpace newSpace = HeapSpace.parse(e.json!['new'])!; + final HeapSpace oldSpace = HeapSpace.parse(e.json!['old'])!; + final isolateRef = e.json!['isolate']; + + final int usedBytes = newSpace.used! + oldSpace.used!; + final int capacityBytes = newSpace.capacity! + oldSpace.capacity!; + + final int time = ((newSpace.time! + oldSpace.time!) * 1000).round(); + + final String summary = '${isolateRef['name']} • ' + '${e.json!['reason']} collection in $time ms • ' + '${printMB(usedBytes, includeUnit: true)} used of ${printMB(capacityBytes, includeUnit: true)}'; + + final event = { + 'reason': e.json!['reason'], + 'new': newSpace.json, + 'old': oldSpace.json, + 'isolate': isolateRef, + }; + + eventsManager.addEvent( + VMEvent( + 'gc', + data: {'data': event, 'timestamp': e.timestamp, 'summary': summary}, + ), + ); + } + + void _handleDeveloperLogEvent(Event e) { + final logRecord = e.json!['logRecord']; + + String? loggerName = + _valueAsString(InstanceRef.parse(logRecord['loggerName'])); + if (loggerName == null || loggerName.isEmpty) { + loggerName = 'log'; + } + final int? level = logRecord['level']; + final InstanceRef messageRef = InstanceRef.parse(logRecord['message'])!; + String? summary = _valueAsString(messageRef); + if (messageRef.valueAsStringIsTruncated == true) { + summary = summary! + '...'; + } + + final String? details = summary; + + const int severeIssue = 1000; + final bool isError = level != null && level >= severeIssue ? true : false; + + eventsManager.addEvent( + VMEvent( + loggerName, + data: { + 'data': details, + 'timestamp': e.timestamp, + 'isError': isError, + 'summary': summary, + }, + ), + ); + } + + String? _valueAsString(InstanceRef? ref) { + if (ref == null) { + return null; + } + + if (ref.valueAsString == null) { + return ref.valueAsString; + } + + if (ref.valueAsStringIsTruncated == true) { + return '${ref.valueAsString}...'; + } else { + return ref.valueAsString; + } + } + + void _handleConnectionStop(dynamic event) {} + + void _handleBusEvents() { + autoDisposeStreamSubscription( + messageBus.onEvent().listen( + (BusEvent event) => + eventsManager.addEvent(VMEvent(event.type, data: event.data)), + ), + ); + } + + static RemoteDiagnosticsNode? _findFirstSummary(RemoteDiagnosticsNode node) { + if (node.level == DiagnosticLevel.summary) { + return node; + } + RemoteDiagnosticsNode? summary; + for (var property in node.inlineProperties) { + summary = _findFirstSummary(property); + if (summary != null) return summary; + } + + for (RemoteDiagnosticsNode child in node.childrenNow) { + summary = _findFirstSummary(child); + if (summary != null) return summary; + } + + return null; + } +} + +/// Receive and log stdout / stderr events from the VM. +/// +/// This class buffers the events for up to 1ms. This is in order to combine a +/// stdout message and its newline. Currently, `foo\n` is sent as two VM events; +/// we wait for up to 1ms when we get the `foo` event, to see if the next event +/// is a single newline. If so, we add the newline to the previous log message. +class _StdoutEventHandler { + _StdoutEventHandler( + this.name, { + this.isError = false, + }); + + final String name; + final bool isError; + + VMEvent? buffer; + Timer? timer; + + void handle(Event e) { + final String message = decodeBase64(e.bytes!); + print('_StdOutEventHandler: $message'); + + if (buffer != null) { + timer?.cancel(); + + if (message == '\n') { + final data = buffer!.data! as Map; + eventsManager.addEvent( + VMEvent( + buffer!.type, + data: { + 'data': data['data']! + message, + 'timestamp': data['timestamp'], + 'summary': data['summary']! + message, + 'isError': data['isError'], + }, + ), + ); + buffer = null; + return; + } + + eventsManager.addEvent(buffer!); + buffer = null; + } + + const maxLength = 200; + + String summary = message; + if (message.length > maxLength) { + summary = message.substring(0, maxLength); + } + + final VMEvent event = VMEvent( + name, + data: { + 'data': message, + 'timestamp': e.timestamp, + 'summary': summary, + 'isError': isError, + }, + ); + + if (message == '\n') { + eventsManager.addEvent(event); + } else { + buffer = event; + timer = Timer(const Duration(milliseconds: 1), () { + eventsManager.addEvent(buffer!); + buffer = null; + }); + } + } +} diff --git a/packages/devtools_app/lib/src/screens/memory/memory_controller.dart b/packages/devtools_app/lib/src/screens/memory/memory_controller.dart index a9be316f3a9..11e032d3808 100644 --- a/packages/devtools_app/lib/src/screens/memory/memory_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/memory_controller.dart @@ -18,7 +18,9 @@ import '../../service/service_extensions.dart'; import '../../service/service_manager.dart'; import '../../shared/globals.dart'; import '../../shared/utils.dart'; +import 'memory_controller_discoverable.dart'; import 'memory_protocol.dart'; +import 'memory_tabs.dart'; import 'panes/allocation_profile/allocation_profile_table_view_controller.dart'; import 'panes/chart/primitives.dart'; import 'panes/diff/controller/diff_pane_controller.dart'; @@ -46,6 +48,7 @@ class OfflineFileException implements Exception { class MemoryController extends DisposableController with AutoDisposeControllerMixin { MemoryController({DiffPaneController? diffPaneController}) { + DiscoverableMemoryPage(this); memoryTimeline = MemoryTimeline(offline); memoryLog = _MemoryLog(this); this.diffPaneController = @@ -65,6 +68,9 @@ class MemoryController extends DisposableController }); } + final ValueNotifier currentTab = + ValueNotifier(MemoryScreenKeys.dartHeapTableProfileTab); + /// The controller is late to enable test injection. late final DiffPaneController diffPaneController; diff --git a/packages/devtools_app/lib/src/screens/memory/memory_controller_discoverable.dart b/packages/devtools_app/lib/src/screens/memory/memory_controller_discoverable.dart new file mode 100644 index 00000000000..268b601d793 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/memory/memory_controller_discoverable.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; + +import '../../../devtools_app.dart'; +import '../../analytics/constants.dart' as analytics_constants; +import '../../extensibility/discoverable.dart'; +import 'memory_tabs.dart'; + +class DiscoverableMemoryPage extends DiscoverablePage { + DiscoverableMemoryPage(this.controller) : super() { + discoverableApp.memoryPage = this; + } + + final MemoryController controller; + + static String get id => MemoryScreen.id; + + // Events + static const memorySnapshotTaken = 'mem-snapshot-done'; + + // Actions + void takeSnapshotAction() { + final takeSnapshot = controller.diffPaneController.takeSnapshotHandler( + analytics_constants.MemoryEvent.diffTakeSnapshotControlPane, + ); + if (takeSnapshot == null) + print("takeSnapshotHandler returned null, can't take snapshot"); + else + takeSnapshot(); + } + + void changeTabAction(Key tab) { + controller.currentTab.value = tab; + } + + static const dartHeapTableProfileTab = + MemoryScreenKeys.dartHeapTableProfileTab; + static const dartHeapAllocationTracingTab = + MemoryScreenKeys.dartHeapAllocationTracingTab; + static const diffTab = MemoryScreenKeys.diffTab; + static const leaksTab = MemoryScreenKeys.leaksTab; +} diff --git a/packages/devtools_app/lib/src/screens/memory/memory_tabs.dart b/packages/devtools_app/lib/src/screens/memory/memory_tabs.dart index a22e284c35f..62389662380 100644 --- a/packages/devtools_app/lib/src/screens/memory/memory_tabs.dart +++ b/packages/devtools_app/lib/src/screens/memory/memory_tabs.dart @@ -13,7 +13,6 @@ import 'panes/allocation_tracing/allocation_profile_tracing_view.dart'; import 'panes/diff/diff_pane.dart'; import 'panes/leaks/leaks_pane.dart'; -@visibleForTesting class MemoryScreenKeys { static const leaksTab = Key('Leaks Tab'); static const dartHeapTableProfileTab = Key('Dart Heap Profile Tab'); @@ -48,6 +47,7 @@ class MemoryTabView extends StatelessWidget { tabs: tabs, tabViews: tabViews, gaScreen: analytics_constants.memory, + selectedTabNotifier: controller.currentTab, ); }, ); diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/controller/diff_pane_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/controller/diff_pane_controller.dart index 675342a7770..0bb274a99d7 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/controller/diff_pane_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/controller/diff_pane_controller.dart @@ -13,6 +13,8 @@ import '../../../../../analytics/constants.dart' as analytics_constants; import '../../../../../config_specific/import_export/import_export.dart'; import '../../../../../primitives/auto_dispose.dart'; import '../../../../../primitives/utils.dart'; +import '../../../../../shared/globals.dart'; +import '../../../memory_controller_discoverable.dart'; import '../../../primitives/class_name.dart'; import '../../../primitives/memory_utils.dart'; import '../../../shared/heap/class_filter.dart'; @@ -75,6 +77,10 @@ class DiffPaneController extends DisposableController { core._selectedSnapshotIndex.value = newElementIndex; _isTakingSnapshot.value = false; derived._updateValues(); + + eventsManager.addEvent( + StructuredLogEvent(DiscoverableMemoryPage.memorySnapshotTaken), + ); } Future clearSnapshots() async { diff --git a/packages/devtools_app/lib/src/screens/performance/performance_controller.dart b/packages/devtools_app/lib/src/screens/performance/performance_controller.dart index 08531a13a96..66ae6b80c9e 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_controller.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_controller.dart @@ -17,6 +17,7 @@ import 'panes/flutter_frames/flutter_frames_controller.dart'; import 'panes/raster_stats/raster_stats_controller.dart'; import 'panes/rebuild_stats/rebuild_stats_model.dart'; import 'panes/timeline_events/timeline_events_controller.dart'; +import 'performance_controller_discoverable.dart'; import 'performance_model.dart'; import 'performance_screen.dart'; @@ -28,6 +29,7 @@ import 'performance_screen.dart'; class PerformanceController extends DisposableController with AutoDisposeControllerMixin { PerformanceController() { + DiscoverablePerformancePage(this); flutterFramesController = FlutterFramesController(this); timelineEventsController = TimelineEventsController(this); rasterStatsController = RasterStatsController(this); diff --git a/packages/devtools_app/lib/src/screens/performance/performance_controller_discoverable.dart b/packages/devtools_app/lib/src/screens/performance/performance_controller_discoverable.dart new file mode 100644 index 00000000000..ed3180406ae --- /dev/null +++ b/packages/devtools_app/lib/src/screens/performance/performance_controller_discoverable.dart @@ -0,0 +1,21 @@ +import '../../../devtools_app.dart'; +import '../../extensibility/discoverable.dart'; + +class DiscoverablePerformancePage extends DiscoverablePage { + DiscoverablePerformancePage(this.controller) : super() { + discoverableApp.performancePage = this; + } + + final PerformanceController controller; + + static String get id => PerformanceScreen.id; + + // Events + + // Actions + void selectFrame(int index) { + controller.flutterFramesController.handleSelectedFrame( + controller.flutterFramesController.flutterFrames.value[index], + ); + } +} diff --git a/packages/devtools_app/lib/src/shared/framework_controller.dart b/packages/devtools_app/lib/src/shared/framework_controller.dart index 1a4ad5738af..ff783f96619 100644 --- a/packages/devtools_app/lib/src/shared/framework_controller.dart +++ b/packages/devtools_app/lib/src/shared/framework_controller.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import '../extensibility/discoverable.dart'; import 'globals.dart'; /// This controller is used by the connection to the DevTools server to receive @@ -57,6 +58,11 @@ class FrameworkController { /// Notify the controller that the current page has changed. void notifyPageChange(PageChangeEvent page) { _pageChangeController.add(page); + eventsManager.addEvent( + StructuredLogEvent( + '${DiscoverableDevToolsApp.pageChangedEventKeyPrefix}${page.id}', + ), + ); } /// Notifies when a device disconnects from DevTools. diff --git a/packages/devtools_app/lib/src/shared/globals.dart b/packages/devtools_app/lib/src/shared/globals.dart index fa44c177c09..bc070eac859 100644 --- a/packages/devtools_app/lib/src/shared/globals.dart +++ b/packages/devtools_app/lib/src/shared/globals.dart @@ -4,6 +4,7 @@ import '../config_specific/ide_theme/ide_theme.dart'; import '../config_specific/import_export/import_export.dart'; +import '../extensibility/discoverable.dart'; import '../extension_points/extensions_base.dart'; import '../primitives/message_bus.dart'; import '../primitives/storage.dart'; @@ -15,6 +16,9 @@ import 'framework_controller.dart'; import 'preferences.dart'; import 'survey.dart'; +export '../extensibility/discoverable.dart' + show StructuredLogEvent, DevToolsUserEvent, VMEvent; + /// Whether this DevTools build is external. bool get isExternalBuild => _isExternalBuild; bool _isExternalBuild = true; @@ -31,6 +35,10 @@ MessageBus get messageBus => globals[MessageBus]; FrameworkController get frameworkController => globals[FrameworkController]; +EventsManager get eventsManager => globals[EventsManager]; + +DiscoverableDevToolsApp get discoverableApp => globals[DiscoverableDevToolsApp]; + Storage get storage => globals[Storage]; SurveyService get surveyService => globals[SurveyService]; diff --git a/packages/devtools_app/lib/src/ui/tab.dart b/packages/devtools_app/lib/src/ui/tab.dart index fe9c5fb401d..109fd3e0f30 100644 --- a/packages/devtools_app/lib/src/ui/tab.dart +++ b/packages/devtools_app/lib/src/ui/tab.dart @@ -82,6 +82,7 @@ class AnalyticsTabbedView extends StatefulWidget { this.outlined = true, this.sendAnalytics = true, this.onTabChanged, + this.selectedTabNotifier, }) : trailingWidgets = List.generate( tabs.length, (index) => tabs[index].trailing ?? const SizedBox(), @@ -106,6 +107,8 @@ class AnalyticsTabbedView extends StatefulWidget { final void Function(int)? onTabChanged; + final ValueNotifier? selectedTabNotifier; + @override _AnalyticsTabbedViewState createState() => _AnalyticsTabbedViewState(); } @@ -131,6 +134,8 @@ class _AnalyticsTabbedViewState extends State ..index = _currentTabControllerIndex ..addListener(_onTabChanged); + widget.selectedTabNotifier?.addListener(_onChangeTab); + // Record a selection for the visible tab. if (widget.sendAnalytics) { ga.select( @@ -146,6 +151,8 @@ class _AnalyticsTabbedViewState extends State if (_currentTabControllerIndex != newIndex) { setState(() { _currentTabControllerIndex = newIndex; + widget.selectedTabNotifier?.value = + _tabIndexToTabKey(_currentTabControllerIndex)!; widget.onTabChanged?.call(newIndex); }); if (widget.sendAnalytics) { @@ -157,6 +164,26 @@ class _AnalyticsTabbedViewState extends State } } + void _onChangeTab() { + if (widget.selectedTabNotifier == null) return; + _currentTabControllerIndex = + _tabKeyToTabIndex(widget.selectedTabNotifier!.value); + _tabController?.animateTo( + _currentTabControllerIndex, + duration: const Duration(), + ); + } + + int _tabKeyToTabIndex(Key tabKey) { + final tabIndex = widget.tabs.indexWhere((tab) => tab.key == tabKey); + if (tabIndex == -1) print('Unable to find tab with key $tabKey'); + return tabIndex; + } + + Key? _tabIndexToTabKey(int tabIndex) { + return widget.tabs[tabIndex].key; + } + @override void initState() { super.initState(); @@ -174,6 +201,7 @@ class _AnalyticsTabbedViewState extends State @override void dispose() { + widget.selectedTabNotifier?.removeListener(_onChangeTab); _tabController?.removeListener(_onTabChanged); _tabController?.dispose(); super.dispose();