Skip to content

Commit b31e6df

Browse files
feat: Add support for generic persistence interface (#27)
* Add support for a generic persistence interface * Add a different constructor for using a custom storage, revert breaking changes * Keep key, hydrate and persist callbacks in the HydratedSubject * Rename GenericValuePersistence -> KeyValueStore * Make KeyValueStore.put always accept nullables. * Consistently use local imports * Add missing newline * Restore `key` getter * Readability improvements * Add code docs * Bring in BehaviorSubject test suite from rxdart repo * Add HydratedSubject tests (WIP) * Remove the use of `!` ("bang") operator * More unit-tests for HydratedSubject * More persistence unit-tests * Ignore VSCode files and coverage dir * Remove unused class, fix typo * Remove unused imports * Rename SharedPreferencesPersistence -> SharedPreferencesStore * Rename PersistenceError -> StoreError * Finish the rename, use StoreError.unsupportedType, fix test names
1 parent 6e7a203 commit b31e6df

File tree

12 files changed

+1608
-240
lines changed

12 files changed

+1608
-240
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# The .vscode folder contains launch configuration and tasks you configure in
1919
# VS Code which you may wish to be included in version control, so this line
2020
# is commented out by default.
21-
#.vscode/
21+
.vscode/
2222

2323
# Flutter/Dart/Pub related
2424
**/doc/api/
@@ -29,6 +29,7 @@
2929
.pub-cache/
3030
.pub/
3131
build/
32+
coverage/
3233

3334
# Android related
3435
**/android/**/gradle-wrapper.jar

lib/hydrated.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
library hydrated;
22

33
export 'src/hydrated.dart';
4+
export 'src/key_value_store/key_value_store.dart';
5+
export 'src/key_value_store/store_error.dart';
6+
export 'src/key_value_store/shared_preferences_store.dart';

lib/src/hydrated.dart

Lines changed: 51 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import 'dart:async';
22

33
import 'package:flutter/foundation.dart';
4-
import 'package:hydrated/src/utils/type_utils.dart';
54
import 'package:rxdart/rxdart.dart';
6-
import 'package:shared_preferences/shared_preferences.dart';
5+
6+
import 'key_value_store/key_value_store.dart';
7+
import 'key_value_store/store_error.dart';
8+
import 'key_value_store/shared_preferences_store.dart';
79

810
/// A callback for encoding an instance of a data class into a String.
911
typedef PersistCallback<T> = String? Function(T);
@@ -15,21 +17,8 @@ typedef HydrateCallback<T> = T Function(String);
1517
///
1618
/// Mimics the behavior of a [BehaviorSubject].
1719
///
18-
/// HydratedSubject supports serialized classes and [shared_preferences] types
19-
/// such as:
20-
/// - `int`
21-
/// - `double`
22-
/// - `bool`
23-
/// - `String`
24-
/// - `List<String>`.
25-
///
26-
/// Serialized classes are supported by using the following `hydrate` and
27-
/// `persist` combination:
28-
///
29-
/// ```
30-
/// hydrate: (String)=>Class
31-
/// persist: (Class)=>String
32-
/// ```
20+
/// The set of supported classes depends on the [KeyValueStore] implementation.
21+
/// For a list of types supported by default see [SharedPreferencesStore].
3322
///
3423
/// Example:
3524
///
@@ -58,21 +47,27 @@ typedef HydrateCallback<T> = T Function(String);
5847
/// );
5948
/// ```
6049
class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
61-
static final _areTypesEqual = TypeUtils.areTypesEqual;
62-
final BehaviorSubject<T> _subject;
6350
final String _key;
6451
final HydrateCallback<T>? _hydrate;
6552
final PersistCallback<T>? _persist;
53+
final BehaviorSubject<T> _subject;
6654
final VoidCallback? _onHydrate;
6755
final T? _seedValue;
6856

57+
final KeyValueStore _persistence;
58+
59+
/// A unique key that references a storage container
60+
/// for a value persisted on the device.
61+
String get key => _key;
62+
6963
HydratedSubject._(
7064
this._key,
7165
this._seedValue,
7266
this._hydrate,
7367
this._persist,
7468
this._onHydrate,
7569
this._subject,
70+
this._persistence,
7671
) : super(_subject, _subject.stream) {
7772
_hydrateSubject();
7873
}
@@ -85,22 +80,13 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
8580
VoidCallback? onHydrate,
8681
VoidCallback? onListen,
8782
VoidCallback? onCancel,
88-
bool sync: false,
83+
bool sync = false,
84+
KeyValueStore keyValueStore = const SharedPreferencesStore(),
8985
}) {
90-
// assert that T is a type compatible with shared_preferences,
91-
// or that we have hydrate and persist mapping functions
92-
assert(_areTypesEqual<T, int>() ||
93-
_areTypesEqual<T, int?>() ||
94-
_areTypesEqual<T, double>() ||
95-
_areTypesEqual<T, double?>() ||
96-
_areTypesEqual<T, bool>() ||
97-
_areTypesEqual<T, bool?>() ||
98-
_areTypesEqual<T, String>() ||
99-
_areTypesEqual<T, String?>() ||
100-
_areTypesEqual<T, List<String>>() ||
101-
_areTypesEqual<T, List<String>?>() ||
102-
(hydrate != null && persist != null));
103-
86+
assert(
87+
(hydrate == null && persist == null) ||
88+
(hydrate != null && persist != null),
89+
'`hydrate` and `persist` callbacks must both be present.');
10490
// ignore: close_sinks
10591
final subject = seedValue != null
10692
? BehaviorSubject<T>.seeded(
@@ -122,16 +108,12 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
122108
persist,
123109
onHydrate,
124110
subject,
111+
keyValueStore,
125112
);
126113
}
127114

128-
/// A unique key that references a storage container
129-
/// for a value persisted on the device.
130-
String get key => _key;
131-
132115
@override
133116
void onAdd(T event) {
134-
_subject.add(event);
135117
_persistValue(event);
136118
}
137119

@@ -149,7 +131,7 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
149131
T get value => _subject.value;
150132

151133
/// Set and emit the new value
152-
set value(T newValue) => add(value);
134+
set value(T newValue) => add(newValue);
153135

154136
@override
155137
Object get error => _subject.error;
@@ -167,68 +149,43 @@ class HydratedSubject<T> extends Subject<T> implements ValueStream<T> {
167149
///
168150
/// Must be called to retrieve values stored on the device.
169151
Future<void> _hydrateSubject() async {
170-
final prefs = await SharedPreferences.getInstance();
171-
172-
T? val;
152+
try {
153+
T? val;
154+
final hydrate = _hydrate;
155+
if (hydrate != null) {
156+
final persistedValue = await _persistence.get<String>(_key);
157+
if (persistedValue != null) {
158+
val = hydrate(persistedValue);
159+
}
160+
} else {
161+
val = await _persistence.get<T?>(_key);
162+
}
173163

174-
if (_hydrate != null) {
175-
final String? persistedValue = prefs.getString(_key);
176-
if (persistedValue != null) {
177-
val = _hydrate!(persistedValue);
164+
// do not hydrate if the store is empty or matches the seed value
165+
// TODO: allow writing of seedValue if it is intentional
166+
if (val != null && val != _seedValue) {
167+
_subject.add(val);
178168
}
179-
} else if (_areTypesEqual<T, int>() || _areTypesEqual<T, int?>())
180-
val = prefs.getInt(_key) as T?;
181-
else if (_areTypesEqual<T, double>() || _areTypesEqual<T, double?>())
182-
val = prefs.getDouble(_key) as T?;
183-
else if (_areTypesEqual<T, bool>() || _areTypesEqual<T, bool?>())
184-
val = prefs.getBool(_key) as T?;
185-
else if (_areTypesEqual<T, String>() || _areTypesEqual<T, String?>())
186-
val = prefs.getString(_key) as T?;
187-
else if (_areTypesEqual<T, List<String>>() ||
188-
_areTypesEqual<T, List<String>?>())
189-
val = prefs.getStringList(_key) as T?;
190-
else
191-
Exception(
192-
'HydratedSubject – shared_preferences returned an invalid type',
193-
);
194-
195-
// do not hydrate if the store is empty or matches the seed value
196-
// TODO: allow writing of seedValue if it is intentional
197-
if (val != null && val != _seedValue) {
198-
_subject.add(val);
199-
}
200169

201-
_onHydrate?.call();
170+
_onHydrate?.call();
171+
} on StoreError catch (e, s) {
172+
addError(e, s);
173+
}
202174
}
203175

204176
void _persistValue(T val) async {
205-
final prefs = await SharedPreferences.getInstance();
206-
207-
if (val is int)
208-
await prefs.setInt(_key, val);
209-
else if (val is double)
210-
await prefs.setDouble(_key, val);
211-
else if (val is bool)
212-
await prefs.setBool(_key, val);
213-
else if (val is String)
214-
await prefs.setString(_key, val);
215-
else if (val is List<String>)
216-
await prefs.setStringList(_key, val);
217-
else if (val == null)
218-
prefs.remove(_key);
219-
else if (_persist != null) {
220-
final encoded = _persist!(val);
221-
if (encoded != null) {
222-
await prefs.setString(_key, encoded);
177+
try {
178+
final persist = _persist;
179+
var persistedVal;
180+
if (persist != null) {
181+
persistedVal = persist(val);
182+
await _persistence.put<String>(_key, persistedVal);
223183
} else {
224-
prefs.remove(_key);
184+
persistedVal = val;
185+
await _persistence.put<T>(_key, persistedVal);
225186
}
226-
} else {
227-
final error = Exception(
228-
'HydratedSubject – value must be int, '
229-
'double, bool, String, or List<String>',
230-
);
231-
_subject.addError(error, StackTrace.current);
187+
} on StoreError catch (e, s) {
188+
addError(e, s);
232189
}
233190
}
234191

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import 'store_error.dart';
2+
3+
/// A generic key-value persistence interface.
4+
abstract class KeyValueStore {
5+
/// Save a value to persistence.
6+
///
7+
/// Passing a `null` should clear the value for this [key].
8+
///
9+
/// Throw a [StoreError] if encountering a problem while persisting a value.
10+
Future<void> put<T>(String key, T? value);
11+
12+
/// Retrieve a value from persistence.
13+
///
14+
/// Throw a [StoreError] if encountering a problem while restoring a value from the storage.
15+
Future<T?> get<T>(String key);
16+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'package:shared_preferences/shared_preferences.dart';
2+
3+
import '../utils/type_utils.dart';
4+
import 'key_value_store.dart';
5+
import 'store_error.dart';
6+
7+
/// An adapter for [SharedPreferences] persistence.
8+
///
9+
/// Supported types:
10+
/// - `int`
11+
/// - `double`
12+
/// - `bool`
13+
/// - `String`
14+
/// - `List<String>`.
15+
class SharedPreferencesStore implements KeyValueStore {
16+
static final _areTypesEqual = TypeUtils.areTypesEqual;
17+
18+
const SharedPreferencesStore();
19+
20+
@override
21+
Future<T?> get<T>(String key) async {
22+
_ensureSupportedType<T>();
23+
final prefs = await _getPrefs();
24+
25+
T? val;
26+
27+
try {
28+
if (_areTypesEqual<T, int>() || _areTypesEqual<T, int?>())
29+
val = prefs.getInt(key) as T?;
30+
else if (_areTypesEqual<T, double>() || _areTypesEqual<T, double?>())
31+
val = prefs.getDouble(key) as T?;
32+
else if (_areTypesEqual<T, bool>() || _areTypesEqual<T, bool?>())
33+
val = prefs.getBool(key) as T?;
34+
else if (_areTypesEqual<T, String>() || _areTypesEqual<T, String?>())
35+
val = prefs.getString(key) as T?;
36+
else if (_areTypesEqual<T, List<String>>() ||
37+
_areTypesEqual<T, List<String>?>())
38+
val = prefs.getStringList(key) as T?;
39+
} catch (e) {
40+
throw StoreError(
41+
'Error retrieving value from SharedPreferences: $e',
42+
);
43+
}
44+
45+
return val;
46+
}
47+
48+
@override
49+
Future<void> put<T>(String key, T? value) async {
50+
_ensureSupportedType<T>();
51+
final prefs = await _getPrefs();
52+
53+
if (value == null)
54+
prefs.remove(key);
55+
else if (value is int)
56+
await prefs.setInt(key, value);
57+
else if (value is double)
58+
await prefs.setDouble(key, value);
59+
else if (value is bool)
60+
await prefs.setBool(key, value);
61+
else if (value is String)
62+
await prefs.setString(key, value);
63+
else if (value is List<String>) await prefs.setStringList(key, value);
64+
}
65+
66+
void _ensureSupportedType<T>() {
67+
if (_areTypesEqual<T, int>() ||
68+
_areTypesEqual<T, int?>() ||
69+
_areTypesEqual<T, double>() ||
70+
_areTypesEqual<T, double?>() ||
71+
_areTypesEqual<T, bool>() ||
72+
_areTypesEqual<T, bool?>() ||
73+
_areTypesEqual<T, String>() ||
74+
_areTypesEqual<T, String?>() ||
75+
_areTypesEqual<T, List<String>>() ||
76+
_areTypesEqual<T, List<String>?>()) {
77+
return;
78+
} else {
79+
throw StoreError.unsupportedType(
80+
'$T type is not supported by SharedPreferences.');
81+
}
82+
}
83+
84+
Future<SharedPreferences> _getPrefs() => SharedPreferences.getInstance();
85+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/// An error encountered when persisting a value, or restoring it from persistence.
2+
///
3+
/// This is probably a configuration error -- check the `KeyValueStore`
4+
/// implementation and `HydratedSubject` `persist` and `hydrate` callbacks
5+
/// for type compatibility.
6+
class StoreError extends Error {
7+
/// A description of an error.
8+
final String message;
9+
10+
/// A storage error with a [message] describing its details.
11+
StoreError(this.message);
12+
13+
/// A storage has encountered an unsupported type.
14+
StoreError.unsupportedType(String message)
15+
: message = 'Error storing an unsupported type: $message';
16+
}

lib/src/utils/type_utils.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1+
/// Utilities for working with Dart type system.
12
class TypeUtils {
3+
/// Check two types for equality.
4+
///
5+
/// Returns `true` if types match exactly, taking nullable types into account.
6+
///
7+
/// Example outputs:
8+
/// ```
9+
/// TypeUtils.areTypesEqual<int, int>() == true
10+
/// TypeUtils.areTypesEqual<int, int?>() == false
11+
/// ```
212
static bool areTypesEqual<T1, T2>() {
313
return T1 == T2;
414
}

0 commit comments

Comments
 (0)