diff --git a/.github/labeler.yml b/.github/labeler.yml index 2bfcbae8..ee179ac2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -27,8 +27,14 @@ - any-glob-to-any-file: - packages/vector_math/**/* +'p: listen': + - changed-files: + - any-glob-to-any-file: + - packages/listen/**/* + 'triage-framework': - changed-files: - any-glob-to-any-file: - packages/vector_math/**/* - packages/flutter_hook_config/**/* + - packages/listen/**/* diff --git a/README.md b/README.md index 5d54b042..f1400049 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ These are the packages hosted in this repository: |---------|-----|--------|-------|--------|---------------| | [flutter\_hook\_config](./packages/flutter_hook_config/) | [![pub package](https://img.shields.io/pub/v/flutter_hook_config.svg)](https://pub.dev/packages/flutter_hook_config) | [![pub points](https://img.shields.io/pub/points/flutter_hook_config)](https://pub.dev/packages/flutter_hook_config/score) | [![downloads](https://img.shields.io/pub/dm/flutter_hook_config)](https://pub.dev/packages/flutter_hook_config/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20flutter_hook_config?label=)](https://github.com/flutter/flutter/labels/p%3A%20flutter_hook_config) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20flutter_hook_config?label=)](https://github.com/flutter/core-packages/labels/p%3A%20flutter_hook_config) | | [flutter\_template\_images](./packages/flutter_template_images/) | [![pub package](https://img.shields.io/pub/v/flutter_template_images.svg)](https://pub.dev/packages/flutter_template_images) | [![pub points](https://img.shields.io/pub/points/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![downloads](https://img.shields.io/pub/dm/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20flutter_template_images?label=)](https://github.com/flutter/flutter/labels/p%3A%20flutter_template_images) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20flutter_template_images?label=)](https://github.com/flutter/core-packages/labels/p%3A%20flutter_template_images) | +| [listen](./packages/listen/) | [![pub package](https://img.shields.io/pub/v/listen.svg)](https://pub.dev/packages/listen) | [![pub points](https://img.shields.io/pub/points/listen)](https://pub.dev/packages/listen/score) | [![downloads](https://img.shields.io/pub/dm/listen)](https://pub.dev/packages/listen/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20listen?label=)](https://github.com/flutter/flutter/labels/p%3A%20listen) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20listen?label=)](https://github.com/flutter/core-packages/labels/p%3A%20listen) | | [multicast\_dns](./packages/multicast_dns/) | [![pub package](https://img.shields.io/pub/v/multicast_dns.svg)](https://pub.dev/packages/multicast_dns) | [![pub points](https://img.shields.io/pub/points/multicast_dns)](https://pub.dev/packages/multicast_dns/score) | [![downloads](https://img.shields.io/pub/dm/multicast_dns)](https://pub.dev/packages/multicast_dns/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20multicast_dns?label=)](https://github.com/flutter/flutter/labels/p%3A%20multicast_dns) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20multicast_dns?label=)](https://github.com/flutter/core-packages/labels/p%3A%20multicast_dns) | | [mustache\_template](./third_party/packages/mustache_template/) | [![pub package](https://img.shields.io/pub/v/mustache_template.svg)](https://pub.dev/packages/mustache_template) | [![pub points](https://img.shields.io/pub/points/mustache_template)](https://pub.dev/packages/mustache_template/score) | [![downloads](https://img.shields.io/pub/dm/mustache_template)](https://pub.dev/packages/mustache_template/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20mustache_template?label=)](https://github.com/flutter/flutter/labels/p%3A%20mustache_template) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20mustache_template?label=)](https://github.com/flutter/core-packages/labels/p%3A%20mustache_template) | | [standard\_message\_codec](./packages/standard_message_codec/) | [![pub package](https://img.shields.io/pub/v/standard_message_codec.svg)](https://pub.dev/packages/standard_message_codec) | [![pub points](https://img.shields.io/pub/points/standard_message_codec)](https://pub.dev/packages/standard_message_codec/score) | [![downloads](https://img.shields.io/pub/dm/standard_message_codec)](https://pub.dev/packages/standard_message_codec/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20standard_message_codec?label=)](https://github.com/flutter/flutter/labels/p%3A%20standard_message_codec) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/core-packages/p%3A%20standard_message_codec?label=)](https://github.com/flutter/core-packages/labels/p%3A%20standard_message_codec) | diff --git a/packages/listen/.gitignore b/packages/listen/.gitignore new file mode 100644 index 00000000..fd4a666c --- /dev/null +++ b/packages/listen/.gitignore @@ -0,0 +1,9 @@ +.children +.project +.DS_Store +packages +pubspec.lock +.pub +.packages +.dart_tool +.idea diff --git a/packages/listen/AUTHORS b/packages/listen/AUTHORS new file mode 100644 index 00000000..e8063a8c --- /dev/null +++ b/packages/listen/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/packages/listen/CHANGELOG.md b/packages/listen/CHANGELOG.md new file mode 100644 index 00000000..5bb866eb --- /dev/null +++ b/packages/listen/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0-beta.1 + +- Moves source code from `flutter/flutter` to `flutter/core-packages`. diff --git a/packages/listen/LICENSE b/packages/listen/LICENSE new file mode 100644 index 00000000..29b709da --- /dev/null +++ b/packages/listen/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +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/listen/README.md b/packages/listen/README.md new file mode 100644 index 00000000..9e2a6f21 --- /dev/null +++ b/packages/listen/README.md @@ -0,0 +1,103 @@ + + +# listen + +A package to notify state changes to interested listeners in pure Dart. + +## Usage + +### Using ValueNotifier + +`ValueNotifier` wraps a single value and notifies listeners whenever the value changes: + + +```dart +void valueNotifierExample() { + final counter = ValueNotifier(0); + + counter.addListener(() { + print('Value changed: ${counter.value}'); + }); + + counter.value = 5; // Prints: Value changed: 5 + counter.value = 10; // Prints: Value changed: 10 + counter.value = 10; // Does not print because the value is == 10 + + counter.dispose(); +} + +``` + +### Using ChangeNotifier + +Extend or mix in `ChangeNotifier` to manage state and notify listeners manually: + + +```dart +/// A [ChangeNotifier] subclass that encapsulates a list of items and notifies +/// listeners whenever items are added or removed. +class ItemListNotifier extends ChangeNotifier { + final List _items = []; + + /// The unmodifiable list of current items. + List get items => List.unmodifiable(_items); + + /// Adds an [item] to the list and notifies listeners. + void addItem(String item) { + _items.add(item); + notifyListeners(); + } + + /// Removes an [item] from the list and notifies listeners if it was present. + void removeItem(String item) { + if (_items.remove(item)) { + notifyListeners(); + } + } +} + +``` + +Then, listen to changes and update state: + + +```dart +void changeNotifierExample() { + final listNotifier = ItemListNotifier(); + + listNotifier.addListener(() { + print('Current items: ${listNotifier.items}'); + }); + + listNotifier.addItem('Apple'); // Prints: Current items: [Apple] + listNotifier.addItem('Banana'); // Prints: Current items: [Apple, Banana] + listNotifier.removeItem('Apple'); // Prints: Current items: [Banana] + + listNotifier.dispose(); +} + +``` + +### Merging listenables + +Use `Listenable.merge` to listen to multiple objects simultaneously: + + +```dart +void mergeExample() { + final first = ValueNotifier('Hello'); + final second = ValueNotifier('World'); + + final merged = Listenable.merge([first, second]); + + merged.addListener(() { + print('Merged listenable triggered: ${first.value} ${second.value}'); + }); + + first.value = 'Hi'; // Prints: Merged listenable triggered: Hi World + second.value = 'Dart'; // Prints: Merged listenable triggered: Hi Dart + + first.dispose(); + second.dispose(); +} +``` diff --git a/packages/listen/example/lib/counter.dart b/packages/listen/example/lib/counter.dart new file mode 100644 index 00000000..ca1e572b --- /dev/null +++ b/packages/listen/example/lib/counter.dart @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +/// A simple counter that extends [ChangeNotifier] to notify listeners +/// whenever its value changes. +class Counter extends ChangeNotifier { + int _count = 0; + + /// The current count value. + int get count => _count; + + /// Increments the count by one and notifies listeners. + void increment() { + _count++; + notifyListeners(); + } +} + +void main() { + final counter = Counter(); + + counter.addListener(() { + print('Counter value changed to: ${counter.count}'); + }); + + counter.increment(); // Prints: Counter value changed to: 1 + counter.increment(); // Prints: Counter value changed to: 2 + + counter.dispose(); +} diff --git a/packages/listen/example/lib/list_notifier.dart b/packages/listen/example/lib/list_notifier.dart new file mode 100644 index 00000000..c514c8dd --- /dev/null +++ b/packages/listen/example/lib/list_notifier.dart @@ -0,0 +1,43 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +/// A [ChangeNotifier] subclass that encapsulates a list of items and notifies +/// listeners whenever items are added or removed. +class ItemListNotifier extends ChangeNotifier { + final List _items = []; + + /// The unmodifiable list of current items. + List get items => List.unmodifiable(_items); + + /// Adds an [item] to the list and notifies listeners. + void addItem(String item) { + _items.add(item); + notifyListeners(); + } + + /// Removes an [item] from the list and notifies listeners if it was present. + void removeItem(String item) { + if (_items.remove(item)) { + notifyListeners(); + } + } +} + +void main() { + final listNotifier = ItemListNotifier(); + + listNotifier.addListener(() { + print('Current items: ${listNotifier.items}'); + }); + + listNotifier.addItem('Apple'); // Prints: Current items: [Apple] + listNotifier.addItem('Banana'); // Prints: Current items: [Apple, Banana] + listNotifier.removeItem('Apple'); // Prints: Current items: [Banana] + + listNotifier.dispose(); +} diff --git a/packages/listen/example/lib/listenable_merge.dart b/packages/listen/example/lib/listenable_merge.dart new file mode 100644 index 00000000..00948411 --- /dev/null +++ b/packages/listen/example/lib/listenable_merge.dart @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +void main() { + final first = ValueNotifier('Hello'); + final second = ValueNotifier('World'); + + final merged = Listenable.merge([first, second]); + + merged.addListener(() { + print('Merged listenable triggered: ${first.value} ${second.value}'); + }); + + first.value = 'Hi'; // Prints: Merged listenable triggered: Hi World + second.value = 'Dart'; // Prints: Merged listenable triggered: Hi Dart + + first.dispose(); + second.dispose(); +} diff --git a/packages/listen/example/lib/main.dart b/packages/listen/example/lib/main.dart new file mode 100644 index 00000000..a335d532 --- /dev/null +++ b/packages/listen/example/lib/main.dart @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +void main() { + final counter = ValueNotifier(0); + + counter.addListener(() { + print('Counter changed to: ${counter.value}'); + }); + + counter.value = 1; // Prints: Counter changed to: 1 + counter.value = 2; // Prints: Counter changed to: 2 + + counter.dispose(); +} diff --git a/packages/listen/example/lib/readme_excerpts.dart b/packages/listen/example/lib/readme_excerpts.dart new file mode 100644 index 00000000..c0fb9c69 --- /dev/null +++ b/packages/listen/example/lib/readme_excerpts.dart @@ -0,0 +1,91 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This file exists solely to host compiled excerpts for README.md, and is not +// intended for use as an actual example application. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +/// Demonstrates [ValueNotifier] usage for README. +// #docregion ValueNotifier +void valueNotifierExample() { + final counter = ValueNotifier(0); + + counter.addListener(() { + print('Value changed: ${counter.value}'); + }); + + counter.value = 5; // Prints: Value changed: 5 + counter.value = 10; // Prints: Value changed: 10 + counter.value = 10; // Does not print because the value is == 10 + + counter.dispose(); +} + +// #enddocregion ValueNotifier + +// #docregion ChangeNotifierClass +/// A [ChangeNotifier] subclass that encapsulates a list of items and notifies +/// listeners whenever items are added or removed. +class ItemListNotifier extends ChangeNotifier { + final List _items = []; + + /// The unmodifiable list of current items. + List get items => List.unmodifiable(_items); + + /// Adds an [item] to the list and notifies listeners. + void addItem(String item) { + _items.add(item); + notifyListeners(); + } + + /// Removes an [item] from the list and notifies listeners if it was present. + void removeItem(String item) { + if (_items.remove(item)) { + notifyListeners(); + } + } +} + +// #enddocregion ChangeNotifierClass + +/// Demonstrates [ChangeNotifier] usage for README. +// #docregion ChangeNotifierUsage +void changeNotifierExample() { + final listNotifier = ItemListNotifier(); + + listNotifier.addListener(() { + print('Current items: ${listNotifier.items}'); + }); + + listNotifier.addItem('Apple'); // Prints: Current items: [Apple] + listNotifier.addItem('Banana'); // Prints: Current items: [Apple, Banana] + listNotifier.removeItem('Apple'); // Prints: Current items: [Banana] + + listNotifier.dispose(); +} + +// #enddocregion ChangeNotifierUsage + +/// Demonstrates [Listenable.merge] usage for README. +// #docregion Merge +void mergeExample() { + final first = ValueNotifier('Hello'); + final second = ValueNotifier('World'); + + final merged = Listenable.merge([first, second]); + + merged.addListener(() { + print('Merged listenable triggered: ${first.value} ${second.value}'); + }); + + first.value = 'Hi'; // Prints: Merged listenable triggered: Hi World + second.value = 'Dart'; // Prints: Merged listenable triggered: Hi Dart + + first.dispose(); + second.dispose(); +} +// #enddocregion Merge diff --git a/packages/listen/example/lib/value_notifier.dart b/packages/listen/example/lib/value_notifier.dart new file mode 100644 index 00000000..8b39bebc --- /dev/null +++ b/packages/listen/example/lib/value_notifier.dart @@ -0,0 +1,21 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:listen/listen.dart'; + +void main() { + final counter = ValueNotifier(0); + + counter.addListener(() { + print('Value changed: ${counter.value}'); + }); + + counter.value = 5; // Prints: Value changed: 5 + counter.value = 10; // Prints: Value changed: 10 + counter.value = 10; // Does not print because the value is == 10 + + counter.dispose(); +} diff --git a/packages/listen/example/pubspec.yaml b/packages/listen/example/pubspec.yaml new file mode 100644 index 00000000..d0180981 --- /dev/null +++ b/packages/listen/example/pubspec.yaml @@ -0,0 +1,14 @@ +name: listen_example +description: Examples for the listen package. +publish_to: none +version: 0.0.1 + +environment: + sdk: ^3.10.0 + +dependencies: + listen: + path: ../ + +dev_dependencies: + test: ^1.25.9 diff --git a/packages/listen/example/test/example_test.dart b/packages/listen/example/test/example_test.dart new file mode 100644 index 00000000..7f30e30d --- /dev/null +++ b/packages/listen/example/test/example_test.dart @@ -0,0 +1,47 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:listen_example/counter.dart' as counter; +import 'package:listen_example/list_notifier.dart' as list_notifier; +import 'package:listen_example/listenable_merge.dart' as listenable_merge; +import 'package:listen_example/main.dart' as basic_main; +import 'package:listen_example/readme_excerpts.dart' as readme_excerpts; +import 'package:listen_example/value_notifier.dart' as value_notifier; +import 'package:test/test.dart'; + +void main() { + test('counter example runs without error', () { + expect(counter.main, returnsNormally); + }); + + test('list notifier example runs without error', () { + expect(list_notifier.main, returnsNormally); + }); + + test('listenable merge example runs without error', () { + expect(listenable_merge.main, returnsNormally); + }); + + test('value notifier example runs without error', () { + expect(value_notifier.main, returnsNormally); + }); + + test('basic main example runs without error', () { + expect(basic_main.main, returnsNormally); + }); + + group('readme excerpts', () { + test('valueNotifierExample runs without error', () { + expect(readme_excerpts.valueNotifierExample, returnsNormally); + }); + + test('changeNotifierExample runs without error', () { + expect(readme_excerpts.changeNotifierExample, returnsNormally); + }); + + test('mergeExample runs without error', () { + expect(readme_excerpts.mergeExample, returnsNormally); + }); + }); +} diff --git a/packages/listen/lib/listen.dart b/packages/listen/lib/listen.dart new file mode 100644 index 00000000..04d4d892 --- /dev/null +++ b/packages/listen/lib/listen.dart @@ -0,0 +1,8 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Notify state changes to interested listeners. +library; + +export 'src/listen.dart'; diff --git a/packages/listen/lib/src/listen.dart b/packages/listen/lib/src/listen.dart new file mode 100644 index 00000000..2c292316 --- /dev/null +++ b/packages/listen/lib/src/listen.dart @@ -0,0 +1,565 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(chunhtai): remove this once https://github.com/dart-lang/sdk/pull/63646 is landed. +// ignore_for_file: doc_directive_unknown + +import 'package:meta/meta.dart'; + +/// Signature of callbacks that have no arguments and return no data. +typedef VoidCallback = void Function(); + +/// Signature of callbacks that are called when an error is thrown by a listener. +typedef ErrorCallback = void Function(String message, StackTrace? stackTrace); + +/// Signature of callbacks that are called when an object is created. +/// +/// The `className` is the name of the class, and `object` is the object that was created. +typedef ObjectCreatedCallback = void Function(String className, Object object); + +/// Signature of callbacks that are called when an object is disposed. +/// +/// The `object` is the object that was disposed. +typedef ObjectDisposedCallback = void Function(Object object); + +/// An object that maintains a list of listeners. +/// +/// The listeners are typically used to notify clients that the object has been +/// updated. +/// +/// The terms "notify clients", "send notifications", "trigger notifications", +/// and "fire notifications" are used interchangeably. +/// +/// See also: +/// +/// * [ValueListenable], an interface that augments the [Listenable] interface +/// with the concept of a _current value_. +/// * [ChangeNotifier], which can be subclassed or mixed in to create objects +/// that implement the [Listenable] interface. +/// * [ValueNotifier], which implements the [ValueListenable] interface with +/// a mutable value that triggers the notifications when modified. +/// * [Listenable.merge], which creates a [Listenable] that triggers +/// notifications whenever any of a list of other [Listenable]s trigger their +/// notifications. +abstract class Listenable { + /// This constructor enables subclasses to provide const constructors so that + /// they can be used in const expressions. + const Listenable(); + + /// Return a [Listenable] that triggers when any of the given [Listenable]s + /// themselves trigger. + /// + /// Once the factory is called, items must not be added or removed from the iterable. + /// Doing so will lead to memory leaks or exceptions. + /// + /// The iterable may contain nulls; they are ignored. + /// + /// {@example example/lib/listenable_merge.dart} + factory Listenable.merge(Iterable listenables) = _MergingListenable; + + /// Error callback that is called when an error is thrown by a listener. + /// + /// By default, errors are thrown as [StateError]. + static ErrorCallback onError = (String message, StackTrace? stackTrace) { + throw StateError(message); + }; + + /// Called when a new object is created. + /// + /// This can be useful for tracking memory leaks. + /// + /// Only called in debug mode. + static ObjectCreatedCallback debugMaybeDispatchCreated = (String _, Object _) {}; + + /// Called when an object is disposed. + /// + /// This can be useful for tracking memory leaks. + /// + /// Only called in debug mode. + static ObjectDisposedCallback debugMaybeDispatchDisposed = (Object _) {}; + + /// Register a closure to be called when the object notifies its listeners. + void addListener(VoidCallback listener); + + /// Remove a previously registered closure from the list of closures that the + /// object notifies. + void removeListener(VoidCallback listener); +} + +/// An interface for subclasses of [Listenable] that expose a [value]. +/// +/// This interface is implemented by [ValueNotifier] and allows other APIs +/// to accept either of those implementations interchangeably. +abstract class ValueListenable extends Listenable { + /// This constructor enables subclasses to provide const constructors so that + /// they can be used in const expressions. + const ValueListenable(); + + /// The current value of the object. + /// + /// When the value changes, the callbacks registered with [addListener] will be + /// invoked. + T get value; +} + +/// A class that can be extended or mixed in that provides a change notification +/// API using [VoidCallback] for notifications. +/// +/// It is O(1) for adding listeners and O(N) for removing listeners and dispatching +/// notifications (where N is the number of listeners). +/// +/// ## Using ChangeNotifier subclasses for data models +/// +/// A data structure can extend or mix in [ChangeNotifier] to implement the +/// [Listenable] interface. +/// +/// The following example implements a simple counter whose current count is +/// stored in a [ChangeNotifier] subclass, notifying clients when the value changes: +/// +/// {@example example/lib/counter.dart} +/// +/// In this case, the [ChangeNotifier] subclass encapsulates a list, notifying +/// clients whenever an item is added or removed: +/// +/// {@example example/lib/list_notifier.dart} +/// +/// See also: +/// +/// * [ValueNotifier], which is a [ChangeNotifier] that wraps a single value. +mixin class ChangeNotifier implements Listenable { + int _count = 0; + // The _listeners is intentionally set to a fixed-length _GrowableList instead + // of const []. + // + // The const [] creates an instance of _ImmutableList which would be + // different from fixed-length _GrowableList used elsewhere in this class. + // keeping runtime type the same during the lifetime of this class lets the + // compiler to infer concrete type for this property, and thus improves + // performance. + static final List _emptyListeners = List.filled(0, null); + List _listeners = _emptyListeners; + int _notificationCallStackDepth = 0; + int _reentrantlyRemovedListeners = 0; + bool _debugDisposed = false; + + /// If true, the creation of this instance was dispatched to + /// [Listenable.debugMaybeDispatchCreated]. + /// + /// As [ChangeNotifier] is used as mixin, it does not have constructor, + /// so we use [addListener] to dispatch the event. + bool _debugCreationDispatched = false; + + /// Used by subclasses to assert that the [ChangeNotifier] has not yet been + /// disposed. + /// + /// The [debugAssertNotDisposed] function should only be called inside of an + /// assert, as in this example. + /// + /// ```dart + /// class MyNotifier with ChangeNotifier { + /// void doUpdate() { + /// assert(ChangeNotifier.debugAssertNotDisposed(this)); + /// // ... + /// } + /// } + /// ``` + // This is static and not an instance method because too many people try to + // implement ChangeNotifier instead of extending it (and so it is too breaking + // to add a method, especially for debug). + static bool debugAssertNotDisposed(ChangeNotifier notifier) { + assert(() { + if (notifier._debugDisposed) { + Listenable.onError( + 'A ${notifier.runtimeType} was used after being disposed.\n' + 'Once you have called dispose() on a ${notifier.runtimeType}, it ' + 'can no longer be used.', + StackTrace.current, + ); + } + return true; + }()); + return true; + } + + /// Whether any listeners are currently registered. + /// + /// Clients should not depend on this value for their behavior, because having + /// one listener's logic change when another listener happens to start or stop + /// listening will lead to extremely hard-to-track bugs. Subclasses might use + /// this information to determine whether to do any work when there are no + /// listeners, however; for example, resuming a [Stream] when a listener is + /// added and pausing it when a listener is removed. + /// + /// Typically this is used by overriding [addListener], checking if + /// [hasListeners] is false before calling `super.addListener()`, and if so, + /// starting whatever work is needed to determine when to call + /// [notifyListeners]; and similarly, by overriding [removeListener], checking + /// if [hasListeners] is false after calling `super.removeListener()`, and if + /// so, stopping that same work. + /// + /// This method returns false if [dispose] has been called. + @protected + bool get hasListeners => _count > 0; + + /// Dispatches the event of the [object] creation to [Listenable.debugMaybeDispatchCreated]. + /// + /// Tools like leak_tracker use the event of object creation to help + /// developers identify the owner of the object, for troubleshooting purposes, + /// by taking stack trace at the moment of the event. + /// + /// But, as [ChangeNotifier] is mixin, it does not have its own constructor. So, it + /// communicates object creation in first `addListener`, that results + /// in the stack trace pointing to `addListener`, not to constructor. + /// + /// To make debugging easier, invoke [ChangeNotifier.maybeDispatchObjectCreation] + /// in constructor of the class. It will help + /// to identify the owner. + @protected + static void maybeDispatchObjectCreation(ChangeNotifier object) { + assert(() { + if (!object._debugCreationDispatched) { + Listenable.debugMaybeDispatchCreated('ChangeNotifier', object); + object._debugCreationDispatched = true; + } + return true; + }()); + } + + /// Register a closure to be called when the object changes. + /// + /// If the given closure is already registered, an additional instance is + /// added, and must be removed the same number of times it is added before it + /// will stop being called. + /// + /// This method must not be called after [dispose] has been called. + /// + /// If a listener is added twice, and is removed once during an iteration + /// (e.g. in response to a notification), it will still be called again. If, + /// on the other hand, it is removed as many times as it was registered, then + /// it will no longer be called. This odd behavior is the result of the + /// [ChangeNotifier] not being able to determine which listener is being + /// removed, since they are identical, therefore it will conservatively still + /// call all the listeners when it knows that any are still registered. + /// + /// This surprising behavior can be unexpectedly observed when registering a + /// listener on two separate objects which are both forwarding all + /// registrations to a common upstream object. + /// + /// See also: + /// + /// * [removeListener], which removes a previously registered closure from + /// the list of closures that are notified when the object changes. + @override + void addListener(VoidCallback listener) { + assert(ChangeNotifier.debugAssertNotDisposed(this)); + + assert(() { + ChangeNotifier.maybeDispatchObjectCreation(this); + return true; + }()); + + if (_count == _listeners.length) { + if (_count == 0) { + _listeners = List.filled(1, null); + } else { + final newListeners = List.filled(_listeners.length * 2, null); + for (var i = 0; i < _count; i++) { + newListeners[i] = _listeners[i]; + } + _listeners = newListeners; + } + } + _listeners[_count++] = listener; + } + + void _removeAt(int index) { + // The list holding the listeners is not growable for performances reasons. + // We still want to shrink this list if a lot of listeners have been added + // and then removed outside a notifyListeners iteration. + // We do this only when the real number of listeners is half the length + // of our list. + _count -= 1; + if (_count * 2 <= _listeners.length) { + final newListeners = List.filled(_count, null); + + // Listeners before the index are at the same place. + for (var i = 0; i < index; i++) { + newListeners[i] = _listeners[i]; + } + + // Listeners after the index move towards the start of the list. + for (var i = index; i < _count; i++) { + newListeners[i] = _listeners[i + 1]; + } + + _listeners = newListeners; + } else { + // When there are more listeners than half the length of the list, we only + // shift our listeners, so that we avoid to reallocate memory for the + // whole list. + for (var i = index; i < _count; i++) { + _listeners[i] = _listeners[i + 1]; + } + _listeners[_count] = null; + } + } + + /// Remove a previously registered closure from the list of closures that are + /// notified when the object changes. + /// + /// If the given listener is not registered, the call is ignored. + /// + /// This method returns immediately if [dispose] has been called. + /// + /// If a listener is added twice, and is removed once during an iteration + /// (e.g. in response to a notification), it will still be called again. If, + /// on the other hand, it is removed as many times as it was registered, then + /// it will not be called. + /// + /// This surprising behavior can be unexpectedly observed when registering a + /// listener on two separate objects which are both forwarding all + /// registrations to a common upstream object. + /// + /// See also: + /// + /// * [addListener], which registers a closure to be called when the object + /// changes. + @override + void removeListener(VoidCallback listener) { + // This method is allowed to be called on disposed instances for usability + // reasons. Due to how our frame scheduling logic between render objects and + // overlays, it is common that the owner of this instance would be disposed a + // frame earlier than the listeners. Allowing calls to this method after it + // is disposed makes it easier for listeners to properly clean up. + for (var i = 0; i < _count; i++) { + final VoidCallback? listenerAtIndex = _listeners[i]; + if (listenerAtIndex == listener) { + if (_notificationCallStackDepth > 0) { + // We don't resize the list during notifyListeners iterations + // but we set to null, the listeners we want to remove. We will + // effectively resize the list at the end of all notifyListeners + // iterations. + _listeners[i] = null; + _reentrantlyRemovedListeners++; + } else { + // When we are outside the notifyListeners iterations we can + // effectively shrink the list. + _removeAt(i); + } + break; + } + } + } + + /// Discards any resources used by the object. + /// + /// After this is called, the object is not in a usable state and should be + /// discarded (calls to [addListener] will throw after the object is disposed). + /// + /// This method should only be called by the object's owner. + /// + /// This method does not notify listeners, and clears the listener list once + /// it is called. Consumers of this class must decide on whether to notify + /// listeners or not immediately before disposal. + @mustCallSuper + void dispose() { + assert(ChangeNotifier.debugAssertNotDisposed(this)); + assert( + _notificationCallStackDepth == 0, + 'The "dispose()" method on $this was called during the call to ' + '"notifyListeners()". This is likely to cause errors since it modifies ' + 'the list of listeners while the list is being used.', + ); + assert(() { + _debugDisposed = true; + if (_debugCreationDispatched) { + Listenable.debugMaybeDispatchDisposed(this); + } + return true; + }()); + _listeners = _emptyListeners; + _count = 0; + } + + /// Call all the registered listeners. + /// + /// Call this method whenever the object changes, to notify any clients the + /// object may have changed. Listeners that are added during this iteration + /// will not be visited. Listeners that are removed during this iteration will + /// not be visited after they are removed. + /// + /// Exceptions thrown by listeners will be caught and reported using + /// [Listenable.onError]. + /// + /// This method must not be called after [dispose] has been called. + /// + /// Surprising behavior can result when reentrantly removing a listener (e.g. + /// in response to a notification) that has been registered multiple times. + /// See the discussion at [removeListener]. + @protected + @visibleForTesting + @pragma('vm:notify-debugger-on-exception') + void notifyListeners() { + assert(ChangeNotifier.debugAssertNotDisposed(this)); + if (_count == 0) { + return; + } + + // To make sure that listeners removed during this iteration are not called, + // we set them to null, but we don't shrink the list right away. + // By doing this, we can continue to iterate on our list until it reaches + // the last listener added before the call to this method. + + // To allow potential listeners to recursively call notifyListener, we track + // the number of times this method is called in _notificationCallStackDepth. + // Once every recursive iteration is finished (i.e. when _notificationCallStackDepth == 0), + // we can safely shrink our list so that it will only contain not null + // listeners. + + _notificationCallStackDepth++; + + final int end = _count; + for (var i = 0; i < end; i++) { + try { + _listeners[i]?.call(); + } catch (exception, stack) { + Listenable.onError(exception.toString(), stack); + } + } + + _notificationCallStackDepth--; + + if (_notificationCallStackDepth == 0 && _reentrantlyRemovedListeners > 0) { + // We really remove the listeners when all notifications are done. + final int newLength = _count - _reentrantlyRemovedListeners; + if (newLength * 2 <= _listeners.length) { + // As in _removeAt, we only shrink the list when the real number of + // listeners is half the length of our list. + final newListeners = List.filled(newLength, null); + + var newIndex = 0; + for (var i = 0; i < _count; i++) { + final VoidCallback? listener = _listeners[i]; + if (listener != null) { + newListeners[newIndex++] = listener; + } + } + + _listeners = newListeners; + } else { + // Otherwise we put all the null references at the end. + for (var i = 0; i < newLength; i += 1) { + if (_listeners[i] == null) { + // We swap this item with the next not null item. + int swapIndex = i + 1; + while (_listeners[swapIndex] == null) { + swapIndex += 1; + } + _listeners[i] = _listeners[swapIndex]; + _listeners[swapIndex] = null; + } + } + } + + _reentrantlyRemovedListeners = 0; + _count = newLength; + } + } +} + +class _MergingListenable extends Listenable { + _MergingListenable(this._children); + + final Iterable _children; + + @override + void addListener(VoidCallback listener) { + for (final Listenable? child in _children) { + child?.addListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + for (final Listenable? child in _children) { + child?.removeListener(listener); + } + } + + @override + String toString() { + return 'Listenable.merge([${_children.join(", ")}])'; + } +} + +/// A [ChangeNotifier] that holds a single value. +/// +/// When [value] is replaced with a new value that is **not equal** to the old +/// value as evaluated by the equality operator (`==`), this class notifies its +/// listeners. +/// +/// ## Limitations +/// +/// Notifications are triggered based on **equality (`==`)**, not on mutations +/// within the value itself. As a result, changes to mutable objects that do not +/// affect their equality will not cause listeners to be notified. +/// +/// For example, a `ValueNotifier>` will not notify listeners when +/// the contents of the existing list are modified in-place; it only notifies +/// when a new value is assigned to the `value` property (i.e. `value = newValue`), +/// where equality is determined by `==`. +/// +/// Because of this behavior, [ValueNotifier] is best used with immutable data +/// types. +/// +/// For mutable data types, consider extending [ChangeNotifier] directly and +/// calling [notifyListeners] manually when changes occur. +/// +/// {@example example/lib/value_notifier.dart} +class ValueNotifier extends ChangeNotifier implements ValueListenable { + /// Creates a [ChangeNotifier] that wraps this value. + ValueNotifier(this._value) { + assert(() { + ChangeNotifier.maybeDispatchObjectCreation(this); + return true; + }()); + } + + /// The current value stored in this notifier. + /// + /// When the value is replaced with something that is not equal to the old + /// value as evaluated by the equality operator ==, this class notifies its + /// listeners. + @override + T get value => _value; + T _value; + set value(T newValue) { + if (_value == newValue) { + return; + } + _value = newValue; + notifyListeners(); + } + + @override + String toString() => '${_describeIdentity(this)}($value)'; +} + +/// Returns a 5 character long hexadecimal string generated from +/// [Object.hashCode]'s 20 least-significant bits. +String _shortHash(Object? object) { + return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); +} + +/// Returns the runtime type of the given `object`.. +String _describeIdentity(Object? object) => + '${_objectRuntimeType(object, '')}#${_shortHash(object)}'; + +String _objectRuntimeType(Object? object, String optimizedValue) { + assert(() { + optimizedValue = object.runtimeType.toString(); + return true; + }()); + return optimizedValue; +} diff --git a/packages/listen/pubspec.yaml b/packages/listen/pubspec.yaml new file mode 100644 index 00000000..c9e39a6b --- /dev/null +++ b/packages/listen/pubspec.yaml @@ -0,0 +1,21 @@ +# Copyright 2013 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +name: listen +description: A package to notify state changes to interested listeners in pure Dart. +repository: https://github.com/flutter/core-packages/tree/main/packages/listen +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+listen%22 +version: 1.0.0-beta.1 + +environment: + sdk: ^3.10.0 + +dependencies: + meta: ^1.11.0 + +dev_dependencies: + test: ^1.25.9 + +topics: + - state-management + - listenable diff --git a/packages/listen/test/listen_test.dart b/packages/listen/test/listen_test.dart new file mode 100644 index 00000000..8acc6320 --- /dev/null +++ b/packages/listen/test/listen_test.dart @@ -0,0 +1,606 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:listen/listen.dart'; +import 'package:test/test.dart'; + +class TestNotifier extends ChangeNotifier { + void notify() { + notifyListeners(); + } + + bool get isListenedTo => hasListeners; +} + +class HasListenersTester extends ValueNotifier { + HasListenersTester(super.value); + bool get testHasListeners => hasListeners; +} + +class A { + bool result = false; + void test() { + result = true; + } +} + +class B extends A with ChangeNotifier { + @override + void test() { + notifyListeners(); + super.test(); + } +} + +class Counter with ChangeNotifier { + Counter() { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + + int get value => _value; + int _value = 0; + set value(int value) { + if (_value != value) { + _value = value; + notifyListeners(); + } + } + + void notify() { + notifyListeners(); + } +} + +void main() { + final ErrorCallback originalOnError = Listenable.onError; + String? lastErrorMessage; + + setUp(() { + lastErrorMessage = null; + Listenable.onError = (String message, StackTrace? stackTrace) { + lastErrorMessage = message; + }; + }); + tearDown(() { + Listenable.onError = originalOnError; + if (lastErrorMessage != null) { + throw StateError('Unexpected error in test: $lastErrorMessage'); + } + }); + + test('ChangeNotifier can not dispose in callback', () async { + final test = TestNotifier(); + var callbackDidFinish = false; + void foo() { + test.dispose(); + callbackDidFinish = true; + } + + test.addListener(foo); + + test.notify(); + + expect(lastErrorMessage, contains('dispose()')); + // Make sure it crashes during dispose call. + expect(callbackDidFinish, isFalse); + test.dispose(); + lastErrorMessage = null; + }); + + test('ChangeNotifier', () { + final log = []; + void listener() { + log.add('listener'); + } + + void listener1() { + log.add('listener1'); + } + + void listener2() { + log.add('listener2'); + } + + void badListener() { + log.add('badListener'); + throw ArgumentError(); + } + + final test = TestNotifier(); + + final ErrorCallback original = Listenable.onError; + final List errorMessages = []; + Listenable.onError = (String message, StackTrace? stackTrace) { + errorMessages.add(message); + }; + addTearDown(() { + Listenable.onError = original; + }); + + test.addListener(listener); + test.addListener(listener); + test.notify(); + expect(log, ['listener', 'listener']); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, ['listener']); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, []); + log.clear(); + + test.removeListener(listener); + test.notify(); + expect(log, []); + log.clear(); + + test.addListener(listener); + test.notify(); + expect(log, ['listener']); + log.clear(); + + test.addListener(listener1); + test.notify(); + expect(log, ['listener', 'listener1']); + log.clear(); + + test.addListener(listener2); + test.notify(); + expect(log, ['listener', 'listener1', 'listener2']); + log.clear(); + + test.removeListener(listener1); + test.notify(); + expect(log, ['listener', 'listener2']); + log.clear(); + + test.addListener(listener1); + test.notify(); + expect(log, ['listener', 'listener2', 'listener1']); + log.clear(); + + test.addListener(badListener); + test.notify(); + expect(log, ['listener', 'listener2', 'listener1', 'badListener']); + expect(errorMessages.removeAt(0), contains('Invalid argument')); + log.clear(); + + test.addListener(listener1); + test.removeListener(listener); + test.removeListener(listener1); + test.removeListener(listener2); + test.addListener(listener2); + test.notify(); + expect(log, ['badListener', 'listener1', 'listener2']); + expect(errorMessages.removeAt(0), contains('Invalid argument')); + log.clear(); + test.dispose(); + }); + + test('ChangeNotifier with mutating listener', () { + final test = TestNotifier(); + final log = []; + + void listener1() { + log.add('listener1'); + } + + void listener3() { + log.add('listener3'); + } + + void listener4() { + log.add('listener4'); + } + + void listener2() { + log.add('listener2'); + test.removeListener(listener1); + test.removeListener(listener3); + test.addListener(listener4); + } + + test.addListener(listener1); + test.addListener(listener2); + test.addListener(listener3); + test.notify(); + expect(log, ['listener1', 'listener2']); + log.clear(); + + test.notify(); + expect(log, ['listener2', 'listener4']); + log.clear(); + + test.notify(); + expect(log, ['listener2', 'listener4', 'listener4']); + log.clear(); + }); + + test('During notifyListeners, a listener was added and removed immediately', () { + final source = TestNotifier(); + final log = []; + + void listener3() { + log.add('listener3'); + } + + void listener2() { + log.add('listener2'); + } + + void listener1() { + log.add('listener1'); + source.addListener(listener2); + source.removeListener(listener2); + source.addListener(listener3); + } + + source.addListener(listener1); + + source.notify(); + + expect(log, ['listener1']); + }); + + test('If a listener in the middle of the list of listeners removes itself, ' + 'notifyListeners still notifies all listeners', () { + final source = TestNotifier(); + final log = []; + + void selfRemovingListener() { + log.add('selfRemovingListener'); + source.removeListener(selfRemovingListener); + } + + void listener1() { + log.add('listener1'); + } + + source.addListener(listener1); + source.addListener(selfRemovingListener); + source.addListener(listener1); + + source.notify(); + + expect(log, ['listener1', 'selfRemovingListener', 'listener1']); + }); + + test('If the first listener removes itself, notifyListeners still notify all listeners', () { + final source = TestNotifier(); + final log = []; + + void selfRemovingListener() { + log.add('selfRemovingListener'); + source.removeListener(selfRemovingListener); + } + + void listener1() { + log.add('listener1'); + } + + source.addListener(selfRemovingListener); + source.addListener(listener1); + + source.notifyListeners(); + + expect(log, ['selfRemovingListener', 'listener1']); + }); + + test('Merging change notifiers', () { + final source1 = TestNotifier(); + final source2 = TestNotifier(); + final source3 = TestNotifier(); + final log = []; + + final merged = Listenable.merge([source1, source2]); + void listener1() { + log.add('listener1'); + } + + void listener2() { + log.add('listener2'); + } + + merged.addListener(listener1); + source1.notify(); + source2.notify(); + source3.notify(); + expect(log, ['listener1', 'listener1']); + log.clear(); + + merged.removeListener(listener1); + source1.notify(); + source2.notify(); + source3.notify(); + expect(log, isEmpty); + log.clear(); + + merged.addListener(listener1); + merged.addListener(listener2); + source1.notify(); + source2.notify(); + source3.notify(); + expect(log, ['listener1', 'listener2', 'listener1', 'listener2']); + log.clear(); + }); + + test('Merging change notifiers supports any iterable', () { + final source1 = TestNotifier(); + final source2 = TestNotifier(); + final log = []; + + final merged = Listenable.merge({source1, source2}); + void listener() => log.add('listener'); + + merged.addListener(listener); + source1.notify(); + source2.notify(); + expect(log, ['listener', 'listener']); + log.clear(); + }); + + test('Merging change notifiers ignores null', () { + final source1 = TestNotifier(); + final source2 = TestNotifier(); + final log = []; + + final merged = Listenable.merge([null, source1, null, source2, null]); + void listener() { + log.add('listener'); + } + + merged.addListener(listener); + source1.notify(); + source2.notify(); + expect(log, ['listener', 'listener']); + log.clear(); + }); + + test('Can remove from merged notifier', () { + final source1 = TestNotifier(); + final source2 = TestNotifier(); + final log = []; + + final merged = Listenable.merge([source1, source2]); + void listener() { + log.add('listener'); + } + + merged.addListener(listener); + source1.notify(); + source2.notify(); + expect(log, ['listener', 'listener']); + log.clear(); + + merged.removeListener(listener); + source1.notify(); + source2.notify(); + expect(log, isEmpty); + }); + + test('Cannot use a disposed ChangeNotifier except for remove listener', () { + final source = TestNotifier(); + source.dispose(); + + source.addListener(() {}); + expect(lastErrorMessage, contains('TestNotifier was used after being disposed.')); + lastErrorMessage = null; + + source.dispose(); + expect(lastErrorMessage, contains('TestNotifier was used after being disposed.')); + lastErrorMessage = null; + + source.notify(); + expect(lastErrorMessage, contains('TestNotifier was used after being disposed.')); + lastErrorMessage = null; + }); + + test('Can remove listener on a disposed ChangeNotifier', () { + final source = TestNotifier(); + source.dispose(); + source.removeListener(() {}); + }); + + test('Can check hasListener on a disposed ChangeNotifier', () { + final source = HasListenersTester(0); + source.addListener(() {}); + expect(source.testHasListeners, isTrue); + source.dispose(); + expect(source.testHasListeners, isFalse); + }); + + test('Value notifier', () { + final notifier = ValueNotifier(2.0); + + final log = []; + void listener() { + log.add(notifier.value); + } + + notifier.addListener(listener); + notifier.value = 3.0; + + expect(log, equals([3.0])); + log.clear(); + + notifier.value = 3.0; + expect(log, isEmpty); + }); + + test('Listenable.merge toString', () { + final source1 = TestNotifier(); + final source2 = TestNotifier(); + + var listenableUnderTest = Listenable.merge([]); + expect(listenableUnderTest.toString(), 'Listenable.merge([])'); + + listenableUnderTest = Listenable.merge([null]); + expect(listenableUnderTest.toString(), 'Listenable.merge([null])'); + + listenableUnderTest = Listenable.merge([source1]); + expect(listenableUnderTest.toString(), "Listenable.merge([Instance of 'TestNotifier'])"); + + listenableUnderTest = Listenable.merge([source1, source2]); + expect( + listenableUnderTest.toString(), + "Listenable.merge([Instance of 'TestNotifier', Instance of 'TestNotifier'])", + ); + + listenableUnderTest = Listenable.merge([null, source2]); + expect(listenableUnderTest.toString(), "Listenable.merge([null, Instance of 'TestNotifier'])"); + }); + + test('Listenable.merge does not leak', () { + // Regression test for https://github.com/flutter/flutter/issues/25163. + + final source1 = TestNotifier(); + final source2 = TestNotifier(); + void fakeListener() {} + + final listenableUnderTest = Listenable.merge([source1, source2]); + expect(source1.isListenedTo, isFalse); + expect(source2.isListenedTo, isFalse); + listenableUnderTest.addListener(fakeListener); + expect(source1.isListenedTo, isTrue); + expect(source2.isListenedTo, isTrue); + + listenableUnderTest.removeListener(fakeListener); + expect(source1.isListenedTo, isFalse); + expect(source2.isListenedTo, isFalse); + }); + + test('hasListeners', () { + final notifier = HasListenersTester(true); + expect(notifier.testHasListeners, isFalse); + void test1() {} + void test2() {} + notifier.addListener(test1); + expect(notifier.testHasListeners, isTrue); + notifier.addListener(test1); + expect(notifier.testHasListeners, isTrue); + notifier.removeListener(test1); + expect(notifier.testHasListeners, isTrue); + notifier.removeListener(test1); + expect(notifier.testHasListeners, isFalse); + notifier.addListener(test1); + expect(notifier.testHasListeners, isTrue); + notifier.addListener(test2); + expect(notifier.testHasListeners, isTrue); + notifier.removeListener(test1); + expect(notifier.testHasListeners, isTrue); + notifier.removeListener(test2); + expect(notifier.testHasListeners, isFalse); + }); + + test('ChangeNotifier as a mixin', () { + // We document that this is a valid way to use this class. + final b = B(); + var notifications = 0; + b.addListener(() { + notifications += 1; + }); + expect(b.result, isFalse); + expect(notifications, 0); + b.test(); + expect(b.result, isTrue); + expect(notifications, 1); + }); + + test('Throws FlutterError when disposed and called', () { + final testNotifier = TestNotifier(); + testNotifier.dispose(); + + testNotifier.dispose(); + + expect(lastErrorMessage, contains('TestNotifier was used after being disposed.')); + lastErrorMessage = null; + }); + + test('Calling debugAssertNotDisposed works as intended', () { + final testNotifier = TestNotifier(); + expect(ChangeNotifier.debugAssertNotDisposed(testNotifier), isTrue); + testNotifier.dispose(); + + ChangeNotifier.debugAssertNotDisposed(testNotifier); + + expect(lastErrorMessage, contains('TestNotifier was used after being disposed.')); + lastErrorMessage = null; + }); + + test('notifyListener can be called recursively', () { + final counter = Counter(); + final log = []; + + void listener1() { + log.add('listener1'); + if (counter.value < 0) { + counter.value = 0; + } + } + + counter.addListener(listener1); + counter.notify(); + expect(log, ['listener1']); + log.clear(); + + counter.value = 3; + expect(log, ['listener1']); + log.clear(); + + counter.value = -2; + expect(log, ['listener1', 'listener1']); + log.clear(); + }); + + test('Remove Listeners while notifying on a list which will not resize', () { + final test = TestNotifier(); + final log = []; + final listeners = []; + + void autoRemove() { + // We remove 4 listeners. + // We will end up with (13-4 = 9) listeners. + test.removeListener(listeners[1]); + test.removeListener(listeners[3]); + test.removeListener(listeners[4]); + test.removeListener(autoRemove); + } + + test.addListener(autoRemove); + + // We add 12 more listeners. + for (var i = 0; i < 12; i++) { + void listener() { + log.add('listener$i'); + } + + listeners.add(listener); + test.addListener(listener); + } + + final remainingListenerIndexes = [0, 2, 5, 6, 7, 8, 9, 10, 11]; + final List expectedLog = remainingListenerIndexes.map((int i) => 'listener$i').toList(); + + test.notify(); + expect(log, expectedLog); + + log.clear(); + // We expect to have the same result after the removal of previous listeners. + test.notify(); + expect(log, expectedLog); + + // We remove all other listeners. + for (var i = 0; i < remainingListenerIndexes.length; i++) { + test.removeListener(listeners[remainingListenerIndexes[i]]); + } + + log.clear(); + test.notify(); + expect(log, []); + }); +}