diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8e333f..57032e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# [1.3.1] - Jun 7, 2022 +- Added Feature Flag Management based on OpenFeature specs. +- Added `FeatureBuilder` and `FeatureScope`. + # [1.3.0] - May 12, 2022 - Added `BridgeGatewayProvider` - Fixed issue with content-type being appended with charset. diff --git a/example/assets/flags.json b/example/assets/flags.json new file mode 100644 index 00000000..ceb1190d --- /dev/null +++ b/example/assets/flags.json @@ -0,0 +1,53 @@ +{ + "newTitle": { + "state": "disabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "iOS" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/example/lib/asset_feature_provider.dart b/example/lib/asset_feature_provider.dart new file mode 100644 index 00000000..e707b1a2 --- /dev/null +++ b/example/lib/asset_feature_provider.dart @@ -0,0 +1,10 @@ +import 'package:clean_framework/clean_framework_defaults.dart'; +import 'package:flutter/services.dart'; + +class AssetFeatureProvider extends JsonFeatureProvider { + Future load(String key) async { + final rawFlags = await rootBundle.loadString(key); + + feed(OpenFeatureFlags.fromJson(rawFlags)); + } +} diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index a2b7292d..1b2d9ae8 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -1,4 +1,6 @@ +import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework_example/routes.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class HomePage extends StatelessWidget { @@ -6,32 +8,97 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Example Features'), - ), - body: ListView( - padding: EdgeInsets.symmetric(vertical: 16), - children: [ - ListTile( - title: Text('Firebase'), - leading: Icon(Icons.local_fire_department_sharp), - onTap: () => router.to(Routes.lastLogin), + return FeatureBuilder( + flagKey: 'color', + valueType: FlagValueType.number, + defaultValue: 0xFF0000FF, + builder: (context, colorValue) { + return Scaffold( + appBar: AppBar( + title: Text('Example Features'), + backgroundColor: Color(colorValue), ), - const Divider(), - ListTile( - title: Text('GraphQL'), - leading: Icon(Icons.graphic_eq), - onTap: () => router.to(Routes.countries), - ), - const Divider(), - ListTile( - title: Text('Rest API'), - leading: Icon(Icons.sync_alt), - onTap: () => router.to(Routes.randomCat), + body: FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest,firebase,graphql', + evaluationContext: EvaluationContext( + {'platform': defaultTargetPlatform.name}, + ), + builder: (context, value) { + final enabledFeatures = value.split(','); + + return _ExampleFeature(enabledFeatures: enabledFeatures); + }, ), - ], - ), + ); + }, + ); + } +} + +class _ExampleFeature extends StatelessWidget { + const _ExampleFeature({required this.enabledFeatures}); + + final List enabledFeatures; + + @override + Widget build(BuildContext context) { + return ListView( + padding: EdgeInsets.symmetric(vertical: 16), + children: [ + _List( + enabled: enabledFeatures.contains('firebase'), + title: 'Firebase', + iconData: Icons.local_fire_department_sharp, + route: Routes.lastLogin, + ), + _List( + enabled: enabledFeatures.contains('graphql'), + title: 'GraphQL', + iconData: Icons.graphic_eq, + route: Routes.countries, + ), + _List( + enabled: enabledFeatures.contains('rest'), + title: 'Rest API', + iconData: Icons.sync_alt, + route: Routes.randomCat, + ), + ], + ); + } +} + +class _List extends StatelessWidget { + const _List({ + required this.enabled, + required this.title, + required this.iconData, + required this.route, + }); + + final bool enabled; + final String title; + final IconData iconData; + final Routes route; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(seconds: 2), + child: enabled + ? Column( + children: [ + ListTile( + title: Text(title), + leading: Icon(iconData), + onTap: () => router.to(route), + ), + Divider(), + ], + ) + : const SizedBox.shrink(), ); } } diff --git a/example/lib/main.dart b/example/lib/main.dart index ef5a5fb8..0c7602e6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,33 +1,43 @@ +import 'dart:developer'; + import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework_example/asset_feature_provider.dart'; import 'package:clean_framework_example/providers.dart'; import 'package:clean_framework_example/routes.dart'; import 'package:flutter/material.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); loadProviders(); + runApp(ExampleApp()); } class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { - return AppProvidersContainer( - providersContext: providersContext, - onBuild: (context, _) { - providersContext().read(featureStatesProvider.featuresMap).load({ - 'features': [ - {'name': 'last_login', 'state': 'ACTIVE'}, - ] - }); + return FeatureScope( + register: () => AssetFeatureProvider(), + loader: (featureProvider) async { + // To demonstrate the lazy update triggered by change in feature flags. + await Future.delayed(Duration(seconds: 2)); + await featureProvider.load('assets/flags.json'); + }, + onLoaded: () { + log('Feature Flags activated.'); }, - child: MaterialApp.router( - routeInformationParser: router.informationParser, - routerDelegate: router.delegate, - theme: ThemeData( - pageTransitionsTheme: PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - }, + child: AppProvidersContainer( + providersContext: providersContext, + onBuild: (context, _) {}, + child: MaterialApp.router( + routeInformationParser: router.informationParser, + routerDelegate: router.delegate, + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), ), ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1c82d6ce..b7888d6c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,8 +4,8 @@ version: 1.0.0 publish_to: 'none' environment: - sdk: '>=2.16.0 <3.0.0' - flutter: '>=1.17.0' + sdk: '>=2.17.0 <3.0.0' + flutter: '>=3.0.0' dependencies: flutter: @@ -25,3 +25,6 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/flags.json diff --git a/example/test/features/country/presentation/country_ui_test.dart b/example/test/features/country/presentation/country_ui_test.dart index cdbf0aaf..f7dc0319 100644 --- a/example/test/features/country/presentation/country_ui_test.dart +++ b/example/test/features/country/presentation/country_ui_test.dart @@ -7,6 +7,8 @@ import 'package:clean_framework_example/routes.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../home_page_test.dart'; + void main() { setupUITest(context: providersContext, router: router); @@ -89,6 +91,10 @@ void main() { uiTest( 'tapping on country tile should navigate to detail page', + parentBuilder: (child) => FeatureScope( + register: () => FakeJsonFeatureProvider(), + child: child, + ), verify: (tester) async { router.to(Routes.countries); await tester.pumpAndSettle(); diff --git a/example/test/home_page_test.dart b/example/test/home_page_test.dart index cef7d45c..9d7cb110 100644 --- a/example/test/home_page_test.dart +++ b/example/test/home_page_test.dart @@ -1,4 +1,5 @@ import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework/clean_framework_defaults.dart'; import 'package:clean_framework_example/features/country/presentation/country_ui.dart'; import 'package:clean_framework_example/features/last_login/presentation/last_login_ui.dart'; import 'package:clean_framework_example/features/random_cat/presentation/random_cat_ui.dart'; @@ -107,12 +108,73 @@ void main() { } Widget buildWidget(Widget widget) { - return AppProvidersContainer( - providersContext: providersContext, - onBuild: (_, __) {}, - child: MaterialApp.router( - routeInformationParser: router.informationParser, - routerDelegate: router.delegate, + return FeatureScope( + register: () => FakeJsonFeatureProvider(), + child: AppProvidersContainer( + providersContext: providersContext, + onBuild: (_, __) {}, + child: MaterialApp.router( + routeInformationParser: router.informationParser, + routerDelegate: router.delegate, + ), ), ); } + +class FakeJsonFeatureProvider extends JsonFeatureProvider { + FakeJsonFeatureProvider() { + feed(OpenFeatureFlags.fromJson('''{ + "newTitle": { + "state": "disabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "iOS" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +}''')); + } +} diff --git a/example/test/main_test.dart b/example/test/main_test.dart index 08441cfc..6ea41eca 100644 --- a/example/test/main_test.dart +++ b/example/test/main_test.dart @@ -18,7 +18,7 @@ void main() { await tester.pumpWidget( app.ExampleApp(), ); - await tester.pump(); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); // Uncomment this to see the widget tree on the console @@ -30,6 +30,6 @@ void main() { providersContext().read(featureStatesProvider.featuresMap); expect(featuresMap.defaultState, isA()); - expect(featuresMap.getStateFor(lastLoginFeature), FeatureState.active); + expect(featuresMap.getStateFor(lastLoginFeature), FeatureState.hidden); }); } diff --git a/lib/clean_framework.dart b/lib/clean_framework.dart index 550cdc2e..cc82d456 100644 --- a/lib/clean_framework.dart +++ b/lib/clean_framework.dart @@ -8,5 +8,7 @@ export 'package:clean_framework/src/feature_state/feature_mapper.dart'; export 'package:clean_framework/src/feature_state/feature_state_provider.dart'; export 'package:clean_framework/src/feature_state/feature_widget.dart'; export 'package:clean_framework/src/logger.dart'; +export 'package:clean_framework/src/open_feature/open_feature.dart'; export 'package:clean_framework/src/routing/app_router.dart'; +export 'package:clean_framework/src/widgets/widgets.dart'; export 'package:either_dart/either.dart'; diff --git a/lib/clean_framework_defaults.dart b/lib/clean_framework_defaults.dart index 0a37cd55..7fbbf8f3 100644 --- a/lib/clean_framework_defaults.dart +++ b/lib/clean_framework_defaults.dart @@ -1,17 +1,16 @@ /// Clean Framework Defaults library clean_framework_defaults; +export 'package:clean_framework/src/defaults/feature_provider/json_feature_provider.dart'; export 'package:clean_framework/src/defaults/feature_state/feature_state.dart'; - +export 'package:clean_framework/src/defaults/network_service.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_client.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_external_interface.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_gateway.dart'; -export 'package:clean_framework/src/defaults/providers/firebase/firebase_watcher_gateway.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_requests.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_responses.dart'; +export 'package:clean_framework/src/defaults/providers/firebase/firebase_watcher_gateway.dart'; export 'package:clean_framework/src/defaults/providers/graphql/graphql.dart'; export 'package:clean_framework/src/defaults/providers/graphql/src/graphql_service.dart'; export 'package:clean_framework/src/defaults/providers/rest/rest.dart'; export 'package:clean_framework/src/defaults/providers/rest/src/rest_service.dart'; - -export 'package:clean_framework/src/defaults/network_service.dart'; diff --git a/lib/src/defaults/feature_provider/engine/evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/evaluation_engine.dart new file mode 100644 index 00000000..164ca500 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/evaluation_engine.dart @@ -0,0 +1,12 @@ +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'open_feature_flags.dart'; + +abstract class EvaluationEngine { + ResolutionDetails evaluate({ + required OpenFeatureFlags flags, + required String flagKey, + required FlagValueType returnType, + required EvaluationContext context, + }); +} diff --git a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart new file mode 100644 index 00000000..3f6a3af3 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart @@ -0,0 +1,76 @@ +// coverage:ignore-file + +import 'package:clean_framework/src/defaults/feature_provider/engine/evaluation_engine.dart'; +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'open_feature_flags.dart'; + +class JsonEvaluationEngine implements EvaluationEngine { + const JsonEvaluationEngine(); + + @override + ResolutionDetails evaluate({ + required OpenFeatureFlags flags, + required String flagKey, + required FlagValueType returnType, + required EvaluationContext context, + }) { + final flag = flags[flagKey]; + + if (flag == null || flag.state != FlagState.enabled) { + throw FlagNotFoundException('$flagKey not found.'); + } else if (flag.returnType != returnType) { + throw TypeMismatchException( + 'Flag value $flagKey had unexpected type ${flag.returnType?.name}, expected $returnType', + ); + } + + try { + final matchedRule = flag.rules.firstWhere( + (rule) { + for (final condition in rule.conditions) { + final value = context[condition.context]; + final conditionValue = condition.value; + + if (condition.op == 'equals') { + return value == conditionValue; + } + + if (value is String && conditionValue is String) { + if (condition.op == 'starts_with') { + return value.startsWith(conditionValue); + } else if (condition.op == 'ends_with') { + return value.endsWith(conditionValue); + } + } + + if (value is String && conditionValue is Iterable) { + if (condition.op == 'ends_with') { + for (final checkValue in conditionValue) { + if (value.contains(checkValue)) return true; + } + return false; + } + } + } + + return false; + }, + ); + + final variant = matchedRule.action.variant; + + return ResolutionDetails( + value: flag.variants[variant], + variant: variant, + reason: Reason.targetingMatch, + ); + } on StateError { + return ResolutionDetails( + value: flag.variants[flag.defaultVariant], + variant: flag.defaultVariant, + reason: Reason.noop, + ); + } + } +} diff --git a/lib/src/defaults/feature_provider/engine/open_feature_flags.dart b/lib/src/defaults/feature_provider/engine/open_feature_flags.dart new file mode 100644 index 00000000..6d801e82 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/open_feature_flags.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +enum FlagState { enabled, disabled } + +class OpenFeatureFlags { + OpenFeatureFlags._(this._flags); + + final Map _flags; + + factory OpenFeatureFlags.fromJson(String json) { + return OpenFeatureFlags.fromMap(jsonDecode(json)); + } + + factory OpenFeatureFlags.fromMap(Map map) { + return OpenFeatureFlags._( + map.map((k, v) => MapEntry(k, FeatureFlag.fromMap(v))), + ); + } + + FeatureFlag? operator [](String key) => _flags[key]; +} + +class FeatureFlag { + FeatureFlag({ + required this.state, + this.name, + this.description, + this.returnType, + this.variants = const {}, + this.defaultVariant, + this.rules = const [], + }); + + final FlagState state; + final String? name; + final String? description; + final FlagValueType? returnType; + final Map variants; + final String? defaultVariant; + final List rules; + + factory FeatureFlag.fromMap(Map map) { + final returnType = map['returnType']; + + return FeatureFlag( + state: FlagState.values.byName(map['state']), + name: map['name'], + description: map['description'], + returnType: + returnType == null ? null : FlagValueType.values.byName(returnType), + variants: map['variants'] ?? {}, + defaultVariant: map['defaultVariant'], + rules: List.from(map['rules'] ?? []) + .map((rule) => FlagRule.fromMap(rule)) + .toList(growable: false), + ); + } +} + +class FlagRule { + FlagRule({ + required this.action, + required this.conditions, + }); + + final RuleAction action; + final List conditions; + + factory FlagRule.fromMap(Map map) { + return FlagRule( + action: RuleAction.fromMap(map['action']), + conditions: List.from(map['conditions']) + .map((c) => RuleCondition.fromMap(c)) + .toList(growable: false), + ); + } +} + +class RuleAction { + RuleAction({ + required this.variant, + }); + + final String variant; + + factory RuleAction.fromMap(Map map) { + return RuleAction( + variant: map['variant'], + ); + } +} + +class RuleCondition { + RuleCondition({ + required this.context, + required this.op, + required this.value, + }); + + final String context; + final String op; + final Object value; + + factory RuleCondition.fromMap(Map map) { + return RuleCondition( + context: map['context'], + op: map['op'], + value: map['value'], + ); + } +} diff --git a/lib/src/defaults/feature_provider/json_feature_provider.dart b/lib/src/defaults/feature_provider/json_feature_provider.dart new file mode 100644 index 00000000..76fa0e45 --- /dev/null +++ b/lib/src/defaults/feature_provider/json_feature_provider.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:clean_framework/src/defaults/feature_provider/engine/evaluation_engine.dart'; +import 'package:clean_framework/src/defaults/feature_provider/engine/json_evaluation_engine.dart'; +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'engine/open_feature_flags.dart'; + +export 'engine/evaluation_engine.dart'; +export 'engine/open_feature_flags.dart'; + +class JsonFeatureProvider implements FeatureProvider { + JsonFeatureProvider({ + EvaluationEngine engine = const JsonEvaluationEngine(), + }) : _engine = engine; + + final EvaluationEngine _engine; + final Completer _flagsCompleter = Completer(); + + // coverage:ignore-start + @override + String get name => 'json'; + // coverage:ignore-end + + @override + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.boolean, + context: context, + ); + } + + @override + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.number, + context: context, + ); + } + + @override + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.string, + context: context, + ); + } + + @override + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.object, + context: context, + ); + } + + void feed(OpenFeatureFlags flags) { + _flagsCompleter.complete(flags); + } +} diff --git a/lib/src/open_feature/open_feature.dart b/lib/src/open_feature/open_feature.dart new file mode 100644 index 00000000..f5147107 --- /dev/null +++ b/lib/src/open_feature/open_feature.dart @@ -0,0 +1,69 @@ +// coverage:ignore-file +library open_feature; + +import 'src/core/feature_client.dart'; +import 'src/evaluation_context/evaluation_context.dart'; +import 'src/hook/hook.dart'; +import 'src/open_feature_client.dart'; +import 'src/provider/feature_provider.dart'; +import 'src/provider/no_op_feature_provider.dart'; + +export 'src/core/enums.dart'; +export 'src/core/feature_client.dart'; +export 'src/core/resolution_details.dart'; +export 'src/evaluation_context/context_transformation_mixin.dart'; +export 'src/evaluation_context/evaluation_context.dart'; +export 'src/exceptions/open_feature_exception.dart'; +export 'src/flag_evaluation/flag_evaluation_options.dart'; +export 'src/hook/hook.dart'; +export 'src/provider/feature_provider.dart'; + +class OpenFeature { + OpenFeature._({ + this.provider = const NoOpFeatureProvider(), + EvaluationContext? context, + }) : context = context ?? EvaluationContext.empty(); + + final FeatureProvider provider; + final List _hooks = []; + final EvaluationContext context; + + static OpenFeature? _instance; + + static OpenFeature get instance { + return _instance ??= OpenFeature._(); + } + + FeatureClient getClient({ + String? name, + String? version, + }) { + return OpenFeatureClient( + options: OpenFeatureClientOptions(name: name, version: version), + ); + } + + void addHooks(List hooks) => _hooks.addAll(hooks); + + void clearHooks() => _hooks.clear(); + + List get hooks => List.unmodifiable(_hooks); + + set provider(FeatureProvider provider) { + _instance = _instance?._copyWith(provider: provider); + } + + set context(EvaluationContext context) { + _instance = _instance?._copyWith(context: context); + } + + OpenFeature _copyWith({ + FeatureProvider? provider, + EvaluationContext? context, + }) { + return OpenFeature._( + provider: provider ?? this.provider, + context: context ?? this.context, + ); + } +} diff --git a/lib/src/open_feature/src/core/enums.dart b/lib/src/open_feature/src/core/enums.dart new file mode 100644 index 00000000..ba263e74 --- /dev/null +++ b/lib/src/open_feature/src/core/enums.dart @@ -0,0 +1,3 @@ +export 'enums/error_code.dart'; +export 'enums/flag_value_type.dart'; +export 'enums/reason.dart'; diff --git a/lib/src/open_feature/src/core/enums/error_code.dart b/lib/src/open_feature/src/core/enums/error_code.dart new file mode 100644 index 00000000..1ccdcf82 --- /dev/null +++ b/lib/src/open_feature/src/core/enums/error_code.dart @@ -0,0 +1,13 @@ +// coverage:ignore-file + +enum ErrorCode { + providerNotReady('PROVIDER_NOT_READY'), + flagNotFound('FLAG_NOT_FOUND'), + parseError('PARSE_ERROR'), + typeMismatch('TYPE_MISMATCH'), + general('GENERAL'); + + const ErrorCode(this.rawCode); + + final String rawCode; +} diff --git a/lib/src/open_feature/src/core/enums/flag_value_type.dart b/lib/src/open_feature/src/core/enums/flag_value_type.dart new file mode 100644 index 00000000..3a87ee5c --- /dev/null +++ b/lib/src/open_feature/src/core/enums/flag_value_type.dart @@ -0,0 +1,6 @@ +enum FlagValueType { + boolean, + string, + number, + object, +} diff --git a/lib/src/open_feature/src/core/enums/reason.dart b/lib/src/open_feature/src/core/enums/reason.dart new file mode 100644 index 00000000..a7e7daf8 --- /dev/null +++ b/lib/src/open_feature/src/core/enums/reason.dart @@ -0,0 +1,8 @@ +enum Reason { + disabled, + split, + targetingMatch, + unknown, + error, + noop, +} diff --git a/lib/src/open_feature/src/core/feature_client.dart b/lib/src/open_feature/src/core/feature_client.dart new file mode 100644 index 00000000..a8c77507 --- /dev/null +++ b/lib/src/open_feature/src/core/feature_client.dart @@ -0,0 +1,5 @@ +import 'flag_evaluation_lifecycle.dart'; +import 'features.dart'; + +abstract class FeatureClient extends Features + implements FlagEvaluationLifecycle {} diff --git a/lib/src/open_feature/src/core/features.dart b/lib/src/open_feature/src/core/features.dart new file mode 100644 index 00000000..824f01e0 --- /dev/null +++ b/lib/src/open_feature/src/core/features.dart @@ -0,0 +1,104 @@ +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_details.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; + +abstract class Features { + Future> getBooleanDetails({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getStringDetails({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getNumberDetails({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getDetails({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future getBooleanValue({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getBooleanDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getStringValue({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getStringDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getNumberValue({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getNumberDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getValue({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future _evaluatedValue( + Future> detailsFuture, + ) async { + final details = await detailsFuture; + return details.value; + } +} diff --git a/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart b/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart new file mode 100644 index 00000000..43dc73ad --- /dev/null +++ b/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart @@ -0,0 +1,9 @@ +import '../hook/hook.dart'; + +abstract class FlagEvaluationLifecycle { + List get hooks; + + void addHooks(List hooks); + + void clearHooks(); +} diff --git a/lib/src/open_feature/src/core/resolution_details.dart b/lib/src/open_feature/src/core/resolution_details.dart new file mode 100644 index 00000000..5851d47f --- /dev/null +++ b/lib/src/open_feature/src/core/resolution_details.dart @@ -0,0 +1,15 @@ +import 'enums.dart'; + +class ResolutionDetails { + ResolutionDetails({ + required this.value, + this.errorCode, + this.reason, + this.variant, + }); + + final T value; + final ErrorCode? errorCode; + final Reason? reason; + final String? variant; +} diff --git a/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart b/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart new file mode 100644 index 00000000..a1ba3a71 --- /dev/null +++ b/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart @@ -0,0 +1,8 @@ +import 'dart:async'; + +import '../provider/feature_provider.dart'; +import 'evaluation_context.dart'; + +mixin ContextTransformationMixin on FeatureProvider { + FutureOr transformContext(EvaluationContext context); +} diff --git a/lib/src/open_feature/src/evaluation_context/evaluation_context.dart b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart new file mode 100644 index 00000000..28e041f3 --- /dev/null +++ b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart @@ -0,0 +1,43 @@ +import 'dart:collection'; + +class EvaluationContext extends MapMixin { + EvaluationContext( + Map map, { + this.targetingKey, + }) : _map = map; + + factory EvaluationContext.empty({String? targetingKey}) { + return EvaluationContext(targetingKey: targetingKey, {}); + } + + final String? targetingKey; + final Map _map; + + // coverage:ignore-start + @override + Object? operator [](Object? key) => _map[key]; + + @override + void operator []=(String key, Object value) { + _map[key] = value; + } + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + Object? remove(Object? key) => _map.remove(key); + // coverage:ignore-end + + EvaluationContext merge(EvaluationContext? other) { + if (other == null) return this; + + return EvaluationContext( + targetingKey: targetingKey ?? other.targetingKey, + {..._map, ...other._map}, + ); + } +} diff --git a/lib/src/open_feature/src/exceptions/open_feature_exception.dart b/lib/src/open_feature/src/exceptions/open_feature_exception.dart new file mode 100644 index 00000000..52cf94f5 --- /dev/null +++ b/lib/src/open_feature/src/exceptions/open_feature_exception.dart @@ -0,0 +1,15 @@ +// coverage:ignore-file + +import '../core/enums/error_code.dart'; + +export '../core/enums/error_code.dart'; +export 'src/flag_not_found_exception.dart'; +export 'src/parse_exception.dart'; +export 'src/type_mismatch_exception.dart'; + +abstract class OpenFeatureException { + OpenFeatureException(this.message, {required this.code}); + + final String message; + final ErrorCode code; +} diff --git a/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart new file mode 100644 index 00000000..5d80fecd --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart @@ -0,0 +1,7 @@ +// coverage:ignore-file + +import '../open_feature_exception.dart'; + +class FlagNotFoundException extends OpenFeatureException { + FlagNotFoundException(super.message) : super(code: ErrorCode.flagNotFound); +} diff --git a/lib/src/open_feature/src/exceptions/src/parse_exception.dart b/lib/src/open_feature/src/exceptions/src/parse_exception.dart new file mode 100644 index 00000000..9d59e6a4 --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/parse_exception.dart @@ -0,0 +1,7 @@ +// coverage:ignore-file + +import '../open_feature_exception.dart'; + +class ParseException extends OpenFeatureException { + ParseException(super.message) : super(code: ErrorCode.flagNotFound); +} diff --git a/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart new file mode 100644 index 00000000..cfe0b8b6 --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart @@ -0,0 +1,7 @@ +// coverage:ignore-file + +import '../open_feature_exception.dart'; + +class TypeMismatchException extends OpenFeatureException { + TypeMismatchException(super.message) : super(code: ErrorCode.typeMismatch); +} diff --git a/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart new file mode 100644 index 00000000..76039bfb --- /dev/null +++ b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart @@ -0,0 +1,17 @@ +import '../core/enums.dart'; + +class FlagEvaluationDetails { + FlagEvaluationDetails({ + required this.key, + required this.value, + this.errorCode, + this.reason, + this.variant, + }); + + final String key; + final T value; + final ErrorCode? errorCode; + final Reason? reason; + final String? variant; +} diff --git a/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart new file mode 100644 index 00000000..c869f97d --- /dev/null +++ b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart @@ -0,0 +1,11 @@ +import '../hook/hook.dart'; + +class FlagEvaluationOptions { + const FlagEvaluationOptions({ + this.hooks = const [], + this.hookHints, + }); + + final List hooks; + final HookHints? hookHints; +} diff --git a/lib/src/open_feature/src/hook/hook.dart b/lib/src/open_feature/src/hook/hook.dart new file mode 100644 index 00000000..fb1d3233 --- /dev/null +++ b/lib/src/open_feature/src/hook/hook.dart @@ -0,0 +1,37 @@ +// coverage:ignore-file + +import 'dart:async'; + +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_details.dart'; +import 'hook_context.dart'; +import 'hook_hints.dart'; + +export 'hook_context.dart'; +export 'hook_hints.dart'; + +abstract class Hook { + FutureOr before({ + required HookContext context, + HookHints? hints, + }) async { + return null; + } + + FutureOr after({ + required HookContext context, + required FlagEvaluationDetails details, + HookHints? hints, + }) {} + + FutureOr error({ + required HookContext context, + required Object error, + HookHints? hints, + }) {} + + FutureOr finallyAfter({ + required HookContext context, + HookHints? hints, + }) {} +} diff --git a/lib/src/open_feature/src/hook/hook_context.dart b/lib/src/open_feature/src/hook/hook_context.dart new file mode 100644 index 00000000..e91810c8 --- /dev/null +++ b/lib/src/open_feature/src/hook/hook_context.dart @@ -0,0 +1,36 @@ +// coverage:ignore-file + +import '../core/enums/flag_value_type.dart'; +import '../core/feature_client.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../provider/feature_provider.dart'; + +class HookContext { + HookContext({ + required this.flagKey, + required this.flagType, + required this.context, + required this.defaultValue, + required this.client, + required this.provider, + }); + + final String flagKey; + final FlagValueType flagType; + final EvaluationContext context; + final T defaultValue; + + final FeatureClient client; + final FeatureProvider provider; + + HookContext apply({required EvaluationContext context}) { + return HookContext( + flagKey: flagKey, + flagType: flagType, + context: context, + defaultValue: defaultValue, + client: client, + provider: provider, + ); + } +} diff --git a/lib/src/open_feature/src/hook/hook_hints.dart b/lib/src/open_feature/src/hook/hook_hints.dart new file mode 100644 index 00000000..f70f0540 --- /dev/null +++ b/lib/src/open_feature/src/hook/hook_hints.dart @@ -0,0 +1,15 @@ +// coverage:ignore-file + +import 'dart:collection'; + +class HookHints extends UnmodifiableMapBase { + HookHints(this._map); + + final Map _map; + + @override + Iterable get keys => _map.keys; + + @override + T? operator [](Object? key) => _map[key]; +} diff --git a/lib/src/open_feature/src/open_feature_client.dart b/lib/src/open_feature/src/open_feature_client.dart new file mode 100644 index 00000000..25efef39 --- /dev/null +++ b/lib/src/open_feature/src/open_feature_client.dart @@ -0,0 +1,251 @@ +// coverage:ignore-file + +import '../open_feature.dart'; +import 'flag_evaluation/flag_evaluation_details.dart'; + +typedef FeatureProviderResolver = Future> + Function({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, +}); + +class OpenFeatureClientOptions { + OpenFeatureClientOptions({ + this.name, + this.version, + }); + + final String? name; + final String? version; +} + +class OpenFeatureClient extends FeatureClient { + OpenFeatureClient({ + required OpenFeatureClientOptions options, + }) : name = options.name, + version = options.version; + + final String? name; + final String? version; + + final List _hooks = []; + + @override + List> get hooks => List.unmodifiable(_hooks); + + @override + void addHooks(List> hooks) { + _hooks.addAll(hooks); + } + + @override + void clearHooks() => _hooks.clear(); + + @override + Future> getBooleanDetails({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.boolean, + resolver: _provider.resolveBooleanValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getStringDetails({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.string, + resolver: _provider.resolveStringValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getNumberDetails({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.number, + resolver: _provider.resolveNumberValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getDetails({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.object, + resolver: _provider.resolveValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + Future> evaluate({ + required String flagKey, + required FlagValueType flagType, + required FeatureProviderResolver resolver, + required T defaultValue, + required EvaluationContext? context, + required FlagEvaluationOptions? options, + }) async { + final mergedHooks = [ + ...OpenFeature.instance.hooks, + ...hooks, + if (options != null) ...options.hooks, + ]; + final mergedHooksReversed = mergedHooks.reversed.toList(growable: false); + + final hookContext = HookContext( + flagKey: flagKey, + flagType: flagType, + context: OpenFeature.instance.context.merge(context), + defaultValue: defaultValue, + client: this, + provider: _provider, + ); + + try { + var mergedContext = await _beforeHooks( + mergedHooks, + hookContext, + options, + ); + mergedContext = mergedContext.merge(context); + + final provider = _provider; + final transformedContext = provider is ContextTransformationMixin + ? await provider.transformContext(mergedContext) + : mergedContext; + + final resolution = await resolver( + flagKey: flagKey, + defaultValue: defaultValue, + context: transformedContext, + options: options ?? const FlagEvaluationOptions(), + ); + + final evaluationDetails = FlagEvaluationDetails( + key: flagKey, + value: resolution.value, + reason: resolution.reason, + errorCode: resolution.errorCode, + variant: resolution.variant, + ); + + await _afterHooks( + mergedHooksReversed, + hookContext, + evaluationDetails, + options, + ); + + return evaluationDetails; + } on OpenFeatureException catch (e) { + await _errorHooks(mergedHooksReversed, hookContext, e, options); + + return FlagEvaluationDetails( + key: flagKey, + value: defaultValue, + reason: Reason.error, + errorCode: e.code, + ); + } finally { + await _finallyHooks(mergedHooksReversed, hookContext, options); + } + } + + Future _beforeHooks( + List hooks, + HookContext hookContext, + FlagEvaluationOptions? options, + ) async { + var context = hookContext.context; + + for (final hook in hooks) { + final evalContext = await hook.before( + context: hookContext, + hints: options?.hookHints, + ); + + context = context.merge(evalContext); + } + + return context; + } + + Future _afterHooks( + List hooks, + HookContext hookContext, + FlagEvaluationDetails evaluationDetails, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.after( + context: hookContext, + hints: options?.hookHints, + details: evaluationDetails, + ); + } + } + + Future _errorHooks( + List hooks, + HookContext hookContext, + Object error, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.error( + context: hookContext, + hints: options?.hookHints, + error: error, + ); + } + } + + Future _finallyHooks( + List hooks, + HookContext hookContext, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.finallyAfter( + context: hookContext, + hints: options?.hookHints, + ); + } + } + + FeatureProvider get _provider => OpenFeature.instance.provider; +} diff --git a/lib/src/open_feature/src/provider/feature_provider.dart b/lib/src/open_feature/src/provider/feature_provider.dart new file mode 100644 index 00000000..00822493 --- /dev/null +++ b/lib/src/open_feature/src/provider/feature_provider.dart @@ -0,0 +1,35 @@ +import '../core/resolution_details.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; + +abstract class FeatureProvider { + String get name; + + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); +} diff --git a/lib/src/open_feature/src/provider/no_op_feature_provider.dart b/lib/src/open_feature/src/provider/no_op_feature_provider.dart new file mode 100644 index 00000000..545116fa --- /dev/null +++ b/lib/src/open_feature/src/provider/no_op_feature_provider.dart @@ -0,0 +1,61 @@ +// coverage:ignore-file + +import '../core/enums/reason.dart'; +import '../core/resolution_details.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; +import 'feature_provider.dart'; + +class NoOpFeatureProvider implements FeatureProvider { + const NoOpFeatureProvider(); + + @override + String get name => 'No-op Provider'; + + @override + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + Future> _resolve(T value) async { + return ResolutionDetails( + value: value, + reason: Reason.noop, + ); + } +} diff --git a/lib/src/tests/test_helpers.dart b/lib/src/tests/test_helpers.dart index 6d7eca41..740f836d 100644 --- a/lib/src/tests/test_helpers.dart +++ b/lib/src/tests/test_helpers.dart @@ -65,6 +65,7 @@ void uiTest( dynamic tags, Size? screenSize, Iterable? localizationDelegates, + Widget Function(Widget)? parentBuilder, }) { assert( () { @@ -130,7 +131,10 @@ void uiTest( child = _scopedChild(builder!()); } - await tester.pumpWidget(child, pumpDuration); + await tester.pumpWidget( + parentBuilder == null ? child : parentBuilder(child), + pumpDuration, + ); if (postFrame == null) { await tester.pumpAndSettle(); diff --git a/lib/src/widgets/src/feature_builder.dart b/lib/src/widgets/src/feature_builder.dart new file mode 100644 index 00000000..fafc1826 --- /dev/null +++ b/lib/src/widgets/src/feature_builder.dart @@ -0,0 +1,91 @@ +import 'dart:developer'; + +import 'package:clean_framework/clean_framework.dart'; +import 'package:flutter/material.dart'; + +typedef FeatureBuilderCallback = Widget Function( + BuildContext, + T, +); + +class FeatureBuilder extends StatefulWidget { + FeatureBuilder({ + super.key, + required this.flagKey, + required this.valueType, + required this.defaultValue, + required this.builder, + this.evaluationContext, + }); + + final String flagKey; + final FlagValueType valueType; + final T defaultValue; + final FeatureBuilderCallback builder; + final EvaluationContext? evaluationContext; + + @override + State> createState() => _FeatureBuilderState(); +} + +class _FeatureBuilderState extends State> { + @override + Widget build(BuildContext context) { + final client = FeatureScope.of(context).client; + + return FutureBuilder( + initialData: widget.defaultValue, + future: _resolver(client), + builder: (context, snapshot) { + if (snapshot.hasError) { + log( + 'Resolution Error', + name: 'Feature Flag', + error: snapshot.error, + stackTrace: snapshot.stackTrace, + ); + + return widget.builder(context, widget.defaultValue); + } + + return widget.builder(context, snapshot.data!); + }, + ); + } + + Future _resolver(FeatureClient client) async { + Future _future; + switch (widget.valueType) { + case FlagValueType.boolean: + _future = client.getBooleanValue( + key: widget.flagKey, + defaultValue: widget.defaultValue as bool, + context: widget.evaluationContext, + ); + break; + case FlagValueType.string: + _future = client.getStringValue( + key: widget.flagKey, + defaultValue: widget.defaultValue as String, + context: widget.evaluationContext, + ); + break; + case FlagValueType.number: + _future = client.getNumberValue( + key: widget.flagKey, + defaultValue: widget.defaultValue as num, + context: widget.evaluationContext, + ); + break; + case FlagValueType.object: + _future = client.getValue( + key: widget.flagKey, + defaultValue: widget.defaultValue, + context: widget.evaluationContext, + ); + break; + } + + return (await _future) as T; + } +} diff --git a/lib/src/widgets/src/feature_scope.dart b/lib/src/widgets/src/feature_scope.dart new file mode 100644 index 00000000..bf784a54 --- /dev/null +++ b/lib/src/widgets/src/feature_scope.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:clean_framework/clean_framework.dart'; +import 'package:flutter/material.dart'; + +class FeatureScope extends StatefulWidget { + const FeatureScope({ + super.key, + required this.register, + required this.child, + this.loader, + this.onLoaded, + }); + + final T Function() register; + final Widget child; + final Future Function(T)? loader; + final VoidCallback? onLoaded; + + static _InheritedFeatureScope of(BuildContext context) { + final _InheritedFeatureScope? result = + context.dependOnInheritedWidgetOfExactType<_InheritedFeatureScope>(); + assert(result != null, 'No _InheritedFeatureScope found in context'); + return result!; + } + + @override + State> createState() => _FeatureScopeState(); +} + +class _FeatureScopeState + extends State> { + late final FeatureClient _client; + + @override + void initState() { + super.initState(); + final featureProvider = widget.register(); + + OpenFeature.instance.provider = featureProvider; + _client = OpenFeature.instance.getClient(); + + _load(featureProvider); + } + + Future _load(T featureProvider) async { + if (widget.loader != null) { + await widget.loader!.call(featureProvider); + widget.onLoaded?.call(); + } + } + + @override + Widget build(BuildContext context) { + return _InheritedFeatureScope( + client: _client, + child: widget.child, + ); + } +} + +class _InheritedFeatureScope extends InheritedWidget { + const _InheritedFeatureScope({ + required Widget child, + required this.client, + }) : super(child: child); + + final FeatureClient client; + + // coverage:ignore-start + @override + bool updateShouldNotify(_InheritedFeatureScope old) => false; + // coverage:ignore-end +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart new file mode 100644 index 00000000..f75581fa --- /dev/null +++ b/lib/src/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'src/feature_builder.dart'; +export 'src/feature_scope.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 128133ae..3a4dc7c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: clean_framework description: Clean Architecture components library, inspired on the guidelines created by Uncle Bob. -version: 1.3.0 +version: 1.3.1 homepage: https://acmesoftware.com/ repository: https://github.com/MattHamburger/clean_framework diff --git a/test/providers/ui_test.dart b/test/providers/ui_test.dart index 599bb880..eaae552f 100644 --- a/test/providers/ui_test.dart +++ b/test/providers/ui_test.dart @@ -20,6 +20,7 @@ void main() { 'LastLogin without setup', builder: () => TestUI(), context: ProvidersContext(), + parentBuilder: (child) => Container(child: child), verify: (tester) async { expect(find.byType(type()), findsOneWidget); expect(find.text('bar'), findsOneWidget); diff --git a/test/widgets/feature_builder_test.dart b/test/widgets/feature_builder_test.dart new file mode 100644 index 00000000..b0087c62 --- /dev/null +++ b/test/widgets/feature_builder_test.dart @@ -0,0 +1,326 @@ +import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework/clean_framework_defaults.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FeatureBuilder tests ||', () { + testWidgets('default value', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + + expect(find.text('rest'), findsOneWidget); + }); + + testWidgets('default variant', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('firebase,graphql'), findsOneWidget); + }); + + testWidgets('alternative variant for iOS', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + evaluationContext: EvaluationContext( + {'platform': 'iOS'}, + ), + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('graphql,rest'), findsOneWidget); + }); + + testWidgets('alternative variant for Android', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + evaluationContext: EvaluationContext( + {'platform': 'android'}, + ), + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('firebase,graphql,rest'), findsOneWidget); + }); + }); + + group('FeatureBuilder tests || different value type ||', () { + testWidgets( + 'boolean', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'boolean', + valueType: FlagValueType.boolean, + defaultValue: false, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('true'), findsOneWidget); + }, + ); + + testWidgets( + 'boolean', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'boolean', + valueType: FlagValueType.boolean, + defaultValue: false, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('true'), findsOneWidget); + }, + ); + + testWidgets( + 'number', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'color', + valueType: FlagValueType.number, + defaultValue: 4285140397, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('4294901760'), findsOneWidget); + }, + ); + + testWidgets( + 'object', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'object', + valueType: FlagValueType.object, + defaultValue: [0, 0], + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('[1, 2]'), findsOneWidget); + }, + ); + + testWidgets( + 'shows default value on error', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'object', + valueType: FlagValueType.number, + defaultValue: [0, 0], + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('[0, 0]'), findsOneWidget); + }, + ); + }); +} + +class FakeJsonFeatureProvider extends JsonFeatureProvider { + void load() { + feed(OpenFeatureFlags.fromJson('''{ + "newTitle": { + "state": "disabled" + }, + "object": { + "returnType": "object", + "variants": { + "a": [1, 2], + "b": [2, 3] + }, + "defaultVariant": "a", + "state": "enabled" + }, + "boolean": { + "returnType": "boolean", + "variants": { + "a": true, + "b": false + }, + "defaultVariant": "a", + "state": "enabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "iOS" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +}''')); + } +}