diff --git a/CODEOWNERS b/CODEOWNERS index 5a5749661927..c54cc035caf9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -123,3 +123,7 @@ packages/local_auth/local_auth_windows/** @cbracken packages/path_provider/path_provider_windows/** @cbracken packages/shared_preferences/shared_preferences_windows/** @cbracken packages/url_launcher/url_launcher_windows/** @cbracken + +# - DevTools extensions +# @adsonpleal is the actual maintainer of shared_preferences_tool but is not yet a committer, so can't be listed as the owner. +packages/shared_preferences/shared_preferences_tool/** @tarrinneal diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 81708ac65323..e4234cd0651c 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.4.0 + +* Adds shared preferences devtools extension + ## 2.3.3 * Clarifies scope of prefix handling in README. diff --git a/packages/shared_preferences/shared_preferences/extension/devtools/.gitignore b/packages/shared_preferences/shared_preferences/extension/devtools/.gitignore new file mode 100644 index 000000000000..378eac25d311 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/extension/devtools/.gitignore @@ -0,0 +1 @@ +build diff --git a/packages/shared_preferences/shared_preferences/extension/devtools/.pubignore b/packages/shared_preferences/shared_preferences/extension/devtools/.pubignore new file mode 100644 index 000000000000..71860a75dbf2 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/extension/devtools/.pubignore @@ -0,0 +1 @@ +!build diff --git a/packages/shared_preferences/shared_preferences/extension/devtools/config.yaml b/packages/shared_preferences/shared_preferences/extension/devtools/config.yaml new file mode 100644 index 000000000000..34fb24e98763 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/extension/devtools/config.yaml @@ -0,0 +1,4 @@ +name: shared_preferences +issueTracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 +version: 1.0.0 +materialIconCodePoint: '0xe683' diff --git a/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_async.dart b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_async.dart index 516788542fd1..ee83433ca7a7 100644 --- a/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_async.dart +++ b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_async.dart @@ -8,6 +8,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shared_preferences_platform_interface/types.dart'; +import 'shared_preferences_devtools_extension_data.dart'; + /// Provides a persistent store for simple data. /// /// Data is persisted to and fetched from the disk asynchronously. @@ -401,3 +403,10 @@ class SharedPreferencesWithCache { return _cacheOptions.allowList?.contains(key) ?? true; } } + +// Include an unused import to ensure this library is included +// when running `flutter run -d chrome`. +// Check this discussion for more info: https://github.com/flutter/packages/pull/6749/files/6eb1b4fdce1eba107294770d581713658ff971e9#discussion_r1755375409 +// ignore: unused_element +final bool _fieldToKeepDevtoolsExtensionReachable = + fieldToKeepDevtoolsExtensionLibraryAlive; diff --git a/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_devtools_extension_data.dart b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_devtools_extension_data.dart new file mode 100644 index 000000000000..2b762f112b69 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_devtools_extension_data.dart @@ -0,0 +1,138 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; + +import '../shared_preferences.dart'; + +const String _eventPrefix = 'shared_preferences.'; + +/// A typedef for the post event function. +@visibleForTesting +typedef PostEvent = void Function( + String eventKind, + Map eventData, +); + +/// A helper class that provides data to the DevTools extension. +/// +/// It is only visible for testing and eval. +@visibleForTesting +class SharedPreferencesDevToolsExtensionData { + /// The default constructor for [SharedPreferencesDevToolsExtensionData]. + /// + /// Accepts an optional [PostEvent] that should only be overwritten when testing. + const SharedPreferencesDevToolsExtensionData([ + this._postEvent = developer.postEvent, + ]); + + final PostEvent _postEvent; + + /// Requests all legacy and async keys and post an event with the result. + Future requestAllKeys() async { + final SharedPreferences legacyPrefs = await SharedPreferences.getInstance(); + final Set legacyKeys = legacyPrefs.getKeys(); + final Set asyncKeys = await SharedPreferencesAsync().getKeys(); + + _postEvent('${_eventPrefix}all_keys', >{ + 'asyncKeys': asyncKeys.toList(), + 'legacyKeys': legacyKeys.toList(), + }); + } + + /// Requests the value for a given key and posts an event with the result. + Future requestValue(String key, bool legacy) async { + final Object? value; + if (legacy) { + final SharedPreferences legacyPrefs = + await SharedPreferences.getInstance(); + value = legacyPrefs.get(key); + } else { + value = await SharedPreferencesAsync().getAll(allowList: { + key + }).then((Map map) => map.values.firstOrNull); + } + + _postEvent('${_eventPrefix}value', { + 'value': value, + // It is safe to use `runtimeType` here. This code + // will only ever run in debug mode. + 'kind': value.runtimeType.toString(), + }); + } + + /// Requests the value change for the given key and posts an empty event when finished. + Future requestValueChange( + String key, + String serializedValue, + String kind, + bool legacy, + ) async { + final Object? value = jsonDecode(serializedValue); + if (legacy) { + final SharedPreferences legacyPrefs = + await SharedPreferences.getInstance(); + // we need to check the kind because sometimes a double + // gets interpreted as an int. If this was not an issue + // we'd only need to do a simple pattern matching on value. + switch (kind) { + case 'int': + await legacyPrefs.setInt(key, value! as int); + case 'bool': + await legacyPrefs.setBool(key, value! as bool); + case 'double': + await legacyPrefs.setDouble(key, value! as double); + case 'String': + await legacyPrefs.setString(key, value! as String); + case 'List': + await legacyPrefs.setStringList( + key, + (value! as List).cast(), + ); + } + } else { + final SharedPreferencesAsync prefs = SharedPreferencesAsync(); + // we need to check the kind because sometimes a double + // gets interpreted as an int. If this was not an issue + // we'd only need to do a simple pattern matching on value. + switch (kind) { + case 'int': + await prefs.setInt(key, value! as int); + case 'bool': + await prefs.setBool(key, value! as bool); + case 'double': + await prefs.setDouble(key, value! as double); + case 'String': + await prefs.setString(key, value! as String); + case 'List': + await prefs.setStringList( + key, + (value! as List).cast(), + ); + } + } + _postEvent('${_eventPrefix}change_value', {}); + } + + /// Requests a key removal and posts an empty event when removed. + Future requestRemoveKey(String key, bool legacy) async { + if (legacy) { + final SharedPreferences legacyPrefs = + await SharedPreferences.getInstance(); + await legacyPrefs.remove(key); + } else { + await SharedPreferencesAsync().remove(key); + } + _postEvent('${_eventPrefix}remove', {}); + } +} + +/// Include a variable to keep the library alive in web builds. +/// It must be a `final` variable. +/// Check this discussion for more info: https://github.com/flutter/packages/pull/6749/files/6eb1b4fdce1eba107294770d581713658ff971e9#discussion_r1755375409 +// ignore: prefer_const_declarations +final bool fieldToKeepDevtoolsExtensionLibraryAlive = false; diff --git a/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_legacy.dart b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_legacy.dart index 72deffe5fe9e..aa7dbbf31cad 100644 --- a/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_legacy.dart +++ b/packages/shared_preferences/shared_preferences/lib/src/shared_preferences_legacy.dart @@ -8,6 +8,8 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:shared_preferences_platform_interface/types.dart'; +import 'shared_preferences_devtools_extension_data.dart'; + /// Wraps NSUserDefaults (on iOS) and SharedPreferences (on Android), providing /// a persistent store for simple data. /// @@ -285,3 +287,10 @@ Either update the implementation to support setPrefix, or do not call setPrefix. _completer = null; } } + +// Include an unused import to ensure this library is included +// when running `flutter run -d chrome`. +// Check this discussion for more info: https://github.com/flutter/packages/pull/6749/files/6eb1b4fdce1eba107294770d581713658ff971e9#discussion_r1755375409 +// ignore: unused_element +final bool _fieldToKeepDevtoolsExtensionReachable = + fieldToKeepDevtoolsExtensionLibraryAlive; diff --git a/packages/shared_preferences/shared_preferences/pubspec.yaml b/packages/shared_preferences/shared_preferences/pubspec.yaml index 6c715ff7e3af..3d13d99684c4 100644 --- a/packages/shared_preferences/shared_preferences/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences/pubspec.yaml @@ -1,9 +1,8 @@ name: shared_preferences -description: Flutter plugin for reading and writing simple key-value pairs. - Wraps NSUserDefaults on iOS and SharedPreferences on Android. +description: Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. repository: https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.3.3 +version: 2.4.0 environment: sdk: ^3.4.0 @@ -40,6 +39,7 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + path: ^1.9.0 topics: - persistence diff --git a/packages/shared_preferences/shared_preferences/test/shared_preferences_devtools_extension_data_test.dart b/packages/shared_preferences/shared_preferences/test/shared_preferences_devtools_extension_data_test.dart new file mode 100644 index 000000000000..9be78eda15d2 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/test/shared_preferences_devtools_extension_data_test.dart @@ -0,0 +1,452 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences/src/shared_preferences_devtools_extension_data.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'shared_preferences_async_test.dart'; + +typedef _Event = (String eventKind, Map eventData); + +class _FakePostEvent { + final List<_Event> eventLog = <_Event>[]; + + void call( + String eventKind, + Map eventData, + ) { + eventLog.add((eventKind, eventData)); + } +} + +void main() { + group('DevtoolsExtension', () { + late SharedPreferencesAsync asyncPreferences; + late _FakePostEvent fakePostEvent; + late SharedPreferencesDevToolsExtensionData extension; + + setUp(() { + SharedPreferencesAsyncPlatform.instance = FakeSharedPreferencesAsync(); + asyncPreferences = SharedPreferencesAsync(); + fakePostEvent = _FakePostEvent(); + extension = SharedPreferencesDevToolsExtensionData(fakePostEvent.call); + }); + + test('should request all keys', () async { + SharedPreferences.setMockInitialValues({ + 'key1': 1, + 'key2': true, + }); + await asyncPreferences.setBool('key3', true); + await asyncPreferences.setInt('key4', 1); + + await extension.requestAllKeys(); + + expect(fakePostEvent.eventLog.length, equals(1)); + final ( + String eventKind, + Map eventData, + ) = fakePostEvent.eventLog.first; + expect( + eventKind, + equals('shared_preferences.all_keys'), + ); + expect( + eventData, + equals(>{ + 'asyncKeys': ['key3', 'key4'], + 'legacyKeys': ['key1', 'key2'], + }), + ); + }); + + group('async api', () { + Future testAsyncApiRequestValue( + String key, { + required Map expectedData, + }) async { + const bool legacy = false; + + await extension.requestValue( + key, + legacy, + ); + + expect(fakePostEvent.eventLog.length, equals(1)); + final ( + String eventKind, + Map eventData, + ) = fakePostEvent.eventLog.first; + expect( + eventKind, + equals('shared_preferences.value'), + ); + expect( + eventData, + equals(expectedData), + ); + } + + test('should request bool value from async api', () async { + const String key = 'key'; + const bool expectedValue = true; + await asyncPreferences.setBool(key, expectedValue); + + await testAsyncApiRequestValue( + key, + expectedData: { + 'value': expectedValue, + 'kind': 'bool', + }, + ); + }); + + test('should request int value from async api', () async { + const String key = 'key'; + const int expectedValue = 42; + await asyncPreferences.setInt(key, expectedValue); + + await testAsyncApiRequestValue( + key, + expectedData: { + 'value': expectedValue, + 'kind': 'int', + }, + ); + }); + + test('should request double value from async api', () async { + const String key = 'key'; + const double expectedValue = 42.2; + await asyncPreferences.setDouble(key, expectedValue); + + await testAsyncApiRequestValue( + key, + expectedData: { + 'value': expectedValue, + 'kind': 'double', + }, + ); + }); + + test('should request string value from async api', () async { + const String key = 'key'; + const String expectedValue = 'value'; + await asyncPreferences.setString(key, expectedValue); + + await testAsyncApiRequestValue( + key, + expectedData: { + 'value': expectedValue, + 'kind': 'String', + }, + ); + }); + + test('should request string list value from async api', () async { + const String key = 'key'; + const List expectedValue = ['string1', 'string2']; + await asyncPreferences.setStringList(key, expectedValue); + + await testAsyncApiRequestValue( + key, + expectedData: { + 'value': expectedValue, + 'kind': 'List', + }, + ); + }); + + Future testAsyncApiValueChange( + String key, + Object expectedValue, + ) async { + const bool legacy = false; + + await extension.requestValueChange( + key, + jsonEncode(expectedValue), + expectedValue.runtimeType.toString(), + legacy, + ); + + expect(fakePostEvent.eventLog.length, equals(1)); + final ( + String eventKind, + Map eventData, + ) = fakePostEvent.eventLog.first; + expect( + eventKind, + equals('shared_preferences.change_value'), + ); + expect( + eventData, + equals({}), + ); + } + + test('should request int value change on async api', () async { + const String key = 'key'; + const int expectedValue = 42; + await asyncPreferences.setInt(key, 24); + + await testAsyncApiValueChange(key, expectedValue); + + expect( + await asyncPreferences.getInt(key), + equals(expectedValue), + ); + }); + + test('should request bool value change on async api', () async { + const String key = 'key'; + const bool expectedValue = false; + await asyncPreferences.setBool(key, true); + + await testAsyncApiValueChange(key, expectedValue); + + expect( + await asyncPreferences.getBool(key), + equals(expectedValue), + ); + }); + + test('should request double value change on async api', () async { + const String key = 'key'; + const double expectedValue = 22.22; + await asyncPreferences.setDouble(key, 11.1); + + await testAsyncApiValueChange(key, expectedValue); + + expect( + await asyncPreferences.getDouble(key), + equals(expectedValue), + ); + }); + + test('should request string value change on async api', () async { + const String key = 'key'; + const String expectedValue = 'new value'; + await asyncPreferences.setString(key, 'old value'); + + await testAsyncApiValueChange(key, expectedValue); + + expect( + await asyncPreferences.getString(key), + equals(expectedValue), + ); + }); + + test('should request string list value change on async api', () async { + const String key = 'key'; + const List expectedValue = ['string1', 'string2']; + await asyncPreferences.setStringList(key, ['old1', 'old2']); + + await testAsyncApiValueChange(key, expectedValue); + + expect( + await asyncPreferences.getStringList(key), + equals(expectedValue), + ); + }); + }); + + group('legacy api', () { + Future testLegacyApiRequestValue( + String key, { + required Map expectedData, + }) async { + const bool legacy = true; + + await extension.requestValue( + key, + legacy, + ); + + expect(fakePostEvent.eventLog.length, equals(1)); + final ( + String eventKind, + Map eventData, + ) = fakePostEvent.eventLog.first; + expect( + eventKind, + equals('shared_preferences.value'), + ); + expect(eventData, equals(expectedData)); + } + + test('should request bool value from legacy api', () async { + const String key = 'key'; + const bool expectedValue = false; + SharedPreferences.setMockInitialValues({ + key: expectedValue, + }); + + await testLegacyApiRequestValue(key, expectedData: { + 'value': expectedValue, + 'kind': 'bool', + }); + }); + + test('should request int value from legacy api', () async { + const String key = 'key'; + const int expectedValue = 42; + SharedPreferences.setMockInitialValues({ + key: expectedValue, + }); + + await testLegacyApiRequestValue(key, expectedData: { + 'value': expectedValue, + 'kind': 'int', + }); + }); + + test('should request double value from legacy api', () async { + const String key = 'key'; + const double expectedValue = 42.2; + SharedPreferences.setMockInitialValues({ + key: expectedValue, + }); + + await testLegacyApiRequestValue(key, expectedData: { + 'value': expectedValue, + 'kind': 'double', + }); + }); + + test('should request string value from legacy api', () async { + const String key = 'key'; + const String expectedValue = 'value'; + SharedPreferences.setMockInitialValues({ + key: expectedValue, + }); + + await testLegacyApiRequestValue(key, expectedData: { + 'value': expectedValue, + 'kind': 'String', + }); + }); + + test('should request string list value from legacy api', () async { + const String key = 'key'; + const List expectedValue = ['string1', 'string2']; + SharedPreferences.setMockInitialValues({ + key: expectedValue, + }); + + await testLegacyApiRequestValue(key, expectedData: { + 'value': expectedValue, + 'kind': 'List', + }); + }); + + Future testLegacyApiValueChange( + String key, + Object expectedValue, + ) async { + const bool legacy = true; + + await extension.requestValueChange( + key, + jsonEncode(expectedValue), + expectedValue.runtimeType.toString(), + legacy, + ); + + expect(fakePostEvent.eventLog.length, equals(1)); + final ( + String eventKind, + Map eventData, + ) = fakePostEvent.eventLog.first; + expect( + eventKind, + equals('shared_preferences.change_value'), + ); + expect( + eventData, + equals({}), + ); + } + + test('should request int value change on legacy api', () async { + const String key = 'key'; + const int expectedValue = 42; + SharedPreferences.setMockInitialValues({ + key: 24, + }); + + await testLegacyApiValueChange(key, expectedValue); + + expect( + (await SharedPreferences.getInstance()).getInt(key), + equals(expectedValue), + ); + }); + + test('should request bool value change on legacy api', () async { + const String key = 'key'; + const bool expectedValue = false; + SharedPreferences.setMockInitialValues({ + key: true, + }); + + await testLegacyApiValueChange(key, expectedValue); + + expect( + (await SharedPreferences.getInstance()).getBool(key), + equals(expectedValue), + ); + }); + + test('should request double value change on legacy api', () async { + const String key = 'key'; + const double expectedValue = 1.11; + SharedPreferences.setMockInitialValues({ + key: 2.22, + }); + + await testLegacyApiValueChange(key, expectedValue); + + expect( + (await SharedPreferences.getInstance()).getDouble(key), + equals(expectedValue), + ); + }); + + test('should request string value change on legacy api', () async { + const String key = 'key'; + const String expectedValue = 'new value'; + SharedPreferences.setMockInitialValues({ + key: 'old value', + }); + + await testLegacyApiValueChange(key, expectedValue); + + expect( + (await SharedPreferences.getInstance()).getString(key), + equals(expectedValue), + ); + }); + + test('should request string list value change on legacy api', () async { + const String key = 'key'; + const List expectedValue = ['string1', 'string2']; + SharedPreferences.setMockInitialValues({ + key: ['old1', 'old2'], + }); + + await testLegacyApiValueChange(key, expectedValue); + + expect( + (await SharedPreferences.getInstance()).getStringList(key), + equals(expectedValue), + ); + }); + }); + }); +} diff --git a/packages/shared_preferences/shared_preferences/tool/pre_publish.dart b/packages/shared_preferences/shared_preferences/tool/pre_publish.dart new file mode 100644 index 000000000000..0b423ffca5cd --- /dev/null +++ b/packages/shared_preferences/shared_preferences/tool/pre_publish.dart @@ -0,0 +1,53 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +Future _runCommand({ + required String message, + required String executable, + required List arguments, +}) async { + stdout.write(message); + // The `packages/shared_preferences` directory. + final Directory sharedPreferencesToolParent = Directory( + p.dirname(Platform.script.path), + ).parent.parent; + + final ProcessResult pubGetResult = await Process.run( + executable, + arguments, + workingDirectory: p.join( + sharedPreferencesToolParent.path, + 'shared_preferences_tool', + ), + ); + + stdout.write(pubGetResult.stdout); + + if (pubGetResult.stderr != null) { + stderr.write(pubGetResult.stderr); + } +} + +Future main() async { + await _runCommand( + message: "Running 'flutter pub get' in shared_preferences_tool\n", + executable: 'flutter', + arguments: ['pub', 'get'], + ); + await _runCommand( + message: "Running 'build_and_copy' in shared_preferences_tool\n", + executable: 'dart', + arguments: [ + 'run', + 'devtools_extensions', + 'build_and_copy', + '--source=.', + '--dest=../shared_preferences/extension/devtools', + ], + ); +} diff --git a/packages/shared_preferences/shared_preferences_tool/LICENSE b/packages/shared_preferences/shared_preferences_tool/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/shared_preferences/shared_preferences_tool/README.md b/packages/shared_preferences/shared_preferences_tool/README.md new file mode 100644 index 000000000000..844f822e3bc8 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/README.md @@ -0,0 +1,52 @@ +A [DevTools extension](https://pub.dev/packages/devtools_extensions) for Flutter's [shared_preferences](https://pub.dev/packages/shared_preferences) package. + +## Features + +This package contains the source code for the `package:shared_preferences` DevTools extension. With this tool, you can: + +- List all keys stored in your app's `SharedPreferences`. +- Search for specific keys. +- Edit or remove values directly, with changes reflected in your app instantly. + +It supports all data types available in `SharedPreferences`: + +- `String` +- `int` +- `double` +- `bool` +- `List` + +## Running this project locally + +1. Run the [example](../shared_preferences/example/) project in the `shared_preferences` package and copy its debug service URL. +2. Run the `shared_preferences_tool` project by running the following command: + +```shell +flutter run -d chrome --dart-define=use_simulated_environment=true +``` + +For more information, see the [devtools_extensions](https://pub.dev/packages/devtools_extensions) package documentation. + +## Publishing this DevTools extension + +The Flutter web app in this package is built and distributed as part of. +`package:shared_preferences`. If there are changes to this tool that are +ready to publish as part of `shared_preferences`, then the publish +workflow for `shared_preferences` should follow these steps prior to publishing. + +1. Build the DevTools extension and move the assets to `shared_preferences`. + + ```sh + cd shared_preferences_tool; + flutter pub get; + dart run devtools_extensions build_and_copy --source=. --dest=../shared_preferences/extension/devtools + ``` + +2. Validate that `shared_preferences` is properly configured to distribute this extension. + + ```sh + cd shared_preferences_tool; + dart run devtools_extensions validate --package=../shared_preferences + ``` + +3. Publish `shared_preferences` as normal. diff --git a/packages/shared_preferences/shared_preferences_tool/lib/main.dart b/packages/shared_preferences/shared_preferences_tool/lib/main.dart new file mode 100644 index 000000000000..6b3eb3bad225 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/main.dart @@ -0,0 +1,64 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; + +import 'src/shared_preferences_state_provider.dart'; +import 'src/ui/shared_preferences_body.dart'; + +void main() { + runApp(const _SharedPreferencesTool()); +} + +class _SharedPreferencesTool extends StatelessWidget { + const _SharedPreferencesTool(); + + @override + Widget build(BuildContext context) { + return const DevToolsExtension( + child: _ConnectionManager(), + ); + } +} + +class _ConnectionManager extends StatefulWidget { + const _ConnectionManager(); + + @override + State<_ConnectionManager> createState() => _ConnectionManagerState(); +} + +class _ConnectionManagerState extends State<_ConnectionManager> { + @override + void initState() { + super.initState(); + // Used to move the application back to the loading state on the simulated + // environment when the developer disconnects the app. + serviceManager.registerLifecycleCallback( + ServiceManagerLifecycle.afterCloseVmService, + (_) { + setState(() {}); + }, + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: serviceManager.onServiceAvailable, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + return const SharedPreferencesStateProvider( + child: SharedPreferencesBody(), + ); + }); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/async_state.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/async_state.dart new file mode 100644 index 000000000000..0d103e650492 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/async_state.dart @@ -0,0 +1,146 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +@immutable + +/// A class that represents the state of an asynchronous operation. +/// +/// It has three possible states: +/// +/// 1. [AsyncState.loading] - The operation is in progress. +/// 2. [AsyncState.data] - The operation has completed successfully with data. +/// 3. [AsyncState.error] - The operation has completed with an error. +/// +/// Since this is a sealed class we can check the state in a switch statement/expression. +/// Check the [Switch statements](https://dart.dev/language/branches#switch-statements) documentation. +sealed class AsyncState { + const AsyncState(); + + const factory AsyncState.loading() = AsyncStateLoading._; + + const factory AsyncState.data(T data) = AsyncStateData._; + + const factory AsyncState.error(Object error, StackTrace? stackTrace) = + AsyncStateError._; + + /// Returns a [AsyncState] with the same type `T` but with the data transformed by the `onData` function. + /// If the current state is not [AsyncStateData], it returns the current state. + AsyncState whenData( + T Function(T data) onData, + ) { + return switch (this) { + AsyncStateData(data: final T data) => AsyncState.data(onData(data)), + _ => this, + }; + } + + /// Returns a [AsyncState] with the value R transformed by the `onData` + /// function. + /// + /// If the current state is [AsyncStateLoading] or [AsyncStateError], it + /// returns the current state, but with the type mapped. + /// If the current state is [AsyncStateData], it returns a new data state with + /// the data transformed by the `onData` function. + AsyncState mapWhenData( + R Function(T data) onData, + ) { + return flatMapWhenData((T data) => AsyncState.data(onData(data))); + } + + /// Transforms the data within an [AsyncState] into another [AsyncState] of a + /// different type using the `onData` function. + /// + /// If the current state is [AsyncStateLoading] or [AsyncStateError], it + /// returns the current state, but with the type mapped. + /// If the current state is [AsyncStateData], it returns the result of the + /// `onData` function. + AsyncState flatMapWhenData( + AsyncState Function(T data) onData, + ) { + return switch (this) { + AsyncStateData(data: final T data) => onData(data), + AsyncStateError( + error: final Object error, + stackTrace: final StackTrace? stackTrace, + ) => + AsyncState.error(error, stackTrace), + AsyncState() => AsyncState.loading(), + }; + } + + /// Returns the data `T` if the current state is [AsyncStateData], otherwise returns `null`. + T? get dataOrNull { + return switch (this) { + AsyncStateData(data: final T data) => data, + _ => null, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + switch (this) { + AsyncStateLoading() => other is AsyncStateLoading, + AsyncStateData(data: final T data) => + other is AsyncStateData && other.data == data, + AsyncStateError( + error: final Object error, + stackTrace: final StackTrace? stackTrace, + ) => + other is AsyncStateError && + other.error == error && + other.stackTrace == stackTrace, + }; + } + + @override + int get hashCode => switch (this) { + AsyncStateLoading() => 0, + AsyncStateData(data: final T data) => data.hashCode, + AsyncStateError( + error: final Object error, + stackTrace: final StackTrace? stackTrace, + ) => + error.hashCode ^ stackTrace.hashCode, + }; + + @override + String toString() { + return switch (this) { + AsyncStateLoading() => 'AsyncState.loading()', + AsyncStateData(data: final T data) => 'AsyncState.data($data)', + AsyncStateError( + error: final Object error, + stackTrace: final StackTrace? stackTrace, + ) => + 'AsyncState.error($error, $stackTrace)', + }; + } +} + +/// A class that represents the state of an asynchronous operation that is in progress. +class AsyncStateLoading extends AsyncState { + const AsyncStateLoading._(); +} + +/// A class that represents the state of an asynchronous operation that has completed successfully with data. +class AsyncStateData extends AsyncState { + const AsyncStateData._(this.data); + + /// The data of the operation. + final T data; +} + +/// A class that represents the state of an asynchronous operation that has completed with an error. +class AsyncStateError extends AsyncState { + const AsyncStateError._(this.error, this.stackTrace); + + /// The error of the operation. + final Object error; + + /// The stack trace of the error. + final StackTrace? stackTrace; +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state.dart new file mode 100644 index 000000000000..26b2d52e86b8 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state.dart @@ -0,0 +1,270 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'async_state.dart'; + +const Object _undefined = Object(); + +@immutable + +/// A class that represents the state of the shared preferences tool. +class SharedPreferencesState { + /// Default constructor for [SharedPreferencesState]. + const SharedPreferencesState({ + this.allKeys = const AsyncState>.loading(), + this.selectedKey, + this.editing = false, + this.legacyApi = false, + }); + + /// A list of all keys in the shared preferences of the target debug session using the selected API. + final AsyncState> allKeys; + + /// The user selected key and its value in the shared preferences + /// of the target debug session. + final SelectedSharedPreferencesKey? selectedKey; + + /// Whether the user is editing the value of the selected key or not. + final bool editing; + + /// Whether the user has selected the legacy api or not. + final bool legacyApi; + + /// Creates a copy of this [SharedPreferencesState] but replacing the given + /// fields with the new values. + SharedPreferencesState Function({ + AsyncState> allKeys, + SelectedSharedPreferencesKey? selectedKey, + bool editing, + bool legacyApi, + }) get copyWith => ({ + Object allKeys = _undefined, + Object? selectedKey = _undefined, + Object editing = _undefined, + Object legacyApi = _undefined, + }) { + return SharedPreferencesState( + allKeys: allKeys == _undefined + ? this.allKeys + : allKeys as AsyncState>, + selectedKey: selectedKey == _undefined + ? this.selectedKey + : selectedKey as SelectedSharedPreferencesKey?, + editing: editing == _undefined ? this.editing : editing as bool, + legacyApi: + legacyApi == _undefined ? this.legacyApi : legacyApi as bool, + ); + }; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is SharedPreferencesState && + other.allKeys == allKeys && + other.selectedKey == selectedKey && + other.editing == editing && + other.legacyApi == legacyApi); + } + + @override + int get hashCode => Object.hash( + allKeys, + selectedKey, + editing, + legacyApi, + ); + + @override + String toString() { + return 'SharedPreferencesState(allKeys: $allKeys, selectedKey: $selectedKey, editing: $editing)'; + } +} + +@immutable + +/// A class that represents the selected key and its value in the shared +/// preferences of the target debug session. +class SelectedSharedPreferencesKey { + /// Default constructor for [SelectedSharedPreferencesKey]. + const SelectedSharedPreferencesKey({ + required this.key, + required this.value, + }); + + /// The user selected key. + final String key; + + /// The value of the selected key in the shared preferences of the target + /// debug session. + final AsyncState value; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is SelectedSharedPreferencesKey && + other.key == key && + other.value == value); + } + + @override + int get hashCode => key.hashCode ^ value.hashCode; + + @override + String toString() { + return 'SelectedSharedPreferencesKey(key: $key, value: $value)'; + } +} + +abstract interface class _SharedPreferencesData { + T get value; +} + +@immutable + +/// A class that represents the data of a shared preference in the target +/// debug session. +sealed class SharedPreferencesData implements _SharedPreferencesData { + const SharedPreferencesData(); + + const factory SharedPreferencesData.string({ + required String value, + }) = SharedPreferencesDataString._; + + const factory SharedPreferencesData.int({ + required int value, + }) = SharedPreferencesDataInt._; + + const factory SharedPreferencesData.double({ + required double value, + }) = SharedPreferencesDataDouble._; + + const factory SharedPreferencesData.bool({ + required bool value, + }) = SharedPreferencesDataBool._; + + const factory SharedPreferencesData.stringList({ + required List value, + }) = SharedPreferencesDataStringList._; + + /// The string representation of the value. + String get valueAsString { + return switch (this) { + final SharedPreferencesDataStringList data => '\n${[ + for (final (int index, String str) in data.value.indexed) + '$index -> $str', + ].join('\n')}', + _ => '$value', + }; + } + + /// The kind of the value as a String. + String get kind { + return switch (this) { + SharedPreferencesDataString() => 'String', + SharedPreferencesDataInt() => 'int', + SharedPreferencesDataDouble() => 'double', + SharedPreferencesDataBool() => 'bool', + SharedPreferencesDataStringList() => 'List', + }; + } + + /// Changes the value of the shared preference to the new value. + /// + /// This is just a in memory change and does not affect the actual shared + /// preference value. + SharedPreferencesData changeValue(String newValue) { + return switch (this) { + SharedPreferencesDataString() => + SharedPreferencesData.string(value: newValue), + SharedPreferencesDataInt() => + SharedPreferencesData.int(value: int.parse(newValue)), + SharedPreferencesDataDouble() => + SharedPreferencesData.double(value: double.parse(newValue)), + SharedPreferencesDataBool() => + SharedPreferencesData.bool(value: bool.parse(newValue)), + SharedPreferencesDataStringList() => SharedPreferencesData.stringList( + value: (jsonDecode(newValue) as List).cast(), + ), + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is SharedPreferencesData && + switch (this) { + final SharedPreferencesDataStringList data => + other is SharedPreferencesDataStringList && + listEquals(other.value, data.value), + _ => other.value == value, + }); + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'SharedPreferencesData($valueAsString)'; + } +} + +/// A class that represents a shared preference with a string value. +class SharedPreferencesDataString extends SharedPreferencesData { + const SharedPreferencesDataString._({ + required this.value, + }); + + /// The string value of the shared preference. + @override + final String value; +} + +/// A class that represents a shared preference with an integer value. +class SharedPreferencesDataInt extends SharedPreferencesData { + const SharedPreferencesDataInt._({ + required this.value, + }); + + /// The integer value of the shared preference. + @override + final int value; +} + +/// A class that represents a shared preference with a double value. +class SharedPreferencesDataDouble extends SharedPreferencesData { + const SharedPreferencesDataDouble._({ + required this.value, + }); + + /// The double value of the shared preference. + @override + final double value; +} + +/// A class that represents a shared preference with a boolean value. +class SharedPreferencesDataBool extends SharedPreferencesData { + const SharedPreferencesDataBool._({ + required this.value, + }); + + /// The boolean value of the shared preference. + @override + final bool value; +} + +/// A class that represents a shared preference with a list of string values. +class SharedPreferencesDataStringList extends SharedPreferencesData { + const SharedPreferencesDataStringList._({ + required this.value, + }); + + /// The list of string values of the shared preference. + @override + final List value; +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_notifier.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_notifier.dart new file mode 100644 index 000000000000..a2607ee17501 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_notifier.dart @@ -0,0 +1,161 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/material.dart'; + +import 'async_state.dart'; +import 'shared_preferences_state.dart'; +import 'shared_preferences_tool_eval.dart'; + +/// A [ValueNotifier] that manages the state of the shared preferences tool. +class SharedPreferencesStateNotifier + extends ValueNotifier { + /// Default constructor that takes an instance of [SharedPreferencesToolEval]. + /// + /// You don't need to call this constructor directly. Use [SharedPreferencesStateNotifierProvider] instead. + SharedPreferencesStateNotifier( + this._eval, + ) : super(const SharedPreferencesState()); + + final SharedPreferencesToolEval _eval; + + List _asyncKeys = const []; + List _legacyKeys = const []; + + bool get _legacyApi => value.legacyApi; + + List get _keysForSelectedApi => _legacyApi ? _legacyKeys : _asyncKeys; + + /// Retrieves all keys from the shared preferences of the target debug session. + /// + /// If this is called when data already exists, it will update the list of keys. + Future fetchAllKeys() async { + value = value.copyWith( + selectedKey: null, + allKeys: const AsyncState>.loading(), + ); + + try { + final KeysResult allKeys = await _eval.fetchAllKeys(); + _legacyKeys = allKeys.legacyKeys; + // Platforms other than Android also add the legacy keys to the async keys + // in the pattern `flutter.$key`, so we need to remove them to avoid duplicates. + const String legacyPrefix = 'flutter.'; + _asyncKeys = [ + for (final String key in allKeys.asyncKeys) + if (!(key.startsWith(legacyPrefix) && + _legacyKeys.contains(key.replaceAll(legacyPrefix, '')))) + key, + ]; + + value = value.copyWith( + allKeys: AsyncState>.data(_keysForSelectedApi), + ); + } catch (error, stackTrace) { + value = value.copyWith( + allKeys: AsyncState>.error(error, stackTrace), + ); + } + } + + /// Set the key as selected and retrieve the value from the shared preferences of the target debug session. + Future selectKey(String key) async { + stopEditing(); + + value = value.copyWith( + selectedKey: SelectedSharedPreferencesKey( + key: key, + value: const AsyncState.loading(), + ), + ); + + try { + final SharedPreferencesData keyValue = + await _eval.fetchValue(key, _legacyApi); + value = value.copyWith( + selectedKey: SelectedSharedPreferencesKey( + key: key, + value: AsyncState.data(keyValue), + ), + ); + } catch (error, stackTrace) { + value = value.copyWith( + selectedKey: SelectedSharedPreferencesKey( + key: key, + value: AsyncState.error( + error, + stackTrace, + ), + ), + ); + } + } + + /// Filters the keys based on the provided token. + /// + /// This function uses [caseInsensitiveFuzzyMatch] to filter the keys. + void filter(String token) { + value = value.copyWith( + allKeys: AsyncState>.data( + _keysForSelectedApi.where((String key) { + return key.caseInsensitiveFuzzyMatch(token); + }).toList(), + ), + ); + } + + /// Changes the value of the selected key in the shared preferences of the target debug session. + Future changeValue( + SharedPreferencesData newValue, + ) async { + if (value.selectedKey case final SelectedSharedPreferencesKey selectedKey) { + value = value.copyWith( + selectedKey: SelectedSharedPreferencesKey( + key: selectedKey.key, + value: const AsyncState.loading(), + ), + ); + await _eval.changeValue(selectedKey.key, newValue, _legacyApi); + await selectKey(selectedKey.key); + stopEditing(); + } + } + + /// Deletes the selected key from the shared preferences of the target debug session. + Future deleteSelectedKey() async { + if (value.selectedKey case final SelectedSharedPreferencesKey selectedKey) { + value = value.copyWith( + allKeys: const AsyncState>.loading(), + selectedKey: SelectedSharedPreferencesKey( + key: selectedKey.key, + value: const AsyncState.loading(), + ), + ); + await _eval.deleteKey(selectedKey.key, _legacyApi); + await fetchAllKeys(); + stopEditing(); + } + } + + /// Change the editing state to true, allowing the user to edit the value of the selected key. + void startEditing() { + value = value.copyWith(editing: true); + } + + /// Change the editing state to false, preventing the user from editing the value of the selected key. + void stopEditing() { + value = value.copyWith(editing: false); + } + + /// Change the API used to fetch the shared preferences of the target debug session. + void selectApi({required bool legacyApi}) { + value = value.copyWith( + legacyApi: legacyApi, + allKeys: AsyncState>.data( + legacyApi ? _legacyKeys : _asyncKeys, + ), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_provider.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_provider.dart new file mode 100644 index 000000000000..e24840608b87 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_state_provider.dart @@ -0,0 +1,293 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/widgets.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'async_state.dart'; +import 'shared_preferences_state.dart'; +import 'shared_preferences_state_notifier.dart'; +import 'shared_preferences_tool_eval.dart'; + +/// A class that provides a [SharedPreferencesStateNotifier] to its descendants +/// without listening to state changes. +/// +/// Check [SharedPreferencesStateProviderExtension] for more info. +class _StateInheritedWidget extends InheritedWidget { + /// Default constructor for [_StateInheritedWidget]. + const _StateInheritedWidget({ + required super.child, + required this.notifier, + }); + + final SharedPreferencesStateNotifier notifier; + + @override + bool updateShouldNotify(covariant _StateInheritedWidget oldWidget) { + return oldWidget.notifier != notifier; + } +} + +enum _StateInheritedModelAspect { + keysList, + selectedKey, + selectedKeyData, + editing, + legacyApi, +} + +/// An inherited model that provides a [SharedPreferencesState] to its descendants. +/// +/// Notifies the descendants depending on the aspect of the state that changed. +/// This is meant to prevent unnecessary rebuilds. +/// For more info check [InheritedModel] and [MediaQuery]. +class _SharedPreferencesStateInheritedModel + extends InheritedModel<_StateInheritedModelAspect> { + const _SharedPreferencesStateInheritedModel({ + required super.child, + required this.state, + }); + + final SharedPreferencesState state; + + @override + bool updateShouldNotify( + covariant _SharedPreferencesStateInheritedModel oldWidget, + ) { + return oldWidget.state != state; + } + + @override + bool updateShouldNotifyDependent( + covariant _SharedPreferencesStateInheritedModel oldWidget, + Set<_StateInheritedModelAspect> dependencies, + ) { + return dependencies.any( + (_StateInheritedModelAspect aspect) => switch (aspect) { + _StateInheritedModelAspect.keysList => + state.allKeys != oldWidget.state.allKeys, + _StateInheritedModelAspect.selectedKey => + state.selectedKey != oldWidget.state.selectedKey, + _StateInheritedModelAspect.selectedKeyData => + state.selectedKey?.value != oldWidget.state.selectedKey?.value, + _StateInheritedModelAspect.editing => + state.editing != oldWidget.state.editing, + _StateInheritedModelAspect.legacyApi => + state.legacyApi != oldWidget.state.legacyApi, + }, + ); + } +} + +@visibleForTesting + +/// A class that provides a [SharedPreferencesStateNotifier] to its descendants. +/// +/// Only used for testing. You can override the notifier with a mock when testing. +class InnerSharedPreferencesStateProvider extends StatelessWidget { + /// Default constructor for [InnerSharedPreferencesStateProvider]. + const InnerSharedPreferencesStateProvider({ + super.key, + required this.notifier, + required this.child, + }); + + /// The [SharedPreferencesStateNotifier] to provide. + final SharedPreferencesStateNotifier notifier; + + /// The required child widget. + final Widget child; + + @override + Widget build(BuildContext context) { + return _StateInheritedWidget( + notifier: notifier, + child: ValueListenableBuilder( + valueListenable: notifier, + builder: ( + BuildContext context, + SharedPreferencesState value, + _, + ) { + return _SharedPreferencesStateInheritedModel( + state: value, + child: child, + ); + }, + ), + ); + } +} + +/// A provider that creates a [SharedPreferencesStateNotifier] and provides it to its descendants. +class SharedPreferencesStateProvider extends StatefulWidget { + /// Default constructor for [SharedPreferencesStateProvider]. + const SharedPreferencesStateProvider({ + super.key, + required this.child, + }); + + /// Returns the async state of the list of all keys. + /// [_SharedPreferencesStateInheritedModel] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild whenever the + /// list of keys changes, including loading and error states. + /// This will not cause a rebuild when any other part of the state changes. + static AsyncState> keysListStateOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType< + _SharedPreferencesStateInheritedModel>( + aspect: _StateInheritedModelAspect.keysList, + )! + .state + .allKeys; + } + + /// Returns the selected key from the closest + /// [_SharedPreferencesStateInheritedModel] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild whenever the + /// selected key changes, including loading and error states. + /// This will not cause a rebuild when any other part of the state changes. + static SelectedSharedPreferencesKey? selectedKeyOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType< + _SharedPreferencesStateInheritedModel>( + aspect: _StateInheritedModelAspect.selectedKey, + )! + .state + .selectedKey; + } + + /// Returns the selected key from the closest + /// [_SharedPreferencesStateInheritedModel] ancestor. + /// + /// Throws an error if the selected key is null. + static SelectedSharedPreferencesKey requireSelectedKeyOf( + BuildContext context) { + return selectedKeyOf(context)!; + } + + /// Returns the async state of the selected key data from the closest + /// [_SharedPreferencesStateInheritedModel] ancestor. + /// Use of this method will cause the given [context] to rebuild whenever the + /// selected key data changes, including loading and error states. + /// This will not cause a rebuild when any other part of the state changes. + static AsyncState? selectedKeyDataOf( + BuildContext context, + ) { + return context + .dependOnInheritedWidgetOfExactType< + _SharedPreferencesStateInheritedModel>( + aspect: _StateInheritedModelAspect.selectedKeyData, + )! + .state + .selectedKey + ?.value; + } + + /// Returns whether the selected key is being edited from the closest + /// _SharedPreferencesStateInheritedModel ancestor. + /// Use of this method will cause the given [context] to rebuild whenever the + /// editing state changes, including loading and error states. + /// This will not cause a rebuild when any other part of the state changes. + static bool editingOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType< + _SharedPreferencesStateInheritedModel>( + aspect: _StateInheritedModelAspect.editing, + )! + .state + .editing; + } + + /// Returns whether the legacy api is selected or not from the closest + /// _SharedPreferencesStateInheritedModel ancestor. + /// Use of this method will cause the given [context] to rebuild whenever the + /// editing state changes, including loading and error states. + /// This will not cause a rebuild when any other part of the state changes. + static bool legacyApiOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType< + _SharedPreferencesStateInheritedModel>( + aspect: _StateInheritedModelAspect.legacyApi, + )! + .state + .legacyApi; + } + + /// The required child widget. + final Widget child; + + @override + State createState() => + _SharedPreferencesStateProviderState(); +} + +class _SharedPreferencesStateProviderState + extends State { + late final SharedPreferencesStateNotifier _notifier; + + @override + void initState() { + super.initState(); + final VmService service = serviceManager.service!; + final EvalOnDartLibrary extensionEval = EvalOnDartLibrary( + 'package:shared_preferences/src/shared_preferences_devtools_extension_data.dart', + service, + serviceManager: serviceManager, + ); + final SharedPreferencesToolEval toolEval = SharedPreferencesToolEval( + service, + extensionEval, + ); + _notifier = SharedPreferencesStateNotifier(toolEval); + _notifier.fetchAllKeys(); + } + + @override + void dispose() { + _notifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InnerSharedPreferencesStateProvider( + notifier: _notifier, + child: widget.child, + ); + } +} + +/// An extension that provides a [SharedPreferencesStateNotifier] to its +/// descendants. +extension SharedPreferencesStateProviderExtension on BuildContext { + /// Returns the [SharedPreferencesStateNotifier] from the closest + /// [StateInheritedNotifier] ancestor. + /// + /// This will not introduce a dependency. So changes to the notifier's value + /// will not trigger a rebuild. + /// + /// This is useful for calling methods on the notifier whenever there is a + /// user interaction, this way we can depend on specific parts of the state, + /// without the need to rebuild the whole widget tree whenever there is a + /// change. + /// + /// Example: + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return DevToolsButton( + /// onPressed: () => context.sharedPreferencesStateNotifier.stopEditing(), + /// label: 'Cancel', + /// ); + /// } + /// ```` + SharedPreferencesStateNotifier get sharedPreferencesStateNotifier { + return getInheritedWidgetOfExactType<_StateInheritedWidget>()!.notifier; + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_tool_eval.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_tool_eval.dart new file mode 100644 index 000000000000..8ade5e27079d --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/shared_preferences_tool_eval.dart @@ -0,0 +1,169 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:devtools_app_shared/service.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'shared_preferences_state.dart'; + +/// A representation of the keys in the shared preferences of the target debug +/// session. +typedef KeysResult = ({ + List asyncKeys, + List legacyKeys, +}); + +/// A class that provides methods to interact with the shared preferences +/// of the target debug session. +/// +/// It abstracts the calls to [EvalOnDartLibrary]. +class SharedPreferencesToolEval { + /// Default constructor for [SharedPreferencesToolEval]. + /// Do not call this constructor directly. + /// Use [SharedPreferencesStateNotifierProvider] instead. + SharedPreferencesToolEval( + this._service, + this._eval, + ); + + final VmService _service; + final EvalOnDartLibrary _eval; + + Disposable? _allKeysDisposable; + Disposable? _valueDisposable; + Disposable? _changeValueDisposable; + Disposable? _removeValueDisposable; + + /// Fetches all keys in the shared preferences of the target debug session. + /// Returns a string list of all keys. + Future fetchAllKeys() async { + _allKeysDisposable?.dispose(); + _allKeysDisposable = Disposable(); + final Map data = await _evalMethod( + method: 'requestAllKeys()', + eventKind: 'all_keys', + isAlive: _allKeysDisposable, + ); + + List castList(String key) { + return (data[key]! as List).cast(); + } + + return ( + asyncKeys: castList('asyncKeys'), + legacyKeys: castList('legacyKeys'), + ); + } + + Future> _evalMethod({ + required String method, + required String eventKind, + Disposable? isAlive, + }) async { + final Completer> completer = + Completer>(); + + late final StreamSubscription streamSubscription; + streamSubscription = _service.onExtensionEvent.listen((Event event) { + // The event prefix and event kind are defined in `shared_preferences_devtools_extension_data.dart` + // from the `shared_preferences` package. + if (event.extensionKind == 'shared_preferences.$eventKind') { + streamSubscription.cancel(); + completer.complete(event.extensionData!.data); + } + }); + + await _eval.eval( + 'SharedPreferencesDevToolsExtensionData().$method', + isAlive: isAlive, + ); + + return completer.future; + } + + /// Fetches the value of the shared preference with the given [key]. + /// Returns a [SharedPreferencesData] object that represents the value. + /// The type of the value is determined by the type of the shared preference. + Future fetchValue(String key, bool legacy) async { + _valueDisposable?.dispose(); + _valueDisposable = Disposable(); + + final Map data = await _evalMethod( + method: "requestValue('$key', $legacy)", + eventKind: 'value', + isAlive: _valueDisposable, + ); + + final Object value = data['value']!; + final Object? kind = data['kind']; + + // we need to check the kind because sometimes a double + // gets interpreted as an int. If this was not and issue + // we'd only need to do a simple pattern matching on value. + return switch (kind) { + 'int' => SharedPreferencesData.int( + value: value as int, + ), + 'bool' => SharedPreferencesData.bool( + value: value as bool, + ), + 'double' => SharedPreferencesData.double( + value: value as double, + ), + 'String' => SharedPreferencesData.string( + value: value as String, + ), + String() when kind.contains('List') => SharedPreferencesData.stringList( + value: (value as List).cast(), + ), + _ => throw UnsupportedError( + 'Unsupported value type: $kind', + ), + }; + } + + /// Changes the value of the key in the shared preferences of the target debug + /// session. + Future changeValue( + String key, + SharedPreferencesData value, + bool legacy, + ) async { + _changeValueDisposable?.dispose(); + _changeValueDisposable = Disposable(); + + final String serializedValue = jsonEncode(value.value); + final String kind = value.kind; + await _evalMethod( + method: + "requestValueChange('$key', '$serializedValue', '$kind', $legacy)", + eventKind: 'change_value', + isAlive: _changeValueDisposable, + ); + } + + /// Deletes the key from the shared preferences of the target debug session. + Future deleteKey(String key, bool legacy) async { + _removeValueDisposable?.dispose(); + _removeValueDisposable = Disposable(); + + await _evalMethod( + method: "requestRemoveKey('$key', $legacy)", + eventKind: 'remove', + isAlive: _removeValueDisposable, + ); + } + + /// Disposes all the disposables used in this class. + void dispose() { + _allKeysDisposable?.dispose(); + _valueDisposable?.dispose(); + _changeValueDisposable?.dispose(); + _removeValueDisposable?.dispose(); + _eval.dispose(); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/ui/api_switch.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/api_switch.dart new file mode 100644 index 000000000000..6f973fea5a5f --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/api_switch.dart @@ -0,0 +1,49 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../shared_preferences_state_provider.dart'; + +/// A switch to toggle between the legacy and async APIs. +class ApiSwitch extends StatelessWidget { + /// Default constructor for [ApiSwitch]. + const ApiSwitch({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final bool legacyApi = SharedPreferencesStateProvider.legacyApiOf(context); + + return Container( + padding: const EdgeInsets.symmetric(vertical: denseSpacing), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).focusColor), + ), + ), + child: Center( + child: DevToolsToggleButtonGroup( + selectedStates: [legacyApi, !legacyApi], + onPressed: (int index) { + context.sharedPreferencesStateNotifier + .selectApi(legacyApi: index == 0); + }, + children: const [ + Padding( + padding: EdgeInsets.all(densePadding), + child: Text('Legacy API'), + ), + Padding( + padding: EdgeInsets.all(densePadding), + child: Text('Async API'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/ui/data_panel.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/data_panel.dart new file mode 100644 index 000000000000..73a215694b4b --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/data_panel.dart @@ -0,0 +1,404 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../async_state.dart'; +import '../shared_preferences_state.dart'; +import '../shared_preferences_state_notifier.dart'; +import '../shared_preferences_state_provider.dart'; +import 'error_panel.dart'; + +/// A panel that displays the data of the selected key. +class DataPanel extends StatefulWidget { + /// Default constructor for [DataPanel]. + const DataPanel({super.key}); + + @override + State createState() => _DataPanelState(); +} + +class _DataPanelState extends State { + String? currentValue; + + void _setCurrentValue(String value) { + setState(() { + currentValue = value; + }); + } + + @override + Widget build(BuildContext context) { + final AsyncState? selectedKeyData = + SharedPreferencesStateProvider.selectedKeyDataOf(context); + + return RoundedOutlinedBorder( + clip: true, + child: switch (selectedKeyData) { + null => const Center( + child: Text('Select a key to view its data.'), + ), + AsyncStateLoading() => const Center( + child: CircularProgressIndicator(), + ), + final AsyncStateError value => ErrorPanel( + error: value.error, + stackTrace: value.stackTrace, + ), + AsyncStateData( + data: final SharedPreferencesData data, + ) => + Column( + children: [ + _Header( + currentValue: currentValue, + data: data, + ), + Expanded( + child: _Content( + data: data, + setCurrentValue: _setCurrentValue, + ), + ), + ], + ), + }, + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.currentValue, + required this.data, + }); + + final String? currentValue; + final SharedPreferencesData data; + + @override + Widget build(BuildContext context) { + final bool editing = SharedPreferencesStateProvider.editingOf(context); + // it is safe to assume that the selected key is not null + // because the header is only shown when a key is selected + final String selectedKey = + SharedPreferencesStateProvider.requireSelectedKeyOf(context).key; + + return AreaPaneHeader( + roundedTopBorder: false, + includeTopBorder: false, + tall: true, + title: Text( + selectedKey, + style: Theme.of(context).textTheme.titleSmall, + ), + actions: [ + if (editing) ...[ + DevToolsButton( + onPressed: () { + context.sharedPreferencesStateNotifier.stopEditing(); + }, + label: 'Cancel', + ), + if (currentValue case final String currentValue? + when currentValue != data.valueAsString && + (data is SharedPreferencesDataString || + currentValue.isNotEmpty)) ...[ + const SizedBox(width: denseRowSpacing), + DevToolsButton( + onPressed: () async { + try { + await context.sharedPreferencesStateNotifier.changeValue( + data.changeValue(currentValue), + ); + } catch (error) { + if (context.mounted) { + context.showSnackBar('Error: $error'); + } + } + }, + label: 'Apply changes', + ), + ], + ] else ...[ + DevToolsButton( + onPressed: () { + // we need to get the notifier here because it is not present in + // the context when the dialog is built + final SharedPreferencesStateNotifier notifier = + context.sharedPreferencesStateNotifier; + showDialog( + context: context, + builder: (BuildContext context) => _ConfirmRemoveDialog( + selectedKey: selectedKey, + notifier: notifier, + ), + ); + }, + label: 'Remove', + ), + const SizedBox(width: denseRowSpacing), + DevToolsButton( + onPressed: () => + context.sharedPreferencesStateNotifier.startEditing(), + label: 'Edit', + ), + ], + ], + ); + } +} + +class _ConfirmRemoveDialog extends StatelessWidget { + const _ConfirmRemoveDialog({ + required this.selectedKey, + required this.notifier, + }); + + final String selectedKey; + final SharedPreferencesStateNotifier notifier; + + @override + Widget build(BuildContext context) { + return DevToolsDialog( + title: const Text('Remove Key'), + content: Text( + 'Are you sure you want to remove $selectedKey?', + ), + actions: [ + const DialogCancelButton(), + DialogTextButton( + child: const Text('REMOVE'), + onPressed: () async { + Navigator.of(context).pop(); + try { + await notifier.deleteSelectedKey(); + } catch (error) { + if (context.mounted) { + context.showSnackBar('Error: $error'); + } + } + }, + ), + ], + ); + } +} + +class _Content extends StatelessWidget { + const _Content({ + required this.data, + required this.setCurrentValue, + }); + + final SharedPreferencesData data; + final ValueChanged setCurrentValue; + + @override + Widget build(BuildContext context) { + final bool editing = SharedPreferencesStateProvider.editingOf(context); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(largeSpacing), + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Type: ${data.kind}'), + const SizedBox(height: denseSpacing), + if (editing) ...[ + const Text('Value:'), + const SizedBox(height: denseSpacing), + switch (data) { + final SharedPreferencesDataBool state => _EditBoolean( + initialValue: state.value, + setCurrentValue: setCurrentValue, + ), + final SharedPreferencesDataStringList state => + _EditStringList( + initialData: state.value, + onChanged: setCurrentValue, + ), + _ => TextFormField( + autofocus: true, + initialValue: data.valueAsString, + inputFormatters: switch (data) { + SharedPreferencesDataInt() => [ + FilteringTextInputFormatter.allow( + RegExp(r'^-?\d*'), + ), + ], + SharedPreferencesDataDouble() => [ + FilteringTextInputFormatter.allow( + RegExp(r'^-?\d*\.?\d*'), + ), + ], + _ => [], + }, + onChanged: setCurrentValue, + ) + }, + ] else ...[ + Text('Value: ${data.valueAsString}'), + ], + ], + ), + ), + ), + ); + } +} + +class _EditBoolean extends StatelessWidget { + const _EditBoolean({ + required this.setCurrentValue, + required this.initialValue, + }); + + final ValueChanged setCurrentValue; + final bool initialValue; + + @override + Widget build(BuildContext context) { + return DropdownMenu( + initialSelection: initialValue, + onSelected: (bool? value) { + setCurrentValue(value.toString()); + }, + dropdownMenuEntries: const >[ + DropdownMenuEntry( + label: 'true', + value: true, + ), + DropdownMenuEntry( + label: 'false', + value: false, + ), + ], + ); + } +} + +class _EditStringList extends StatefulWidget { + const _EditStringList({ + required this.initialData, + required this.onChanged, + }); + + final List initialData; + final ValueChanged onChanged; + + @override + State<_EditStringList> createState() => _EditStringListState(); +} + +class _EditStringListState extends State<_EditStringList> { + late final List<(int key, String value)> _currentList; + int _keyCounter = 0; + + void _addElementAt(int index) { + setState(() { + _currentList.insert(index, (_keyCounter++, '')); + }); + _updateValue(); + } + + void _updateValue() { + widget.onChanged(jsonEncode( + [ + for (final (_, String value) in _currentList) value, + ], + )); + } + + @override + void initState() { + super.initState(); + _currentList = <(int, String)>[ + for (final String str in widget.initialData) (_keyCounter++, str), + ]; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (int index, (int keyValue, String str)) + in _currentList.indexed) ...[ + if (index > 0) const SizedBox(height: largeSpacing), + _AddListElement( + onPressed: () => _addElementAt(index), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: densePadding), + child: Row( + children: [ + Expanded( + child: TextFormField( + key: Key('list_element_$keyValue'), + initialValue: str, + onChanged: (String value) { + setState(() { + _currentList[index] = (keyValue, value); + }); + _updateValue(); + }, + ), + ), + DevToolsButton( + icon: Icons.remove, + onPressed: () { + setState(() { + _currentList.removeAt(index); + }); + _updateValue(); + }, + ) + ], + ), + ), + ], + const SizedBox(height: largeSpacing), + _AddListElement( + onPressed: () => _addElementAt(_currentList.length), + ), + ], + ); + } +} + +class _AddListElement extends StatelessWidget { + const _AddListElement({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Center( + child: DevToolsButton( + icon: Icons.add, + onPressed: onPressed, + ), + ); + } +} + +extension on BuildContext { + void showSnackBar(String message) { + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/ui/error_panel.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/error_panel.dart new file mode 100644 index 000000000000..1e490a65b558 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/error_panel.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +/// A panel that displays an error message and a stack trace. +class ErrorPanel extends StatelessWidget { + /// Default constructor for [ErrorPanel]. + const ErrorPanel({ + super.key, + required this.error, + required this.stackTrace, + }); + + /// The error message to display. + /// This will be displayed as a string. + final Object error; + + /// The stack trace to display. + final StackTrace? stackTrace; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(densePadding), + child: Text( + 'Error:\n$error\n\n$stackTrace', + style: Theme.of(context).errorTextStyle, + ), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/ui/keys_panel.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/keys_panel.dart new file mode 100644 index 000000000000..e5cf533b8347 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/keys_panel.dart @@ -0,0 +1,269 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../async_state.dart'; +import '../shared_preferences_state.dart'; +import '../shared_preferences_state_provider.dart'; +import 'api_switch.dart'; +import 'error_panel.dart'; + +/// A panel that displays the keys stored in shared preferences. +class KeysPanel extends StatefulWidget { + /// Default constructor for [KeysPanel]. + const KeysPanel({super.key}); + + @override + State createState() => _KeysPanelState(); +} + +class _KeysPanelState extends State { + bool searching = false; + final FocusNode searchFocusNode = FocusNode(); + + @override + void dispose() { + searchFocusNode.dispose(); + super.dispose(); + } + + void _startSearching() { + setState(() { + searching = true; + }); + } + + @override + Widget build(BuildContext context) { + void stopSearching() { + setState(() { + searching = false; + }); + context.sharedPreferencesStateNotifier.filter(''); + } + + return RoundedOutlinedBorder( + clip: true, + child: Column( + children: [ + AreaPaneHeader( + roundedTopBorder: false, + includeTopBorder: false, + tall: true, + title: Row( + children: [ + Text( + 'Stored Keys', + style: Theme.of(context).textTheme.titleSmall, + ), + if (searching) ...[ + const SizedBox( + width: denseSpacing, + ), + Expanded( + child: _SearchField( + searchFocusNode: searchFocusNode, + stopSearching: stopSearching, + ), + ), + ] else ...[ + const Spacer(), + _ToolbarAction( + tooltipMessage: 'Search', + icon: Icons.search, + onPressed: _startSearching, + ), + ], + const SizedBox( + width: denseRowSpacing, + ), + _ToolbarAction( + tooltipMessage: 'Refresh', + icon: Icons.refresh, + onPressed: () { + stopSearching(); + context.sharedPreferencesStateNotifier.fetchAllKeys(); + }, + ), + ], + ), + ), + const ApiSwitch(), + const Expanded( + child: _StateMapper(), + ), + ], + ), + ); + } +} + +// TODO(adsonpleal): replace this with `ToolbarAction` once it's available in `devtools_app_shared`, https://github.com/flutter/devtools/issues/7793. +class _ToolbarAction extends StatelessWidget { + const _ToolbarAction({ + required this.tooltipMessage, + required this.icon, + required this.onPressed, + }); + + final String tooltipMessage; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return DevToolsTooltip( + message: tooltipMessage, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onPressed, + child: Icon( + icon, + size: actionsIconSize, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.searchFocusNode, + required this.stopSearching, + }); + + final FocusNode searchFocusNode; + final VoidCallback stopSearching; + + @override + Widget build(BuildContext context) { + return KeyboardListener( + focusNode: searchFocusNode, + onKeyEvent: (KeyEvent value) { + if (value.logicalKey == LogicalKeyboardKey.escape) { + stopSearching(); + } + }, + child: TextField( + autofocus: true, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: densePadding, + ), + hintText: 'Search', + border: const OutlineInputBorder(), + suffix: _ToolbarAction( + tooltipMessage: 'Stop searching', + icon: Icons.close, + onPressed: stopSearching, + ), + ), + onChanged: (String newValue) { + context.sharedPreferencesStateNotifier.filter(newValue); + }, + ), + ); + } +} + +class _StateMapper extends StatelessWidget { + const _StateMapper(); + + @override + Widget build(BuildContext context) { + return switch (SharedPreferencesStateProvider.keysListStateOf(context)) { + final AsyncStateData> value => _KeysList( + keys: value.data, + ), + final AsyncStateError> value => ErrorPanel( + error: value.error, + stackTrace: value.stackTrace, + ), + AsyncStateLoading>() => const Center( + child: CircularProgressIndicator(), + ), + }; + } +} + +class _KeysList extends StatefulWidget { + const _KeysList({ + required this.keys, + }); + + final List keys; + + @override + State<_KeysList> createState() => _KeysListState(); +} + +class _KeysListState extends State<_KeysList> { + final ScrollController scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: scrollController, + child: ListView( + controller: scrollController, + children: [ + for (final String keyName in widget.keys) + _KeyItem( + keyName: keyName, + ), + ], + ), + ); + } +} + +class _KeyItem extends StatelessWidget { + const _KeyItem({ + required this.keyName, + }); + + final String keyName; + + @override + Widget build(BuildContext context) { + final SelectedSharedPreferencesKey? selectedKey = + SharedPreferencesStateProvider.selectedKeyOf(context); + final bool isSelected = selectedKey?.key == keyName; + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color? backgroundColor = + isSelected ? colorScheme.selectedRowBackgroundColor : null; + + return InkWell( + onTap: () { + context.sharedPreferencesStateNotifier.selectKey(keyName); + }, + child: Container( + color: backgroundColor, + padding: const EdgeInsets.only( + left: defaultSpacing, + right: densePadding, + top: densePadding, + bottom: densePadding, + ), + child: Text( + keyName, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/lib/src/ui/shared_preferences_body.dart b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/shared_preferences_body.dart new file mode 100644 index 000000000000..8ccc65c202d5 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/lib/src/ui/shared_preferences_body.dart @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import 'data_panel.dart'; +import 'keys_panel.dart'; + +/// The main body of the shared preferences tool. +/// It contains the [KeysPanel] and the [DataPanel]. +class SharedPreferencesBody extends StatelessWidget { + /// Default constructor for [SharedPreferencesBody]. + const SharedPreferencesBody({super.key}); + + @override + Widget build(BuildContext context) { + final Axis splitAxis = SplitPane.axisFor(context, 0.85); + + return SplitPane( + axis: splitAxis, + initialFractions: const [0.33, 0.67], + children: const [ + KeysPanel(), + DataPanel(), + ], + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/pubspec.yaml b/packages/shared_preferences/shared_preferences_tool/pubspec.yaml new file mode 100644 index 000000000000..2af169ae78f7 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/pubspec.yaml @@ -0,0 +1,25 @@ +name: shared_preferences_tool +description: "DevTools extension for package:shared_preferences. Manage + SharedPreferences efficiently. Edit, search, and view keys." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.4.0 <4.0.0' + +dependencies: + devtools_app_shared: ^0.2.2 + devtools_extensions: ^0.2.2 + flutter: + sdk: flutter + vm_service: any + +dev_dependencies: + build_runner: ^2.4.10 + flutter_test: + sdk: flutter + mockito: 5.4.4 + +flutter: + uses-material-design: true diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.dart new file mode 100644 index 000000000000..5c6288e36211 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.dart @@ -0,0 +1,246 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_app_shared/service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/async_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state_notifier.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_tool_eval.dart'; + +@GenerateNiceMocks(>[ + MockSpec(), + MockSpec() +]) +import 'shared_preferences_state_notifier_test.mocks.dart'; + +void main() { + group('SharedPreferencesStateNotifier', () { + late MockSharedPreferencesToolEval evalMock; + late SharedPreferencesStateNotifier notifier; + + setUpAll(() { + provideDummy(const SharedPreferencesData.int(value: 42)); + }); + + setUp(() { + evalMock = MockSharedPreferencesToolEval(); + notifier = SharedPreferencesStateNotifier(evalMock); + }); + + test('should start with the default state', () { + expect( + notifier.value, + const SharedPreferencesState(), + ); + }); + + test('should fetch all keys', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + + await notifier.fetchAllKeys(); + + expect(notifier.value.allKeys.dataOrNull, asyncKeys); + }); + + test('should filter out keys with "flutter." prefix async keys', () async { + const List asyncKeys = ['flutter.key1', 'key2']; + const List legacyKeys = ['key1', 'key3']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + + await notifier.fetchAllKeys(); + + expect( + notifier.value.allKeys.dataOrNull, + equals(['key2']), + ); + }); + + test('should select key', () async { + const List keys = ['key1', 'key2']; + const SharedPreferencesData keyValue = + SharedPreferencesData.string(value: 'value'); + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: keys, + legacyKeys: const [], + ), + ); + when(evalMock.fetchValue('key1', false)).thenAnswer( + (_) async => keyValue, + ); + await notifier.fetchAllKeys(); + + await notifier.selectKey('key1'); + + expect( + notifier.value.selectedKey, + equals( + const SelectedSharedPreferencesKey( + key: 'key1', + value: AsyncState.data(keyValue), + ), + ), + ); + }); + + test('should select key for legacy api', () async { + const List keys = ['key1', 'key2']; + const SharedPreferencesData keyValue = + SharedPreferencesData.string(value: 'value'); + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: const [], + legacyKeys: keys, + ), + ); + when(evalMock.fetchValue('key1', true)).thenAnswer( + (_) async => keyValue, + ); + await notifier.fetchAllKeys(); + notifier.selectApi(legacyApi: true); + + await notifier.selectKey('key1'); + + expect( + notifier.value, + equals( + const SharedPreferencesState( + allKeys: AsyncState>.data(keys), + selectedKey: SelectedSharedPreferencesKey( + key: 'key1', + value: AsyncState.data(keyValue), + ), + legacyApi: true, + ), + ), + ); + }); + + test('should filter keys and clear filter', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + await notifier.fetchAllKeys(); + + notifier.filter('key1'); + + expect(notifier.value.allKeys.dataOrNull, equals(['key1'])); + + notifier.filter(''); + + expect(notifier.value.allKeys.dataOrNull, equals(asyncKeys)); + }); + + test('should start/stop editing', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + await notifier.fetchAllKeys(); + notifier.startEditing(); + + expect(notifier.value.editing, equals(true)); + + notifier.stopEditing(); + + expect(notifier.value.editing, equals(false)); + }); + + test('should change value', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + const SharedPreferencesData keyValue = SharedPreferencesData.string( + value: 'value', + ); + when(evalMock.fetchValue('key1', false)).thenAnswer( + (_) async => keyValue, + ); + await notifier.fetchAllKeys(); + await notifier.selectKey('key1'); + + await notifier.deleteSelectedKey(); + + verify(evalMock.deleteKey('key1', false)).called(1); + }); + + test('should change value', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + const SharedPreferencesData keyValue = + SharedPreferencesData.string(value: 'value'); + when(evalMock.fetchValue('key1', false)) + .thenAnswer((_) async => keyValue); + await notifier.fetchAllKeys(); + await notifier.selectKey('key1'); + + await notifier.changeValue( + const SharedPreferencesData.string(value: 'newValue'), + ); + + verify( + evalMock.changeValue( + 'key1', + const SharedPreferencesData.string(value: 'newValue'), + false, + ), + ).called(1); + }); + + test('should change select legacy api and async api', () async { + const List asyncKeys = ['key1', 'key2']; + const List legacyKeys = ['key11', 'key22']; + when(evalMock.fetchAllKeys()).thenAnswer( + (_) async => ( + asyncKeys: asyncKeys, + legacyKeys: legacyKeys, + ), + ); + await notifier.fetchAllKeys(); + + notifier.selectApi(legacyApi: true); + + expect(notifier.value.legacyApi, equals(true)); + + notifier.selectApi(legacyApi: false); + + expect(notifier.value.legacyApi, equals(false)); + }); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.mocks.dart b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.mocks.dart new file mode 100644 index 000000000000..32000ac343a1 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_notifier_test.mocks.dart @@ -0,0 +1,269 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in shared_preferences_tool/test/src/shared_preferences_state_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i2; + +import 'package:devtools_app_shared/service.dart' as _i6; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart' + as _i4; +import 'package:shared_preferences_tool/src/shared_preferences_tool_eval.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeCompleter_0 extends _i1.SmartFake implements _i2.Completer { + _FakeCompleter_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SharedPreferencesToolEval]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSharedPreferencesToolEval extends _i1.Mock + implements _i3.SharedPreferencesToolEval { + @override + _i2.Future<({List asyncKeys, List legacyKeys})> + fetchAllKeys() => (super.noSuchMethod( + Invocation.method( + #fetchAllKeys, + [], + ), + returnValue: _i2.Future< + ({ + List asyncKeys, + List legacyKeys + })>.value((asyncKeys: [], legacyKeys: [])), + returnValueForMissingStub: _i2.Future< + ({ + List asyncKeys, + List legacyKeys + })>.value((asyncKeys: [], legacyKeys: [])), + ) as _i2.Future<({List asyncKeys, List legacyKeys})>); + + @override + _i2.Future<_i4.SharedPreferencesData> fetchValue( + String? key, + bool? legacy, + ) => + (super.noSuchMethod( + Invocation.method( + #fetchValue, + [ + key, + legacy, + ], + ), + returnValue: _i2.Future<_i4.SharedPreferencesData>.value( + _i5.dummyValue<_i4.SharedPreferencesData>( + this, + Invocation.method( + #fetchValue, + [ + key, + legacy, + ], + ), + )), + returnValueForMissingStub: _i2.Future<_i4.SharedPreferencesData>.value( + _i5.dummyValue<_i4.SharedPreferencesData>( + this, + Invocation.method( + #fetchValue, + [ + key, + legacy, + ], + ), + )), + ) as _i2.Future<_i4.SharedPreferencesData>); + + @override + _i2.Future changeValue( + String? key, + _i4.SharedPreferencesData? value, + bool? legacy, + ) => + (super.noSuchMethod( + Invocation.method( + #changeValue, + [ + key, + value, + legacy, + ], + ), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + + @override + _i2.Future deleteKey( + String? key, + bool? legacy, + ) => + (super.noSuchMethod( + Invocation.method( + #deleteKey, + [ + key, + legacy, + ], + ), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [ConnectedApp]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockConnectedApp extends _i1.Mock implements _i6.ConnectedApp { + @override + _i2.Completer get initialized => (super.noSuchMethod( + Invocation.getter(#initialized), + returnValue: _FakeCompleter_0( + this, + Invocation.getter(#initialized), + ), + returnValueForMissingStub: _FakeCompleter_0( + this, + Invocation.getter(#initialized), + ), + ) as _i2.Completer); + + @override + set initialized(_i2.Completer? _initialized) => super.noSuchMethod( + Invocation.setter( + #initialized, + _initialized, + ), + returnValueForMissingStub: null, + ); + + @override + bool get connectedAppInitialized => (super.noSuchMethod( + Invocation.getter(#connectedAppInitialized), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + String get operatingSystem => (super.noSuchMethod( + Invocation.getter(#operatingSystem), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#operatingSystem), + ), + returnValueForMissingStub: _i5.dummyValue( + this, + Invocation.getter(#operatingSystem), + ), + ) as String); + + @override + _i2.Future get isFlutterApp => (super.noSuchMethod( + Invocation.getter(#isFlutterApp), + returnValue: _i2.Future.value(false), + returnValueForMissingStub: _i2.Future.value(false), + ) as _i2.Future); + + @override + _i2.Future get isProfileBuild => (super.noSuchMethod( + Invocation.getter(#isProfileBuild), + returnValue: _i2.Future.value(false), + returnValueForMissingStub: _i2.Future.value(false), + ) as _i2.Future); + + @override + _i2.Future get isDartWebApp => (super.noSuchMethod( + Invocation.getter(#isDartWebApp), + returnValue: _i2.Future.value(false), + returnValueForMissingStub: _i2.Future.value(false), + ) as _i2.Future); + + @override + bool get isFlutterWebAppNow => (super.noSuchMethod( + Invocation.getter(#isFlutterWebAppNow), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isFlutterNativeAppNow => (super.noSuchMethod( + Invocation.getter(#isFlutterNativeAppNow), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isDebugFlutterAppNow => (super.noSuchMethod( + Invocation.getter(#isDebugFlutterAppNow), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i2.Future get isDartCliApp => (super.noSuchMethod( + Invocation.getter(#isDartCliApp), + returnValue: _i2.Future.value(false), + returnValueForMissingStub: _i2.Future.value(false), + ) as _i2.Future); + + @override + bool get isDartCliAppNow => (super.noSuchMethod( + Invocation.getter(#isDartCliAppNow), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i2.Future initializeValues({void Function()? onComplete}) => + (super.noSuchMethod( + Invocation.method( + #initializeValues, + [], + {#onComplete: onComplete}, + ), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + + @override + Map toJson() => (super.noSuchMethod( + Invocation.method( + #toJson, + [], + ), + returnValue: {}, + returnValueForMissingStub: {}, + ) as Map); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_test.dart new file mode 100644 index 000000000000..7b46baeb5836 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_state_test.dart @@ -0,0 +1,130 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_tool/src/async_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; + +void main() { + group('SharedPreferencesState', () { + test('should be possible to set selected key to null', () { + const SharedPreferencesState state = SharedPreferencesState( + selectedKey: SelectedSharedPreferencesKey( + key: 'key', + value: AsyncState.loading(), + ), + ); + + expect( + state.copyWith(selectedKey: null), + equals(const SharedPreferencesState()), + ); + }); + }); + + group('SharedPreferencesData', () { + test('value as string should return formatted value', () { + const SharedPreferencesData stringData = + SharedPreferencesData.string(value: 'value'); + expect(stringData.valueAsString, 'value'); + + const SharedPreferencesData intData = SharedPreferencesData.int(value: 1); + expect(intData.valueAsString, '1'); + + const SharedPreferencesData doubleData = + SharedPreferencesData.double(value: 1.1); + expect(doubleData.valueAsString, '1.1'); + + const SharedPreferencesData boolData = + SharedPreferencesData.bool(value: true); + expect(boolData.valueAsString, 'true'); + + const SharedPreferencesData stringListData = + SharedPreferencesData.stringList(value: ['value1', 'value2']); + expect(stringListData.valueAsString, '\n0 -> value1\n1 -> value2'); + }); + }); + + test('should return pretty type', () { + const SharedPreferencesData stringData = + SharedPreferencesData.string(value: 'value'); + expect(stringData.kind, 'String'); + + const SharedPreferencesData intData = SharedPreferencesData.int(value: 1); + expect(intData.kind, 'int'); + + const SharedPreferencesData doubleData = + SharedPreferencesData.double(value: 1.0); + expect(doubleData.kind, 'double'); + + const SharedPreferencesData boolData = + SharedPreferencesData.bool(value: true); + expect(boolData.kind, 'bool'); + + const SharedPreferencesData stringListData = + SharedPreferencesData.stringList(value: ['value1', 'value2']); + expect(stringListData.kind, 'List'); + }); + + test('should change value', () { + const SharedPreferencesData stringData = + SharedPreferencesData.string(value: 'value'); + const String newStringValue = 'newValue'; + expect( + stringData.changeValue(newStringValue), + isA().having( + (SharedPreferencesDataString data) => data.value, + 'value', + equals(newStringValue), + ), + ); + + const SharedPreferencesData intData = SharedPreferencesData.int(value: 1); + const String newIntValue = '2'; + expect( + intData.changeValue(newIntValue), + isA().having( + (SharedPreferencesDataInt data) => data.value, + 'value', + equals(int.parse(newIntValue)), + ), + ); + + const SharedPreferencesData doubleData = + SharedPreferencesData.double(value: 1.0); + const String newDoubleValue = '2.0'; + expect( + doubleData.changeValue(newDoubleValue), + isA().having( + (SharedPreferencesDataDouble data) => data.value, + 'value', + equals(double.parse(newDoubleValue)), + ), + ); + + const SharedPreferencesData boolData = + SharedPreferencesData.bool(value: true); + const String newBoolValue = 'false'; + expect( + boolData.changeValue(newBoolValue), + isA().having( + (SharedPreferencesDataBool data) => data.value, + 'value', + equals(false), + ), + ); + + const SharedPreferencesData stringListData = + SharedPreferencesData.stringList(value: ['value1', 'value2']); + const String newStringListValue = '["newValue1", "newValue2"]'; + expect( + stringListData.changeValue(newStringListValue), + isA().having( + (SharedPreferencesDataStringList data) => data.value, + 'value', + equals(['newValue1', 'newValue2']), + ), + ); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.dart new file mode 100644 index 000000000000..d10ad4d0f6d8 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.dart @@ -0,0 +1,273 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:devtools_app_shared/service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_tool_eval.dart'; +import 'package:vm_service/vm_service.dart'; + +@GenerateNiceMocks(>[ + MockSpec(), +]) +import 'shared_preferences_tool_eval_test.mocks.dart'; + +void main() { + group('SharedPreferencesToolEval', () { + late MockEvalOnDartLibrary eval; + late _FakeVmService vmService; + late SharedPreferencesToolEval sharedPreferencesToolEval; + + void stubEvalMethod({ + required String eventKind, + required String method, + required Map response, + }) { + final StreamController eventStream = StreamController(); + vmService.onExtensionEvent = eventStream.stream; + when( + eval.eval( + 'SharedPreferencesDevToolsExtensionData().$method', + isAlive: anyNamed('isAlive'), + ), + ).thenAnswer((_) async { + eventStream.add( + Event( + extensionKind: 'shared_preferences.$eventKind', + extensionData: ExtensionData.parse(response), + ), + ); + return null; + }); + } + + setUp(() { + eval = MockEvalOnDartLibrary(); + vmService = _FakeVmService(); + sharedPreferencesToolEval = SharedPreferencesToolEval(vmService, eval); + }); + + test('should fetch all keys', () async { + final List expectedAsyncKeys = ['key1', 'key2']; + const List expectedLegacyKeys = ['key3', 'key4']; + stubEvalMethod( + eventKind: 'all_keys', + method: 'requestAllKeys()', + response: { + 'asyncKeys': expectedAsyncKeys, + 'legacyKeys': expectedLegacyKeys, + }, + ); + + final KeysResult allKeys = await sharedPreferencesToolEval.fetchAllKeys(); + + expect( + allKeys.asyncKeys, + equals(expectedAsyncKeys), + ); + expect( + allKeys.legacyKeys, + equals(expectedLegacyKeys), + ); + }); + + test('should fetch int value', () async { + const String key = 'testKey'; + const int expectedValue = 42; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', false)", + response: { + 'value': expectedValue, + 'kind': 'int', + }, + ); + + final SharedPreferencesData data = + await sharedPreferencesToolEval.fetchValue(key, false); + + expect( + data, + equals( + const SharedPreferencesData.int( + value: expectedValue, + ), + ), + ); + }); + + test('should fetch bool value', () async { + const String key = 'testKey'; + const bool expectedValue = true; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', false)", + response: { + 'value': expectedValue, + 'kind': 'bool', + }, + ); + + final SharedPreferencesData data = + await sharedPreferencesToolEval.fetchValue(key, false); + + expect( + data, + equals( + const SharedPreferencesData.bool( + value: expectedValue, + ), + ), + ); + }); + + test('should fetch double value', () async { + const String key = 'testKey'; + const double expectedValue = 11.1; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', false)", + response: { + 'value': expectedValue, + 'kind': 'double', + }, + ); + + final SharedPreferencesData data = + await sharedPreferencesToolEval.fetchValue(key, false); + + expect( + data, + equals( + const SharedPreferencesData.double( + value: expectedValue, + ), + ), + ); + }); + + test('should fetch string value', () async { + const String key = 'testKey'; + const String expectedValue = 'value'; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', false)", + response: { + 'value': expectedValue, + 'kind': 'String', + }, + ); + + final SharedPreferencesData data = + await sharedPreferencesToolEval.fetchValue(key, false); + + expect( + data, + equals( + const SharedPreferencesData.string( + value: expectedValue, + ), + ), + ); + }); + + test('should fetch string list value', () async { + const String key = 'testKey'; + const List expectedValue = ['value1', 'value2']; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', true)", + response: { + 'value': expectedValue, + 'kind': 'List', + }, + ); + + final SharedPreferencesData data = + await sharedPreferencesToolEval.fetchValue(key, true); + + expect( + data, + equals( + const SharedPreferencesData.stringList( + value: expectedValue, + ), + ), + ); + }); + + test('should throw error on unsupported value', () { + const String key = 'testKey'; + stubEvalMethod( + eventKind: 'value', + method: "requestValue('$key', true)", + response: { + 'value': 'error', + 'kind': 'SomeClass', + }, + ); + + expect( + () => sharedPreferencesToolEval.fetchValue(key, true), + throwsUnsupportedError, + ); + }); + + test('should change value', () async { + const String key = 'testKey'; + const String method = "requestValueChange('$key', 'true', 'bool', false)"; + stubEvalMethod( + eventKind: 'change_value', + method: method, + response: {}, + ); + + await sharedPreferencesToolEval.changeValue( + key, + const SharedPreferencesData.bool(value: true), + false, + ); + + verify( + eval.eval( + 'SharedPreferencesDevToolsExtensionData().$method', + isAlive: anyNamed('isAlive'), + ), + ).called(1); + }); + + test('should delete key', () async { + const String key = 'testKey'; + const String method = "requestRemoveKey('$key', false)"; + stubEvalMethod( + eventKind: 'remove', + method: method, + response: {}, + ); + + await sharedPreferencesToolEval.deleteKey( + key, + false, + ); + + verify( + eval.eval( + 'SharedPreferencesDevToolsExtensionData().$method', + isAlive: anyNamed('isAlive'), + ), + ).called(1); + }); + }); +} + +class _FakeVmService extends VmService { + _FakeVmService() : super(const Stream.empty(), (String _) {}); + + @override + late Stream onExtensionEvent; +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.mocks.dart b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.mocks.dart new file mode 100644 index 000000000000..65a0ce57380f --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/shared_preferences_tool_eval_test.mocks.dart @@ -0,0 +1,602 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in shared_preferences_tool/test/src/shared_preferences_tool_eval_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; +import 'dart:ui' as _i8; + +import 'package:devtools_app_shared/src/service/eval_on_dart_library.dart' + as _i4; +import 'package:devtools_app_shared/src/service/service_manager.dart' as _i3; +import 'package:flutter/foundation.dart' as _i7; +import 'package:flutter/widgets.dart' as _i9; +import 'package:mockito/mockito.dart' as _i2; +import 'package:mockito/src/dummies.dart' as _i5; +import 'package:vm_service/vm_service.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeServiceManager_0 extends _i2.SmartFake + implements _i3.ServiceManager { + _FakeServiceManager_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeVmService_1 extends _i2.SmartFake implements _i1.VmService { + _FakeVmService_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeClass_2 extends _i2.SmartFake implements _i1.Class { + _FakeClass_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeInstance_3 extends _i2.SmartFake implements _i1.Instance { + _FakeInstance_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeInstanceRef_4 extends _i2.SmartFake implements _i1.InstanceRef { + _FakeInstanceRef_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [EvalOnDartLibrary]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockEvalOnDartLibrary extends _i2.Mock implements _i4.EvalOnDartLibrary { + @override + bool get oneRequestAtATime => (super.noSuchMethod( + Invocation.getter(#oneRequestAtATime), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get disableBreakpoints => (super.noSuchMethod( + Invocation.getter(#disableBreakpoints), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get logExceptions => (super.noSuchMethod( + Invocation.getter(#logExceptions), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i3.ServiceManager<_i1.VmService> get serviceManager => (super.noSuchMethod( + Invocation.getter(#serviceManager), + returnValue: _FakeServiceManager_0<_i1.VmService>( + this, + Invocation.getter(#serviceManager), + ), + returnValueForMissingStub: _FakeServiceManager_0<_i1.VmService>( + this, + Invocation.getter(#serviceManager), + ), + ) as _i3.ServiceManager<_i1.VmService>); + + @override + String get libraryName => (super.noSuchMethod( + Invocation.getter(#libraryName), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#libraryName), + ), + returnValueForMissingStub: _i5.dummyValue( + this, + Invocation.getter(#libraryName), + ), + ) as String); + + @override + _i1.VmService get service => (super.noSuchMethod( + Invocation.getter(#service), + returnValue: _FakeVmService_1( + this, + Invocation.getter(#service), + ), + returnValueForMissingStub: _FakeVmService_1( + this, + Invocation.getter(#service), + ), + ) as _i1.VmService); + + @override + set allPendingRequestsDone(_i6.Completer? _allPendingRequestsDone) => + super.noSuchMethod( + Invocation.setter( + #allPendingRequestsDone, + _allPendingRequestsDone, + ), + returnValueForMissingStub: null, + ); + + @override + bool get disposed => (super.noSuchMethod( + Invocation.getter(#disposed), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + List<_i7.Listenable> get listenables => (super.noSuchMethod( + Invocation.getter(#listenables), + returnValue: <_i7.Listenable>[], + returnValueForMissingStub: <_i7.Listenable>[], + ) as List<_i7.Listenable>); + + @override + List get listeners => (super.noSuchMethod( + Invocation.getter(#listeners), + returnValue: [], + returnValueForMissingStub: [], + ) as List); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i6.Future<_i1.InstanceRef?> eval( + String? expression, { + required _i4.Disposable? isAlive, + Map? scope, + bool? shouldLogError = true, + }) => + (super.noSuchMethod( + Invocation.method( + #eval, + [expression], + { + #isAlive: isAlive, + #scope: scope, + #shouldLogError: shouldLogError, + }, + ), + returnValue: _i6.Future<_i1.InstanceRef?>.value(), + returnValueForMissingStub: _i6.Future<_i1.InstanceRef?>.value(), + ) as _i6.Future<_i1.InstanceRef?>); + + @override + _i6.Future<_i1.InstanceRef?> invoke( + _i1.InstanceRef? instanceRef, + String? name, + List? argRefs, { + required _i4.Disposable? isAlive, + bool? shouldLogError = true, + }) => + (super.noSuchMethod( + Invocation.method( + #invoke, + [ + instanceRef, + name, + argRefs, + ], + { + #isAlive: isAlive, + #shouldLogError: shouldLogError, + }, + ), + returnValue: _i6.Future<_i1.InstanceRef?>.value(), + returnValueForMissingStub: _i6.Future<_i1.InstanceRef?>.value(), + ) as _i6.Future<_i1.InstanceRef?>); + + @override + _i6.Future<_i1.Class?> getClass( + _i1.ClassRef? instance, + _i4.Disposable? isAlive, + ) => + (super.noSuchMethod( + Invocation.method( + #getClass, + [ + instance, + isAlive, + ], + ), + returnValue: _i6.Future<_i1.Class?>.value(), + returnValueForMissingStub: _i6.Future<_i1.Class?>.value(), + ) as _i6.Future<_i1.Class?>); + + @override + _i6.Future<_i1.Class> safeGetClass( + _i1.ClassRef? instance, + _i4.Disposable? isAlive, + ) => + (super.noSuchMethod( + Invocation.method( + #safeGetClass, + [ + instance, + isAlive, + ], + ), + returnValue: _i6.Future<_i1.Class>.value(_FakeClass_2( + this, + Invocation.method( + #safeGetClass, + [ + instance, + isAlive, + ], + ), + )), + returnValueForMissingStub: _i6.Future<_i1.Class>.value(_FakeClass_2( + this, + Invocation.method( + #safeGetClass, + [ + instance, + isAlive, + ], + ), + )), + ) as _i6.Future<_i1.Class>); + + @override + _i6.Future<_i1.Func?> getFunc( + _i1.FuncRef? instance, + _i4.Disposable? isAlive, + ) => + (super.noSuchMethod( + Invocation.method( + #getFunc, + [ + instance, + isAlive, + ], + ), + returnValue: _i6.Future<_i1.Func?>.value(), + returnValueForMissingStub: _i6.Future<_i1.Func?>.value(), + ) as _i6.Future<_i1.Func?>); + + @override + _i6.Future<_i1.Instance?> getInstance( + _i6.FutureOr<_i1.InstanceRef>? instanceRefFuture, + _i4.Disposable? isAlive, + ) => + (super.noSuchMethod( + Invocation.method( + #getInstance, + [ + instanceRefFuture, + isAlive, + ], + ), + returnValue: _i6.Future<_i1.Instance?>.value(), + returnValueForMissingStub: _i6.Future<_i1.Instance?>.value(), + ) as _i6.Future<_i1.Instance?>); + + @override + _i6.Future<_i1.Instance> safeGetInstance( + _i6.FutureOr<_i1.InstanceRef>? instanceRefFuture, + _i4.Disposable? isAlive, + ) => + (super.noSuchMethod( + Invocation.method( + #safeGetInstance, + [ + instanceRefFuture, + isAlive, + ], + ), + returnValue: _i6.Future<_i1.Instance>.value(_FakeInstance_3( + this, + Invocation.method( + #safeGetInstance, + [ + instanceRefFuture, + isAlive, + ], + ), + )), + returnValueForMissingStub: + _i6.Future<_i1.Instance>.value(_FakeInstance_3( + this, + Invocation.method( + #safeGetInstance, + [ + instanceRefFuture, + isAlive, + ], + ), + )), + ) as _i6.Future<_i1.Instance>); + + @override + _i6.Future getHashCode( + _i1.InstanceRef? instance, { + required _i4.Disposable? isAlive, + }) => + (super.noSuchMethod( + Invocation.method( + #getHashCode, + [instance], + {#isAlive: isAlive}, + ), + returnValue: _i6.Future.value(0), + returnValueForMissingStub: _i6.Future.value(0), + ) as _i6.Future); + + @override + _i6.Future<_i1.Instance> evalInstance( + String? expression, { + required _i4.Disposable? isAlive, + Map? scope, + }) => + (super.noSuchMethod( + Invocation.method( + #evalInstance, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + returnValue: _i6.Future<_i1.Instance>.value(_FakeInstance_3( + this, + Invocation.method( + #evalInstance, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + )), + returnValueForMissingStub: + _i6.Future<_i1.Instance>.value(_FakeInstance_3( + this, + Invocation.method( + #evalInstance, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + )), + ) as _i6.Future<_i1.Instance>); + + @override + _i6.Future<_i1.InstanceRef?> asyncEval( + String? expression, { + required _i4.Disposable? isAlive, + Map? scope, + }) => + (super.noSuchMethod( + Invocation.method( + #asyncEval, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + returnValue: _i6.Future<_i1.InstanceRef?>.value(), + returnValueForMissingStub: _i6.Future<_i1.InstanceRef?>.value(), + ) as _i6.Future<_i1.InstanceRef?>); + + @override + _i6.Future<_i1.InstanceRef> safeEval( + String? expression, { + required _i4.Disposable? isAlive, + Map? scope, + }) => + (super.noSuchMethod( + Invocation.method( + #safeEval, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + returnValue: _i6.Future<_i1.InstanceRef>.value(_FakeInstanceRef_4( + this, + Invocation.method( + #safeEval, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + )), + returnValueForMissingStub: + _i6.Future<_i1.InstanceRef>.value(_FakeInstanceRef_4( + this, + Invocation.method( + #safeEval, + [expression], + { + #isAlive: isAlive, + #scope: scope, + }, + ), + )), + ) as _i6.Future<_i1.InstanceRef>); + + @override + _i6.Future addRequest( + _i4.Disposable? isAlive, + _i6.Future Function()? request, + ) => + (super.noSuchMethod( + Invocation.method( + #addRequest, + [ + isAlive, + request, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future getObjHelper( + _i1.ObjRef? instance, + _i4.Disposable? isAlive, { + int? offset, + int? count, + }) => + (super.noSuchMethod( + Invocation.method( + #getObjHelper, + [ + instance, + isAlive, + ], + { + #offset: offset, + #count: count, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + void addAutoDisposeListener( + _i7.Listenable? listenable, [ + _i8.VoidCallback? listener, + String? id, + ]) => + super.noSuchMethod( + Invocation.method( + #addAutoDisposeListener, + [ + listenable, + listener, + id, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void autoDisposeStreamSubscription( + _i6.StreamSubscription? subscription) => + super.noSuchMethod( + Invocation.method( + #autoDisposeStreamSubscription, + [subscription], + ), + returnValueForMissingStub: null, + ); + + @override + void autoDisposeFocusNode(_i9.FocusNode? node) => super.noSuchMethod( + Invocation.method( + #autoDisposeFocusNode, + [node], + ), + returnValueForMissingStub: null, + ); + + @override + void cancelStreamSubscriptions() => super.noSuchMethod( + Invocation.method( + #cancelStreamSubscriptions, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void cancelListeners({List? excludeIds = const []}) => + super.noSuchMethod( + Invocation.method( + #cancelListeners, + [], + {#excludeIds: excludeIds}, + ), + returnValueForMissingStub: null, + ); + + @override + void cancelListener(_i8.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #cancelListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void cancelFocusNodes() => super.noSuchMethod( + Invocation.method( + #cancelFocusNodes, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void callOnceWhenReady({ + required _i8.VoidCallback? callback, + required _i7.ValueListenable? trigger, + required bool Function(T)? readyWhen, + }) => + super.noSuchMethod( + Invocation.method( + #callOnceWhenReady, + [], + { + #callback: callback, + #trigger: trigger, + #readyWhen: readyWhen, + }, + ), + returnValueForMissingStub: null, + ); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/ui/data_panel_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/ui/data_panel_test.dart new file mode 100644 index 000000000000..fdcce9d37dab --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/ui/data_panel_test.dart @@ -0,0 +1,390 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@TestOn('browser') +library; + +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/async_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state_provider.dart'; +import 'package:shared_preferences_tool/src/ui/data_panel.dart'; +import 'package:shared_preferences_tool/src/ui/error_panel.dart'; + +import '../../test_helpers/notifier_mocking_helpers.dart'; +import '../../test_helpers/notifier_mocking_helpers.mocks.dart'; + +void main() { + group('DataPanel', () { + setupDummies(); + + late MockSharedPreferencesStateNotifier notifierMock; + + setUp(() { + notifierMock = MockSharedPreferencesStateNotifier(); + }); + + Future pumpDataPanel(WidgetTester tester) { + return tester.pumpWidget( + DevToolsExtension( + requiresRunningApplication: false, + child: InnerSharedPreferencesStateProvider( + notifier: notifierMock, + child: const DataPanel(), + ), + ), + ); + } + + void stubAsyncState( + AsyncState? state, { + bool editing = false, + }) { + const String selectedKey = 'selectedTestKey'; + when(notifierMock.value).thenReturn( + SharedPreferencesState( + allKeys: const AsyncState>.data([selectedKey]), + editing: editing, + selectedKey: state == null + ? null + : SelectedSharedPreferencesKey( + key: selectedKey, + value: state, + ), + ), + ); + } + + void stubDataState(SharedPreferencesData state, {bool editing = false}) { + stubAsyncState( + AsyncState.data(state), + editing: editing, + ); + } + + testWidgets('should show select key state', (WidgetTester tester) async { + stubAsyncState(null); + await pumpDataPanel(tester); + + expect(find.text('Select a key to view its data.'), findsOneWidget); + }); + + testWidgets('should show loading state', (WidgetTester tester) async { + stubAsyncState(const AsyncState.loading()); + await pumpDataPanel(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('should show error state', (WidgetTester tester) async { + stubAsyncState( + const AsyncState.error( + 'error', + StackTrace.empty, + ), + ); + await pumpDataPanel(tester); + + expect(find.byType(ErrorPanel), findsOneWidget); + }); + + testWidgets('should show string value', (WidgetTester tester) async { + const String value = 'testValue'; + stubDataState(const SharedPreferencesData.string(value: value)); + await pumpDataPanel(tester); + + expect(find.text('Type: String'), findsOneWidget); + expect(find.text('Value: $value'), findsOneWidget); + }); + + testWidgets('should show int value', (WidgetTester tester) async { + const int value = 42; + stubDataState(const SharedPreferencesData.int(value: value)); + await pumpDataPanel(tester); + + expect(find.text('Type: int'), findsOneWidget); + expect(find.text('Value: $value'), findsOneWidget); + }); + + testWidgets('should show double value', (WidgetTester tester) async { + const double value = 42.0; + stubDataState(const SharedPreferencesData.double(value: value)); + await pumpDataPanel(tester); + + expect(find.text('Type: double'), findsOneWidget); + expect(find.text('Value: $value'), findsOneWidget); + }); + + testWidgets('should show boolean value', (WidgetTester tester) async { + const bool value = true; + stubDataState(const SharedPreferencesData.bool(value: value)); + await pumpDataPanel(tester); + + expect(find.text('Type: bool'), findsOneWidget); + expect(find.text('Value: $value'), findsOneWidget); + }); + + testWidgets('should show string list value', (WidgetTester tester) async { + stubDataState(const SharedPreferencesData.stringList( + value: ['value1', 'value2'])); + await pumpDataPanel(tester); + + expect(find.text('Type: List'), findsOneWidget); + expect(find.textContaining('0 -> value1'), findsOneWidget); + expect(find.textContaining('1 -> value2'), findsOneWidget); + }); + + testWidgets('should show viewing state', (WidgetTester tester) async { + stubDataState(const SharedPreferencesData.string(value: 'value')); + await pumpDataPanel(tester); + + expect(find.text('Remove'), findsOneWidget); + expect(find.text('Edit'), findsOneWidget); + }); + + testWidgets('on edit should start editing', (WidgetTester tester) async { + stubDataState(const SharedPreferencesData.string(value: 'value')); + await pumpDataPanel(tester); + + await tester.tap(find.text('Edit')); + verify(notifierMock.startEditing()).called(1); + }); + + testWidgets( + 'on remove should show confirmation modal', + (WidgetTester tester) async { + stubDataState(const SharedPreferencesData.string(value: 'value')); + await pumpDataPanel(tester); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect( + find.text('Are you sure you want to remove selectedTestKey?'), + findsOneWidget, + ); + expect(find.text('CANCEL'), findsOneWidget); + expect(find.text('REMOVE'), findsOneWidget); + }, + ); + + testWidgets( + 'on removed confirmed should remove key', + (WidgetTester tester) async { + const SharedPreferencesData value = SharedPreferencesData.string( + value: 'value', + ); + stubDataState(value); + await pumpDataPanel(tester); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('REMOVE')); + + verify( + notifierMock.deleteSelectedKey(), + ).called(1); + }, + ); + + testWidgets( + 'on remove canceled should cancel remove', + (WidgetTester tester) async { + stubDataState(const SharedPreferencesData.string(value: 'value')); + await pumpDataPanel(tester); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + + expect( + find.text('Are you sure you want to remove selectedTestKey?'), + findsNothing, + ); + }, + ); + + testWidgets('should show editing state', (WidgetTester tester) async { + stubDataState( + const SharedPreferencesData.string(value: 'value'), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets( + 'should show string editing state', + (WidgetTester tester) async { + const String value = 'value'; + stubDataState( + const SharedPreferencesData.string(value: value), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Type: String'), findsOneWidget); + expect(find.text('Value:'), findsOneWidget); + expect(find.text(value), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }, + ); + + testWidgets( + 'should show int editing state', + (WidgetTester tester) async { + const int value = 42; + stubDataState( + const SharedPreferencesData.int(value: value), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Type: int'), findsOneWidget); + expect(find.text('Value:'), findsOneWidget); + expect(find.text('$value'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect( + tester.textInputFormatterPattern, + equals(RegExp(r'^-?\d*').toString()), + ); + }, + ); + + testWidgets( + 'should show double editing state', + (WidgetTester tester) async { + const double value = 42.0; + stubDataState( + const SharedPreferencesData.double(value: value), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Type: double'), findsOneWidget); + expect(find.text('Value:'), findsOneWidget); + expect(find.text('$value'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect( + tester.textInputFormatterPattern, + equals(RegExp(r'^-?\d*\.?\d*').toString()), + ); + }, + ); + + testWidgets( + 'should show boolean editing state', + (WidgetTester tester) async { + const bool value = true; + stubDataState( + const SharedPreferencesData.bool(value: value), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Type: bool'), findsOneWidget); + expect(find.text('Value:'), findsOneWidget); + expect(find.byType(DropdownMenu), findsOneWidget); + }, + ); + + testWidgets( + 'should show string list editing state', + (WidgetTester tester) async { + stubDataState( + const SharedPreferencesData.stringList( + value: ['value1', 'value2'], + ), + editing: true, + ); + await pumpDataPanel(tester); + + expect(find.text('Type: List'), findsOneWidget); + expect(find.text('Value:'), findsOneWidget); + expect(find.text('value1'), findsOneWidget); + expect(find.text('value2'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + // Finds 3 add buttons: + // + + // value1 + // + + // value2 + // + + expect(find.byIcon(Icons.add), findsNWidgets(3)); + }, + ); + + testWidgets( + 'should show apply changes button on value changed', + (WidgetTester tester) async { + stubDataState( + const SharedPreferencesData.string(value: 'value'), + editing: true, + ); + await pumpDataPanel(tester); + + await tester.enterText(find.byType(TextField), 'newValue'); + await tester.pumpAndSettle(); + + expect(find.text('Apply changes'), findsOneWidget); + }, + ); + + testWidgets( + 'pressing an add button on the string list editing state ' + 'should add element in the right index', + (WidgetTester tester) async { + stubDataState( + const SharedPreferencesData.stringList( + value: ['value1', 'value2'], + ), + editing: true, + ); + await pumpDataPanel(tester); + + for (int i = 0; i < 3; i++) { + await tester.tap(find.byIcon(Icons.add).at(i)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(i), '$i'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply changes')); + await tester.pumpAndSettle(); + } + + verifyInOrder(>[ + notifierMock.changeValue( + const SharedPreferencesData.stringList( + value: ['0', 'value1', 'value2'], + ), + ), + notifierMock.changeValue( + const SharedPreferencesData.stringList( + value: ['0', '1', 'value1', 'value2'], + ), + ), + notifierMock.changeValue( + const SharedPreferencesData.stringList( + value: ['0', '1', '2', 'value1', 'value2'], + ), + ), + ]); + }, + ); + }); +} + +extension on WidgetTester { + Pattern get textInputFormatterPattern { + final TextField textField = widget(find.byType(TextField)); + return (textField.inputFormatters!.first as FilteringTextInputFormatter) + .filterPattern + .toString(); + } +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/ui/error_panel_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/ui/error_panel_test.dart new file mode 100644 index 000000000000..69b3748ecf55 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/ui/error_panel_test.dart @@ -0,0 +1,38 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@TestOn('browser') +library; + +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_tool/src/ui/error_panel.dart'; + +void main() { + group('ErrorPanel', () { + testWidgets( + 'should show error and stacktrace', + (WidgetTester tester) async { + const String error = 'error'; + final StackTrace stackTrace = StackTrace.current; + + await tester.pumpWidget( + DevToolsExtension( + requiresRunningApplication: false, + child: Directionality( + textDirection: TextDirection.ltr, + child: ErrorPanel( + error: error, + stackTrace: stackTrace, + ), + ), + ), + ); + + expect(find.text('Error:\n$error\n\n$stackTrace'), findsOneWidget); + }, + ); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/ui/keys_panel_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/ui/keys_panel_test.dart new file mode 100644 index 000000000000..5ce79d7bca8f --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/ui/keys_panel_test.dart @@ -0,0 +1,205 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@TestOn('browser') +library; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/async_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state_provider.dart'; +import 'package:shared_preferences_tool/src/ui/error_panel.dart'; +import 'package:shared_preferences_tool/src/ui/keys_panel.dart'; + +import '../../test_helpers/notifier_mocking_helpers.dart'; +import '../../test_helpers/notifier_mocking_helpers.mocks.dart'; + +void main() { + group('KeysPanel', () { + setupDummies(); + + late MockSharedPreferencesStateNotifier notifierMock; + + setUp(() { + notifierMock = MockSharedPreferencesStateNotifier(); + }); + + Future pumpKeysPanel(WidgetTester tester) { + return tester.pumpWidget( + DevToolsExtension( + requiresRunningApplication: false, + child: InnerSharedPreferencesStateProvider( + notifier: notifierMock, + child: const KeysPanel(), + ), + ), + ); + } + + void stubDataState({ + AsyncState> allKeys = + const AsyncState>.data([]), + SelectedSharedPreferencesKey? selectedKey, + bool editing = false, + }) { + when(notifierMock.value).thenReturn( + SharedPreferencesState( + allKeys: allKeys, + selectedKey: selectedKey, + editing: editing, + ), + ); + } + + testWidgets('should show loading state', (WidgetTester tester) async { + stubDataState( + allKeys: const AsyncState>.loading(), + ); + await pumpKeysPanel(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('should show error state', (WidgetTester tester) async { + stubDataState( + allKeys: const AsyncState>.error( + 'error', + StackTrace.empty, + ), + ); + await pumpKeysPanel(tester); + + expect(find.byType(ErrorPanel), findsOneWidget); + }); + + testWidgets('should show keys list with all keys', + (WidgetTester tester) async { + const List allKeys = ['key1', 'key2']; + stubDataState( + allKeys: const AsyncState>.data(allKeys), + ); + + await pumpKeysPanel(tester); + + for (final String key in allKeys) { + expect(find.text(key), findsOneWidget); + } + }); + + testWidgets( + 'only selected key should be highlighted', + (WidgetTester tester) async { + const String selectedKey = 'selectedKey'; + const List keys = ['key1', selectedKey, 'key2']; + + stubDataState( + allKeys: const AsyncState>.data(keys), + selectedKey: const SelectedSharedPreferencesKey( + key: selectedKey, + value: AsyncState.loading(), + ), + ); + + await pumpKeysPanel(tester); + + final Element selectedKeyElement = + tester.element(find.text(selectedKey)); + final ColorScheme colorScheme = + Theme.of(selectedKeyElement).colorScheme; + + Color? bgColorFor(String key) { + final Container? container = tester + .element(find.text(key)) + .findAncestorWidgetOfExactType(); + return container?.color; + } + + for (final String key in [...keys]..remove(selectedKey)) { + expect( + bgColorFor(key), + isNot(equals(colorScheme.selectedRowBackgroundColor)), + ); + } + expect( + bgColorFor(selectedKey), + equals(colorScheme.selectedRowBackgroundColor), + ); + }, + ); + + testWidgets( + 'should start searching when clicking the search icon', + (WidgetTester tester) async { + stubDataState(); + await pumpKeysPanel(tester); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + }, + ); + + testWidgets( + 'should stop searching when clicking the close icon', + (WidgetTester tester) async { + stubDataState(); + await pumpKeysPanel(tester); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + }, + ); + + testWidgets( + 'should filter keys when searching', + (WidgetTester tester) async { + stubDataState(); + await pumpKeysPanel(tester); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'key2'); + + verify(notifierMock.filter('key2')).called(1); + }, + ); + + testWidgets( + 'should refresh on refresh icon clicked', + (WidgetTester tester) async { + stubDataState(); + await pumpKeysPanel(tester); + + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + + verify(notifierMock.fetchAllKeys()).called(1); + }, + ); + + testWidgets( + 'should select key on key clicked', + (WidgetTester tester) async { + const String keyToSelect = 'keyToSelect'; + stubDataState( + allKeys: const AsyncState>.data([keyToSelect]), + ); + await pumpKeysPanel(tester); + + await tester.tap(find.text(keyToSelect)); + + verify(notifierMock.selectKey(keyToSelect)).called(1); + }, + ); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/src/ui/shared_preferences_body_test.dart b/packages/shared_preferences/shared_preferences_tool/test/src/ui/shared_preferences_body_test.dart new file mode 100644 index 000000000000..d59563fa4699 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/src/ui/shared_preferences_body_test.dart @@ -0,0 +1,46 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@TestOn('browser') +library; + +import 'package:devtools_extensions/devtools_extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state_provider.dart'; +import 'package:shared_preferences_tool/src/ui/data_panel.dart'; +import 'package:shared_preferences_tool/src/ui/keys_panel.dart'; +import 'package:shared_preferences_tool/src/ui/shared_preferences_body.dart'; + +import '../../test_helpers/notifier_mocking_helpers.dart'; +import '../../test_helpers/notifier_mocking_helpers.mocks.dart'; + +void main() { + group('group name', () { + setupDummies(); + + testWidgets( + 'should show keys and data panels', + (WidgetTester tester) async { + final MockSharedPreferencesStateNotifier notifier = + MockSharedPreferencesStateNotifier(); + when(notifier.value).thenReturn(const SharedPreferencesState()); + + await tester.pumpWidget( + DevToolsExtension( + requiresRunningApplication: false, + child: InnerSharedPreferencesStateProvider( + notifier: notifier, + child: const SharedPreferencesBody(), + ), + ), + ); + + expect(find.byType(KeysPanel), findsOneWidget); + expect(find.byType(DataPanel), findsOneWidget); + }, + ); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.dart b/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.dart new file mode 100644 index 000000000000..3993f3d3aaa4 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.dart @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences_tool/src/async_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart'; +import 'package:shared_preferences_tool/src/shared_preferences_state_notifier.dart'; + +@GenerateNiceMocks(>[ + MockSpec(), +]) +// ignore: unused_import +import 'notifier_mocking_helpers.mocks.dart'; + +void setupDummies() { + setUpAll(() { + provideDummy( + const AsyncState.data( + SharedPreferencesData.int(value: 42), + ), + ); + provideDummy( + const AsyncState.data( + SharedPreferencesState(), + ), + ); + }); +} diff --git a/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.mocks.dart b/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.mocks.dart new file mode 100644 index 000000000000..25e4389403c5 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.mocks.dart @@ -0,0 +1,186 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in shared_preferences_tool/test/test_helpers/notifier_mocking_helpers.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; +import 'dart:ui' as _i5; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences_tool/src/shared_preferences_state.dart' + as _i2; +import 'package:shared_preferences_tool/src/shared_preferences_state_notifier.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSharedPreferencesState_0 extends _i1.SmartFake + implements _i2.SharedPreferencesState { + _FakeSharedPreferencesState_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SharedPreferencesStateNotifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSharedPreferencesStateNotifier extends _i1.Mock + implements _i3.SharedPreferencesStateNotifier { + @override + _i2.SharedPreferencesState get value => (super.noSuchMethod( + Invocation.getter(#value), + returnValue: _FakeSharedPreferencesState_0( + this, + Invocation.getter(#value), + ), + returnValueForMissingStub: _FakeSharedPreferencesState_0( + this, + Invocation.getter(#value), + ), + ) as _i2.SharedPreferencesState); + + @override + set value(_i2.SharedPreferencesState? newValue) => super.noSuchMethod( + Invocation.setter( + #value, + newValue, + ), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i4.Future fetchAllKeys() => (super.noSuchMethod( + Invocation.method( + #fetchAllKeys, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future selectKey(String? key) => (super.noSuchMethod( + Invocation.method( + #selectKey, + [key], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void filter(String? token) => super.noSuchMethod( + Invocation.method( + #filter, + [token], + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future changeValue(_i2.SharedPreferencesData? newValue) => + (super.noSuchMethod( + Invocation.method( + #changeValue, + [newValue], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future deleteSelectedKey() => (super.noSuchMethod( + Invocation.method( + #deleteSelectedKey, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void startEditing() => super.noSuchMethod( + Invocation.method( + #startEditing, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void stopEditing() => super.noSuchMethod( + Invocation.method( + #stopEditing, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void selectApi({required bool? legacyApi}) => super.noSuchMethod( + Invocation.method( + #selectApi, + [], + {#legacyApi: legacyApi}, + ), + returnValueForMissingStub: null, + ); + + @override + void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #removeListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method( + #notifyListeners, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/packages/shared_preferences/shared_preferences_tool/web/index.html b/packages/shared_preferences/shared_preferences_tool/web/index.html new file mode 100644 index 000000000000..9e3773f06ddc --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/web/index.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + shared_preferences_tool + + + + + + + + + + diff --git a/packages/shared_preferences/shared_preferences_tool/web/manifest.json b/packages/shared_preferences/shared_preferences_tool/web/manifest.json new file mode 100644 index 000000000000..f2fa39ab9af2 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_tool/web/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "shared_preferences_tool", + "short_name": "shared_preferences_tool", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false +} diff --git a/script/configs/allowed_unpinned_deps.yaml b/script/configs/allowed_unpinned_deps.yaml index 9aa33fd59f8e..2ac8f4740033 100644 --- a/script/configs/allowed_unpinned_deps.yaml +++ b/script/configs/allowed_unpinned_deps.yaml @@ -35,6 +35,8 @@ - convert - crypto - dart_style +- devtools_app_shared +- devtools_extensions - fake_async - ffi - gcloud