From 315bbd37fd0d0f09ef9704162f4b57682ca2f29c Mon Sep 17 00:00:00 2001 From: lilua yang Date: Wed, 8 Apr 2026 17:41:34 +0800 Subject: [PATCH] feat(devtools): add filter and sort for data table - Add FilterDialog: filter by scope, type, async/ready/created state - Add SortDialog: sort by type, instance name, details with asc/desc --- tool/get_it_devtools_extension/lib/main.dart | 245 ++++++++++++++++-- .../lib/src/widgets/filter_dialog.dart | 234 +++++++++++++++++ .../lib/src/widgets/sort_dialog.dart | 112 ++++++++ tool/get_it_devtools_extension/pubspec.lock | 2 +- tool/get_it_devtools_extension/pubspec.yaml | 1 + 5 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 tool/get_it_devtools_extension/lib/src/widgets/filter_dialog.dart create mode 100644 tool/get_it_devtools_extension/lib/src/widgets/sort_dialog.dart diff --git a/tool/get_it_devtools_extension/lib/main.dart b/tool/get_it_devtools_extension/lib/main.dart index 6e060ba..fa9ca2d 100644 --- a/tool/get_it_devtools_extension/lib/main.dart +++ b/tool/get_it_devtools_extension/lib/main.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; import 'package:flutter/material.dart'; +import 'package:get_it_devtools_extension/src/widgets/filter_dialog.dart'; +import 'package:get_it_devtools_extension/src/widgets/sort_dialog.dart'; import 'package:vm_service/vm_service.dart'; import 'src/model.dart'; @@ -31,6 +33,19 @@ class _GetItDevToolsScreenState extends State { List _registrations = []; bool _isLoading = true; String? _error; + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + // Sorting + SortField _sortField = SortField.defaultOrder; + SortDirection _sortDirection = SortDirection.asc; + + // Filters + final Set _selectedRegistrationTypes = {}; + final Set _selectedScopes = {}; + bool? _filterAsync; + bool? _filterReady; + bool? _filterCreated; @override void initState() { @@ -38,6 +53,60 @@ class _GetItDevToolsScreenState extends State { _init(); } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List get _filteredRegistrations { + final filtered = [ + if (_searchQuery.isNotEmpty) _matchesSearch, + if (_selectedScopes.isNotEmpty) _matchesScope, + if (_selectedRegistrationTypes.isNotEmpty) _matchesRegistrationType, + if (_filterAsync != null) _matchesAsync, + if (_filterReady != null) _matchesReady, + if (_filterCreated != null) _matchesCreated, + ].fold>(_registrations, (data, predicate) => data.where(predicate)).toList(); + + // For defaultOrder, descending means reversing the list + if (_sortField == SortField.defaultOrder) { + return _sortDirection == SortDirection.desc ? filtered.reversed.toList() : filtered; + } + + return filtered..sort(_compareBySortField); + } + + bool _matchesSearch(RegistrationInfo item) { + final query = _searchQuery.toLowerCase(); + return item.type.toLowerCase().contains(query) || + (item.instanceName?.toLowerCase().contains(query) ?? false) || + (item.instanceDetails?.toLowerCase().contains(query) ?? false); + } + + bool _matchesScope(RegistrationInfo item) => _selectedScopes.contains(item.scopeName); + + bool _matchesRegistrationType(RegistrationInfo item) => _selectedRegistrationTypes.contains(item.registrationType); + + bool _matchesAsync(RegistrationInfo item) => item.isAsync == _filterAsync; + + bool _matchesReady(RegistrationInfo item) => item.isReady == _filterReady; + + bool _matchesCreated(RegistrationInfo item) => item.isCreated == _filterCreated; + + int _compareBySortField(RegistrationInfo a, RegistrationInfo b) { + if (_sortField == SortField.defaultOrder) return 0; + + final comparison = switch (_sortField) { + SortField.type => a.type.compareTo(b.type), + SortField.instanceName => (a.instanceName ?? '').compareTo(b.instanceName ?? ''), + SortField.instanceDetails => (a.instanceDetails ?? '').compareTo(b.instanceDetails ?? ''), + SortField.defaultOrder => 0, + }; + + return _sortDirection == SortDirection.asc ? comparison : -comparison; + } + Future _init() async { try { await _fetchRegistrations(); @@ -58,13 +127,9 @@ class _GetItDevToolsScreenState extends State { Future _fetchRegistrations() async { try { - final response = await serviceManager.callServiceExtensionOnMainIsolate( - 'ext.get_it.getRegistrations', - ); + final response = await serviceManager.callServiceExtensionOnMainIsolate('ext.get_it.getRegistrations'); final List data = response.json?['registrations'] ?? []; - final registrations = data - .map((e) => RegistrationInfo.fromJson(e as Map)) - .toList(); + final registrations = data.map((e) => RegistrationInfo.fromJson(e as Map)).toList(); setState(() { _registrations = registrations; @@ -74,8 +139,7 @@ class _GetItDevToolsScreenState extends State { // If the extension is not registered yet (app starting up), we might get an error. // We can retry or just show empty state. setState(() { - _error = - 'Could not fetch registrations. Make sure debugEventsEnabled is true in GetIt.'; + _error = 'Could not fetch registrations. Make sure debugEventsEnabled is true in GetIt.'; _isLoading = false; }); } @@ -94,10 +158,7 @@ class _GetItDevToolsScreenState extends State { children: [ Text('Error: $_error'), const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchRegistrations, - child: const Text('Retry'), - ), + ElevatedButton(onPressed: _fetchRegistrations, child: const Text('Retry')), ], ), ); @@ -108,19 +169,57 @@ class _GetItDevToolsScreenState extends State { AreaPaneHeader( title: const Text('GetIt Registrations'), actions: [ - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: _fetchRegistrations, - ), + IconButton(icon: const Icon(Icons.filter_list), tooltip: 'Filter', onPressed: _handleFilterPressed), + IconButton(icon: const Icon(Icons.sort), tooltip: 'Sort', onPressed: _handleSortPressed), + IconButton(icon: const Icon(Icons.refresh), tooltip: 'Refresh', onPressed: _fetchRegistrations), ], ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search registrations...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty == true + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: const OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + // Show active filter chips + if (_selectedRegistrationTypes.isNotEmpty || + _selectedScopes.isNotEmpty || + _filterAsync != null || + _filterReady != null || + _filterCreated != null) + _buildFilterChips(), Expanded(child: _buildTable()), ], ); } Widget _buildTable() { + final filtered = _filteredRegistrations; + + if (filtered.isEmpty && _searchQuery.isNotEmpty) { + return const Center(child: Text('No results found')); + } + return SingleChildScrollView( scrollDirection: Axis.vertical, child: SingleChildScrollView( @@ -136,7 +235,7 @@ class _GetItDevToolsScreenState extends State { DataColumn(label: Text('Created')), DataColumn(label: Text('Instance Details')), ], - rows: _registrations.map((item) { + rows: filtered.map((item) { return DataRow( cells: [ DataCell(Text(item.type)), @@ -150,10 +249,7 @@ class _GetItDevToolsScreenState extends State { item.instanceDetails != null ? Tooltip( message: item.instanceDetails!, - child: Text( - _truncateText(item.instanceDetails!, 50), - overflow: TextOverflow.ellipsis, - ), + child: Text(_truncateText(item.instanceDetails!, 50), overflow: TextOverflow.ellipsis), ) : const Text(''), ), @@ -171,4 +267,109 @@ class _GetItDevToolsScreenState extends State { } return '${text.substring(0, maxLength)}...'; } + + Padding _buildFilterChips() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ..._selectedScopes.map( + (scope) => Chip( + label: Text('Scope: $scope'), + onDeleted: () { + setState(() { + _selectedScopes.remove(scope); + }); + }, + ), + ), + ..._selectedRegistrationTypes.map( + (type) => Chip( + label: Text('Mode: $type'), + onDeleted: () { + setState(() { + _selectedRegistrationTypes.remove(type); + }); + }, + ), + ), + if (_filterAsync != null) + Chip( + label: Text('Async: ${_filterAsync! ? 'Yes' : 'No'}'), + onDeleted: () { + setState(() { + _filterAsync = null; + }); + }, + ), + if (_filterReady != null) + Chip( + label: Text('Ready: ${_filterReady! ? 'Yes' : 'No'}'), + onDeleted: () { + setState(() { + _filterReady = null; + }); + }, + ), + if (_filterCreated != null) + Chip( + label: Text('Created: ${_filterCreated! ? 'Yes' : 'No'}'), + onDeleted: () { + setState(() { + _filterCreated = null; + }); + }, + ), + ], + ), + ); + } + + Future _handleFilterPressed() async { + final allRegistrationTypes = _registrations.map((r) => r.registrationType).toSet().toList()..sort(); + final allScopes = _registrations.map((r) => r.scopeName).toSet().toList()..sort(); + + final initialState = FilterState( + selectedScopes: _selectedScopes, + selectedRegistrationTypes: _selectedRegistrationTypes, + filterAsync: _filterAsync, + filterReady: _filterReady, + filterCreated: _filterCreated, + ); + final result = await showDialog( + context: context, + builder: (context) => + FilterDialog(registrationTypes: allRegistrationTypes, scopes: allScopes, initialState: initialState), + ); + + if (result != null) { + setState(() { + _selectedScopes + ..clear() + ..addAll(result.selectedScopes); + _selectedRegistrationTypes + ..clear() + ..addAll(result.selectedRegistrationTypes); + _filterAsync = result.filterAsync; + _filterReady = result.filterReady; + _filterCreated = result.filterCreated; + }); + } + } + + Future _handleSortPressed() async { + final initialState = SortState(field: _sortField, direction: _sortDirection); + final result = await showDialog( + context: context, + builder: (context) => SortDialog(initialState: initialState), + ); + if (result != null) { + setState(() { + _sortField = result.field; + _sortDirection = result.direction; + }); + } + } } diff --git a/tool/get_it_devtools_extension/lib/src/widgets/filter_dialog.dart b/tool/get_it_devtools_extension/lib/src/widgets/filter_dialog.dart new file mode 100644 index 0000000..43b3325 --- /dev/null +++ b/tool/get_it_devtools_extension/lib/src/widgets/filter_dialog.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; + +/// Filter state data class +class FilterState { + final Set selectedScopes; + final Set selectedRegistrationTypes; + final bool? filterAsync; + final bool? filterReady; + final bool? filterCreated; + + const FilterState({ + this.selectedScopes = const {}, + this.selectedRegistrationTypes = const {}, + this.filterAsync, + this.filterReady, + this.filterCreated, + }); + + FilterState copyWith({ + Set? selectedScopes, + Set? selectedRegistrationTypes, + bool? filterAsync, + bool? filterReady, + bool? filterCreated, + }) { + return FilterState( + selectedScopes: selectedScopes ?? this.selectedScopes, + selectedRegistrationTypes: selectedRegistrationTypes ?? this.selectedRegistrationTypes, + filterAsync: filterAsync ?? this.filterAsync, + filterReady: filterReady ?? this.filterReady, + filterCreated: filterCreated ?? this.filterCreated, + ); + } +} + +/// Standalone filter dialog widget +class FilterDialog extends StatefulWidget { + final List registrationTypes; + final List scopes; + final FilterState initialState; + + const FilterDialog({super.key, required this.registrationTypes, required this.scopes, required this.initialState}); + + @override + State createState() => _FilterDialogState(); +} + +class _FilterDialogState extends State { + late Set selectedScopes; + late Set selectedRegistrationTypes; + late bool? filterAsync; + late bool? filterReady; + late bool? filterCreated; + + @override + void initState() { + super.initState(); + selectedScopes = Set.from(widget.initialState.selectedScopes); + selectedRegistrationTypes = Set.from(widget.initialState.selectedRegistrationTypes); + filterAsync = widget.initialState.filterAsync; + filterReady = widget.initialState.filterReady; + filterCreated = widget.initialState.filterCreated; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Filter'), + content: SingleChildScrollView( + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.scopes.isNotEmpty) ...[ + const Text('Scope:', style: TextStyle(fontWeight: FontWeight.bold)), + Wrap( + spacing: 8, + runSpacing: 8, + children: widget.scopes + .map( + (scope) => FilterChip( + label: Text(scope), + selected: selectedScopes.contains(scope), + onSelected: (selected) { + setState(() { + if (selected) { + selectedScopes.add(scope); + } else { + selectedScopes.remove(scope); + } + }); + }, + ), + ) + .toList(), + ), + ], + if (widget.registrationTypes.isNotEmpty) ...[ + const Text('Mode:', style: TextStyle(fontWeight: FontWeight.bold)), + Wrap( + spacing: 8, + runSpacing: 8, + children: widget.registrationTypes + .map( + (type) => FilterChip( + label: Text(type), + selected: selectedRegistrationTypes.contains(type), + onSelected: (selected) { + setState(() { + if (selected) { + selectedRegistrationTypes.add(type); + } else { + selectedRegistrationTypes.remove(type); + } + }); + }, + ), + ) + .toList(), + ), + ], + const Text('Async:', style: TextStyle(fontWeight: FontWeight.bold)), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilterChip( + label: const Text('Yes'), + selected: filterAsync == true, + onSelected: (selected) { + setState(() { + filterAsync = selected ? true : null; + }); + }, + ), + FilterChip( + label: const Text('No'), + selected: filterAsync == false, + onSelected: (selected) { + setState(() { + filterAsync = selected ? false : null; + }); + }, + ), + ], + ), + + const Text('Ready:', style: TextStyle(fontWeight: FontWeight.bold)), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilterChip( + label: const Text('Yes'), + selected: filterReady == true, + onSelected: (selected) { + setState(() { + filterReady = selected ? true : null; + }); + }, + ), + FilterChip( + label: const Text('No'), + selected: filterReady == false, + onSelected: (selected) { + setState(() { + filterReady = selected ? false : null; + }); + }, + ), + ], + ), + + const Text('Created:', style: TextStyle(fontWeight: FontWeight.bold)), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilterChip( + label: const Text('Yes'), + selected: filterCreated == true, + onSelected: (selected) { + setState(() { + filterCreated = selected ? true : null; + }); + }, + ), + FilterChip( + label: const Text('No'), + selected: filterCreated == false, + onSelected: (selected) { + setState(() { + filterCreated = selected ? false : null; + }); + }, + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + setState(() { + selectedScopes.clear(); + selectedRegistrationTypes.clear(); + filterAsync = null; + filterReady = null; + filterCreated = null; + }); + }, + child: const Text('Clear All'), + ), + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.of(context).pop( + FilterState( + selectedScopes: selectedScopes, + selectedRegistrationTypes: selectedRegistrationTypes, + filterAsync: filterAsync, + filterReady: filterReady, + filterCreated: filterCreated, + ), + ); + }, + child: const Text('OK'), + ), + ], + ); + } +} diff --git a/tool/get_it_devtools_extension/lib/src/widgets/sort_dialog.dart b/tool/get_it_devtools_extension/lib/src/widgets/sort_dialog.dart new file mode 100644 index 0000000..3be0b7d --- /dev/null +++ b/tool/get_it_devtools_extension/lib/src/widgets/sort_dialog.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +enum SortField { defaultOrder, type, instanceName, instanceDetails } + +enum SortDirection { asc, desc } + +/// Sort state data class +class SortState { + final SortField field; + final SortDirection direction; + + const SortState({this.field = SortField.defaultOrder, this.direction = SortDirection.asc}); +} + +/// Standalone sort dialog widget +class SortDialog extends StatefulWidget { + final SortState initialState; + + const SortDialog({super.key, required this.initialState}); + + @override + State createState() => _SortDialogState(); +} + +class _SortDialogState extends State { + late SortField selectedField; + late SortDirection selectedDirection; + + @override + void initState() { + super.initState(); + selectedField = widget.initialState.field; + selectedDirection = widget.initialState.direction; + } + + String _sortFieldName(SortField field) { + switch (field) { + case SortField.defaultOrder: + return 'Default Order'; + case SortField.type: + return 'Type'; + case SortField.instanceName: + return 'Instance Name'; + case SortField.instanceDetails: + return 'Instance Details'; + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Sort By'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Sort field selection + ...SortField.values.map( + (field) => RadioMenuButton( + value: field, + groupValue: selectedField, + onChanged: (value) { + setState(() { + selectedField = value!; + }); + }, + child: Text(_sortFieldName(field)), + ), + ), + const Divider(), + // Sort direction selection + Row( + children: [ + Expanded( + child: RadioMenuButton( + value: SortDirection.asc, + groupValue: selectedDirection, + onChanged: (value) { + setState(() { + selectedDirection = value!; + }); + }, + child: const Text('Ascending'), + ), + ), + Expanded( + child: RadioMenuButton( + value: SortDirection.desc, + groupValue: selectedDirection, + onChanged: (value) { + setState(() { + selectedDirection = value!; + }); + }, + child: const Text('Descending'), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.of(context).pop(SortState(field: selectedField, direction: selectedDirection)); + }, + child: const Text('OK'), + ), + ], + ); + } +} diff --git a/tool/get_it_devtools_extension/pubspec.lock b/tool/get_it_devtools_extension/pubspec.lock index ab13de3..fbeab00 100644 --- a/tool/get_it_devtools_extension/pubspec.lock +++ b/tool/get_it_devtools_extension/pubspec.lock @@ -486,7 +486,7 @@ packages: source: hosted version: "2.2.0" vm_service: - dependency: transitive + dependency: "direct main" description: name: vm_service sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" diff --git a/tool/get_it_devtools_extension/pubspec.yaml b/tool/get_it_devtools_extension/pubspec.yaml index 2f4f567..8fff229 100644 --- a/tool/get_it_devtools_extension/pubspec.yaml +++ b/tool/get_it_devtools_extension/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 devtools_extensions: ^0.4.0 devtools_app_shared: ^0.4.0 + vm_service: ^15.0.2 dev_dependencies: flutter_test: