Skip to content

Commit

Permalink
v2.0.1 - restore BehaviorSubject behavior for HydratedSubject (#25)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
solid-yuriiprykhodko authored Aug 12, 2021
1 parent 70878bf commit 6e7a203
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 104 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/flutter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
- run: flutter pub get
- run: flutter analyze
- name: Run tests
run: flutter test
run: flutter test -r expanded
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
83 changes: 36 additions & 47 deletions lib/src/hydrated.dart
Original file line number Diff line number Diff line change
@@ -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<T> = String? Function(T);

Expand All @@ -15,6 +13,8 @@ typedef HydrateCallback<T> = 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`
Expand Down Expand Up @@ -59,26 +59,21 @@ typedef HydrateCallback<T> = T Function(String);
/// ```
class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
static final _areTypesEqual = TypeUtils.areTypesEqual;

final BehaviorSubject<T> _subject;
final String _key;
final HydrateCallback<T>? _hydrate;
final PersistCallback<T>? _persist;
final VoidCallback? _onHydrate;
final T? _seedValue;
final StreamController<T> _controller;

SubjectValueWrapper<T>? _wrapper;

HydratedSubject._(
this._key,
this._seedValue,
this._hydrate,
this._persist,
this._onHydrate,
this._controller,
Stream<T> observable,
this._wrapper,
) : super(_controller, observable) {
this._subject,
) : super(_subject, _subject.stream) {
_hydrateSubject();
}

Expand Down Expand Up @@ -107,28 +102,27 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
(hydrate != null && persist != null));

// ignore: close_sinks
final StreamController<T> controller = StreamController.broadcast(
onListen: onListen,
onCancel: onCancel,
sync: sync,
);

final wrapper =
seedValue != null ? SubjectValueWrapper(value: seedValue) : null;
final subject = seedValue != null
? BehaviorSubject<T>.seeded(
seedValue,
onListen: onListen,
onCancel: onCancel,
sync: sync,
)
: BehaviorSubject<T>(
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
Expand All @@ -137,40 +131,37 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {

@override
void onAdd(T event) {
_wrapper = SubjectValueWrapper(value: event);
_subject.add(event);
_persistValue(event);
}

@override
ValueStream<T> 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.
///
Expand Down Expand Up @@ -204,8 +195,7 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
// 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();
Expand Down Expand Up @@ -238,8 +228,7 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
'HydratedSubject – value must be int, '
'double, bool, String, or List<String>',
);
final errorAndTrace = ErrorAndStackTrace(error, StackTrace.current);
_wrapper = SubjectValueWrapper(errorAndStackTrace: errorAndTrace);
_subject.addError(error, StackTrace.current);
}
}

Expand Down
11 changes: 0 additions & 11 deletions lib/src/model/subject_value_wrapper.dart

This file was deleted.

File renamed without changes.
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
115 changes: 72 additions & 43 deletions test/hydrated_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>": ["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<String>": ["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>("int", 1, 2);
});
group('HydratedSubject', () {
group('correctly handles data type', () {
test('int', () async {
await testHydrated<int>("int", 1, 2);
});

test('double', () async {
await testHydrated<double>("double", 1.1, 2.2);
});
test('double', () async {
await testHydrated<double>("double", 1.1, 2.2);
});

test('bool', () async {
await testHydrated<bool>("bool", true, false);
});
test('bool', () async {
await testHydrated<bool>("bool", true, false);
});

test('String', () async {
await testHydrated<String>("String", "first", "second");
});
test('String', () async {
await testHydrated<String>("String", "first", "second");
});

test('List<String>', () async {
testHydrated<List<String>>("List<String>", ["a", "b"], ["c", "d"]);
});
test('List<String>', () async {
testHydrated<List<String>>("List<String>", ["a", "b"], ["c", "d"]);
});

test('SerializedClass', () async {
final completer = Completer();

final subject = HydratedSubject<SerializedClass>(
"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>(
"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<SerializedClass>().having((c) => c.value, 'value', isTrue),
isA<SerializedClass>().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();
});
}
Expand Down

0 comments on commit 6e7a203

Please sign in to comment.