From 6e7a20306a2607d46ad9d472bf2b36c489e95fa0 Mon Sep 17 00:00:00 2001 From: Yuri Prykhodko <40931732+solid-yuriiprykhodko@users.noreply.github.com> Date: Thu, 12 Aug 2021 09:54:45 +0300 Subject: [PATCH] v2.0.1 - restore BehaviorSubject behavior for `HydratedSubject` (#25) * Use BehaviorSubject internally * Clean up test messages, don't double-check listener behavior * Use expanded test reporter for CI (better logging) * Rename typing_utils.dart -> type_utils.dart * Add documentation note that the class behaves as `BehaviorSubject`. * Readability improvements * Remove unused completer, reformat * Fix value setter * Remove unused SubjectValueWrapper class * Update doc string * Update CHANGELOG.md, fix typo, bump version to 2.0.1 --- .github/workflows/flutter.yaml | 2 +- CHANGELOG.md | 6 +- lib/src/hydrated.dart | 83 ++++++------- lib/src/model/subject_value_wrapper.dart | 11 -- .../{typing_utils.dart => type_utils.dart} | 0 pubspec.yaml | 2 +- test/hydrated_test.dart | 115 +++++++++++------- 7 files changed, 115 insertions(+), 104 deletions(-) delete mode 100644 lib/src/model/subject_value_wrapper.dart rename lib/src/utils/{typing_utils.dart => type_utils.dart} (100%) diff --git a/.github/workflows/flutter.yaml b/.github/workflows/flutter.yaml index 602b53e..2c5dd51 100644 --- a/.github/workflows/flutter.yaml +++ b/.github/workflows/flutter.yaml @@ -13,4 +13,4 @@ jobs: - run: flutter pub get - run: flutter analyze - name: Run tests - run: flutter test \ No newline at end of file + run: flutter test -r expanded \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae359b..885255a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.1 + +- Restore `BehaviorSubject` behavior of `HydratedSubject`. + ## 2.0.0 - Add null-safety support. Credits to @solid-yuriiprykhodko and @solid-vovabeloded. @@ -14,7 +18,7 @@ ## 1.2.5 - Bump `rx_dart` version to `^0.22.0` -- This breaks compotibility with Dart SDK < 2.6 +- This breaks compatibility with Dart SDK < 2.6 - Simple CI added to repository ## 1.2.4 diff --git a/lib/src/hydrated.dart b/lib/src/hydrated.dart index 117c009..77a908b 100644 --- a/lib/src/hydrated.dart +++ b/lib/src/hydrated.dart @@ -1,12 +1,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:hydrated/src/utils/typing_utils.dart'; +import 'package:hydrated/src/utils/type_utils.dart'; import 'package:rxdart/rxdart.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'model/subject_value_wrapper.dart'; - /// A callback for encoding an instance of a data class into a String. typedef PersistCallback = String? Function(T); @@ -15,6 +13,8 @@ typedef HydrateCallback = T Function(String); /// A [Subject] that automatically persists its values and hydrates on creation. /// +/// Mimics the behavior of a [BehaviorSubject]. +/// /// HydratedSubject supports serialized classes and [shared_preferences] types /// such as: /// - `int` @@ -59,15 +59,12 @@ typedef HydrateCallback = T Function(String); /// ``` class HydratedSubject extends Subject implements ValueStream { static final _areTypesEqual = TypeUtils.areTypesEqual; - + final BehaviorSubject _subject; final String _key; final HydrateCallback? _hydrate; final PersistCallback? _persist; final VoidCallback? _onHydrate; final T? _seedValue; - final StreamController _controller; - - SubjectValueWrapper? _wrapper; HydratedSubject._( this._key, @@ -75,10 +72,8 @@ class HydratedSubject extends Subject implements ValueStream { this._hydrate, this._persist, this._onHydrate, - this._controller, - Stream observable, - this._wrapper, - ) : super(_controller, observable) { + this._subject, + ) : super(_subject, _subject.stream) { _hydrateSubject(); } @@ -107,28 +102,27 @@ class HydratedSubject extends Subject implements ValueStream { (hydrate != null && persist != null)); // ignore: close_sinks - final StreamController controller = StreamController.broadcast( - onListen: onListen, - onCancel: onCancel, - sync: sync, - ); - - final wrapper = - seedValue != null ? SubjectValueWrapper(value: seedValue) : null; + final subject = seedValue != null + ? BehaviorSubject.seeded( + seedValue, + onListen: onListen, + onCancel: onCancel, + sync: sync, + ) + : BehaviorSubject( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); return HydratedSubject._( - key, - seedValue, - hydrate, - persist, - onHydrate, - controller, - Rx.defer( - () => wrapper == null - ? controller.stream - : controller.stream.startWith(wrapper.value!), - reusable: true), - wrapper); + key, + seedValue, + hydrate, + persist, + onHydrate, + subject, + ); } /// A unique key that references a storage container @@ -137,7 +131,7 @@ class HydratedSubject extends Subject implements ValueStream { @override void onAdd(T event) { - _wrapper = SubjectValueWrapper(value: event); + _subject.add(event); _persistValue(event); } @@ -145,32 +139,29 @@ class HydratedSubject extends Subject implements ValueStream { ValueStream get stream => this; @override - bool get hasValue => _wrapper?.value != null; + bool get hasValue => _subject.hasValue; @override - T? get valueOrNull => _wrapper?.value; + T? get valueOrNull => _subject.valueOrNull; /// Get the latest value emitted by the Subject @override - T get value => - hasValue ? _wrapper!.value! : throw ValueStreamError.hasNoValue(); + T get value => _subject.value; /// Set and emit the new value - set value(T newValue) => add(newValue); + set value(T newValue) => add(value); @override - Object get error => hasError - ? _wrapper!.errorAndStackTrace! - : throw ValueStreamError.hasNoError(); + Object get error => _subject.error; @override - Object? get errorOrNull => _wrapper?.errorAndStackTrace; + Object? get errorOrNull => _subject.errorOrNull; @override - bool get hasError => _wrapper?.errorAndStackTrace != null; + bool get hasError => _subject.errorOrNull != null; @override - StackTrace? get stackTrace => _wrapper?.errorAndStackTrace?.stackTrace; + StackTrace? get stackTrace => _subject.stackTrace; /// Hydrates the HydratedSubject with a value stored on the user's device. /// @@ -204,8 +195,7 @@ class HydratedSubject extends Subject implements ValueStream { // do not hydrate if the store is empty or matches the seed value // TODO: allow writing of seedValue if it is intentional if (val != null && val != _seedValue) { - _wrapper = SubjectValueWrapper(value: val); - _controller.add(val); + _subject.add(val); } _onHydrate?.call(); @@ -238,8 +228,7 @@ class HydratedSubject extends Subject implements ValueStream { 'HydratedSubject – value must be int, ' 'double, bool, String, or List', ); - final errorAndTrace = ErrorAndStackTrace(error, StackTrace.current); - _wrapper = SubjectValueWrapper(errorAndStackTrace: errorAndTrace); + _subject.addError(error, StackTrace.current); } } diff --git a/lib/src/model/subject_value_wrapper.dart b/lib/src/model/subject_value_wrapper.dart deleted file mode 100644 index 2ffa359..0000000 --- a/lib/src/model/subject_value_wrapper.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:rxdart/rxdart.dart'; - -class SubjectValueWrapper { - final T? value; - final ErrorAndStackTrace? errorAndStackTrace; - - SubjectValueWrapper({ - this.value, - this.errorAndStackTrace, - }); -} diff --git a/lib/src/utils/typing_utils.dart b/lib/src/utils/type_utils.dart similarity index 100% rename from lib/src/utils/typing_utils.dart rename to lib/src/utils/type_utils.dart diff --git a/pubspec.yaml b/pubspec.yaml index 7d81383..79fb6c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: hydrated description: An automatically persisted BehaviorSubject with simple hydration for Flutter. Intended to be used with the BLoC pattern. -version: 2.0.0 +version: 2.0.1 homepage: https://github.com/solid-software/hydrated environment: diff --git a/test/hydrated_test.dart b/test/hydrated_test.dart index 074f145..6e2fce2 100644 --- a/test/hydrated_test.dart +++ b/test/hydrated_test.dart @@ -6,73 +6,102 @@ import 'package:hydrated/hydrated.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - SharedPreferences.setMockInitialValues({ - "flutter.prefs": true, - "flutter.int": 1, - "flutter.double": 1.1, - "flutter.bool": true, - "flutter.String": "first", - "flutter.List": ["a", "b"], - "flutter.SerializedClass": '{"value":true,"count":42}' + setUp(() { + SharedPreferences.setMockInitialValues({ + "flutter.prefs": true, + "flutter.int": 1, + "flutter.double": 1.1, + "flutter.bool": true, + "flutter.String": "first", + "flutter.List": ["a", "b"], + "flutter.SerializedClass": '{"value":true,"count":42}' + }); }); - test('shared preferences', () async { + test('Shared Preferences set mock initial values', () async { final prefs = await SharedPreferences.getInstance(); final value = prefs.getBool("prefs"); expect(value, isTrue); }); - test('int', () async { - await testHydrated("int", 1, 2); - }); + group('HydratedSubject', () { + group('correctly handles data type', () { + test('int', () async { + await testHydrated("int", 1, 2); + }); - test('double', () async { - await testHydrated("double", 1.1, 2.2); - }); + test('double', () async { + await testHydrated("double", 1.1, 2.2); + }); - test('bool', () async { - await testHydrated("bool", true, false); - }); + test('bool', () async { + await testHydrated("bool", true, false); + }); - test('String', () async { - await testHydrated("String", "first", "second"); - }); + test('String', () async { + await testHydrated("String", "first", "second"); + }); - test('List', () async { - testHydrated>("List", ["a", "b"], ["c", "d"]); - }); + test('List', () async { + testHydrated>("List", ["a", "b"], ["c", "d"]); + }); + + test('SerializedClass', () async { + final completer = Completer(); + + final subject = HydratedSubject( + "SerializedClass", + hydrate: (s) => SerializedClass.fromJSON(s), + persist: (c) => c.toJSON(), + onHydrate: () => completer.complete(), + ); - test('SerializedClass', () async { - final completer = Completer(); + final second = SerializedClass(false, 42); + /// null before hydrate + expect(subject.valueOrNull, isNull); + + /// properly hydrates + await completer.future; + expect(subject.value.value, isTrue); + expect(subject.value.count, equals(42)); + + /// add values + subject.add(second); + expect(subject.value.value, isFalse); + expect(subject.value.count, equals(42)); + + /// check value in store + final prefs = await SharedPreferences.getInstance(); + expect(prefs.get(subject.key), equals('{"value":false,"count":42}')); + + /// clean up + subject.close(); + }); + }); + }); + + test('HydratedSubject emits latest value into the new listener', () async { final subject = HydratedSubject( "SerializedClass", hydrate: (s) => SerializedClass.fromJSON(s), persist: (c) => c.toJSON(), - onHydrate: () => completer.complete(), ); - final second = SerializedClass(false, 42); - - /// null before hydrate - expect(subject.valueOrNull, isNull); + await subject.first; - /// properly hydrates - await completer.future; - expect(subject.value.value, isTrue); - expect(subject.value.count, equals(42)); + final expectation = expectLater( + subject.stream, + emitsInOrder([ + isA().having((c) => c.value, 'value', isTrue), + isA().having((c) => c.value, 'value', isFalse), + ])); - /// add values + final second = SerializedClass(false, 42); subject.add(second); - expect(subject.value.value, isFalse); - expect(subject.value.count, equals(42)); - - /// check value in store - final prefs = await SharedPreferences.getInstance(); - expect(prefs.get(subject.key), equals('{"value":false,"count":42}')); - /// clean up + await expectation; subject.close(); }); }