From 6d616e25869c8584b296cdb86136e513fbddfdc2 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 21 Oct 2026 15:51:16 +0100 Subject: [PATCH 1/3] feat: Expose flag values with their JSON type --- lib/src/core/model/flag.dart | 21 +++++++++-- test/core/models/flag_test.dart | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lib/src/core/model/flag.dart b/lib/src/core/model/flag.dart index 27fdd5a..d6b534f 100644 --- a/lib/src/core/model/flag.dart +++ b/lib/src/core/model/flag.dart @@ -17,6 +17,11 @@ class Flag { fromJson: stringFromJson, toJson: stringToJson) final String? stateValue; + + /// The flag's value with its JSON type: a String, num, bool or null. + /// Falls back to [stateValue] when the flag was not parsed from JSON. + @JsonKey(includeFromJson: false, includeToJson: false) + final Object? value; final bool? enabled; final int? environment; final int? identity; @@ -37,13 +42,15 @@ class Flag { {this.id, required this.feature, this.stateValue, + Object? value, this.enabled, this.environment, this.identity, this.featureSegment, this.variant, this.reason, - this.experiment}); + this.experiment}) + : value = value ?? stateValue; String get key => feature.name; @override @@ -57,6 +64,7 @@ class Flag { {int? id, required Feature feature, String? stateValue, + Object? value, bool? enabled, int? environment, int? identity, @@ -68,6 +76,7 @@ class Flag { id: id, feature: feature, stateValue: stateValue, + value: value, enabled: enabled, environment: environment, identity: identity, @@ -94,15 +103,19 @@ class Flag { enabled: enabled); } - factory Flag.fromJson(Map json) => _$FlagFromJson(json); + factory Flag.fromJson(Map json) => + _$FlagFromJson(json).copyWith(value: json['feature_state_value']); - Map toJson() => _$FlagToJson(this); + /// Writes [value] with its type, so that [Flag.fromJson] restores it. + Map toJson() => + _$FlagToJson(this)..['feature_state_value'] = value; String asString() => jsonEncode(toJson()); Flag copyWith( {int? id, Feature? feature, String? stateValue, + Object? value, bool? enabled, int? environment, int? identity, @@ -114,6 +127,8 @@ class Flag { id: id ?? this.id, feature: feature ?? this.feature, stateValue: stateValue ?? this.stateValue, + // A new stateValue without a value replaces the old value too. + value: value ?? (stateValue == null ? this.value : null), enabled: enabled ?? this.enabled, environment: environment ?? this.environment, identity: identity ?? this.identity, diff --git a/test/core/models/flag_test.dart b/test/core/models/flag_test.dart index ed705b7..19a094c 100644 --- a/test/core/models/flag_test.dart +++ b/test/core/models/flag_test.dart @@ -197,6 +197,71 @@ void main() { }); }); + group('[Typed value]', () { + Map flagJson(Object? value) => { + 'feature': {'id': 1, 'name': 'max_items'}, + 'enabled': true, + 'feature_state_value': value, + }; + + for (final (value, stateValue) in [ + (42, '42'), + (1.5, '1.5'), + (true, 'true'), + ('welcome', 'welcome'), + (null, null), + ]) { + test( + 'When feature_state_value is ${value.runtimeType}, then value ' + 'keeps its type', () { + final flag = Flag.fromJson(flagJson(value)); + expect(flag.value, value); + expect(flag.value.runtimeType, value.runtimeType); + expect(flag.stateValue, stateValue); + }); + + test( + 'When a ${value.runtimeType} flag round-trips through toJson, ' + 'then value keeps its type', () { + final restored = Flag.fromJson( + jsonDecode(Flag.fromJson(flagJson(value)).asString()) + as Map); + expect(restored.value, value); + expect(restored.value.runtimeType, value.runtimeType); + expect(restored.stateValue, stateValue); + }); + } + + test('When a flag is restored from storage, then value keeps its type', + () async { + final store = StorageProvider(InMemoryStorage(), password: 'pa5w0rD'); + await store.saveAll([Flag.fromJson(flagJson(42))]); + final restored = await store.read('max_items'); + expect(restored!.value, 42); + expect(restored.stateValue, '42'); + }); + + test('When cached before typed values, then value is the cached string', + () { + final flag = Flag.fromJson(flagJson('42')); + expect(flag.value, '42'); + expect(flag.stateValue, '42'); + }); + + test('When a flag is created without a value, then value is stateValue', + () { + expect(Flag.seed('feature', value: '1.0.0').value, '1.0.0'); + expect(Flag.seed('feature').value, isNull); + }); + + test('When copyWith sets only stateValue, then value follows it', () { + final flag = Flag.fromJson(flagJson(42)); + expect(flag.copyWith(stateValue: '43').value, '43'); + expect(flag.copyWith(enabled: false).value, 42); + expect(flag.copyWith(value: 43).value, 43); + }); + }); + group('[FlagAndTraits]', () { test('When response successfuly parsed', () { final identity = FlagsAndTraits.fromJson( From 5e161f917e71c6f4ea939db73e43c7501bd03f81 Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Wed, 21 Oct 2026 16:24:13 +0100 Subject: [PATCH 2/3] Update lib/src/core/model/flag.dart Co-authored-by: Kim Gustyr --- lib/src/core/model/flag.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/core/model/flag.dart b/lib/src/core/model/flag.dart index d6b534f..a7b1c3d 100644 --- a/lib/src/core/model/flag.dart +++ b/lib/src/core/model/flag.dart @@ -18,7 +18,7 @@ class Flag { toJson: stringToJson) final String? stateValue; - /// The flag's value with its JSON type: a String, num, bool or null. + /// The flag's value with its JSON type. /// Falls back to [stateValue] when the flag was not parsed from JSON. @JsonKey(includeFromJson: false, includeToJson: false) final Object? value; From 24be036a758ae1834bbc383fcbf3ebe73db926a4 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 21 Oct 2026 17:04:22 +0100 Subject: [PATCH 3/3] docs: Trim Flag.value comments --- lib/src/core/model/flag.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/core/model/flag.dart b/lib/src/core/model/flag.dart index a7b1c3d..ed83ba4 100644 --- a/lib/src/core/model/flag.dart +++ b/lib/src/core/model/flag.dart @@ -106,7 +106,6 @@ class Flag { factory Flag.fromJson(Map json) => _$FlagFromJson(json).copyWith(value: json['feature_state_value']); - /// Writes [value] with its type, so that [Flag.fromJson] restores it. Map toJson() => _$FlagToJson(this)..['feature_state_value'] = value; String asString() => jsonEncode(toJson()); @@ -127,7 +126,7 @@ class Flag { id: id ?? this.id, feature: feature ?? this.feature, stateValue: stateValue ?? this.stateValue, - // A new stateValue without a value replaces the old value too. + // A new stateValue also replaces value. value: value ?? (stateValue == null ? this.value : null), enabled: enabled ?? this.enabled, environment: environment ?? this.environment,