From 87f86d811764a984073b02df3cc82e1258923a97 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 16:32:53 +0200 Subject: [PATCH] Update service provider Signed-off-by: Hans Kokx --- .github/workflows/analyze-and-unit-test.yaml | 2 + CHANGELOG.md | 9 +- README.md | 12 +- lib/src/providers/service_provider.dart | 160 ++++++++++++---- test/providers/service_provider_test.dart | 184 +++++++++++++++++-- 5 files changed, 303 insertions(+), 64 deletions(-) diff --git a/.github/workflows/analyze-and-unit-test.yaml b/.github/workflows/analyze-and-unit-test.yaml index d756130..82f7f07 100644 --- a/.github/workflows/analyze-and-unit-test.yaml +++ b/.github/workflows/analyze-and-unit-test.yaml @@ -29,6 +29,8 @@ jobs: flutter-version: ${{ env.PURO_FLUTTER_VERSION == 'stable' && '' || env.FLUTTER_VERSION }} - name: Install dependencies run: flutter pub get + - name: Run build_runner + run: dart run build_runner build -d - name: Analyze run: flutter analyze - name: Test diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c55c15..e134e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,14 @@ ### ArcaneServiceProvider -- [BREAKING] `ArcaneServiceProvider.of(context)` now returns a nullable instance, rather than throwing an exception. +- [NEW] Added a new `ArcaneServiceProvider.maybeOf(context)` getter which returns a nullable `ArcaneServiceProvider` instance. +- [NEW] `ArcaneServiceProvider` now includes a `serviceOfType(context)` getter to retrieve a nullable registered service instance. +- [NEW] An `addService` method was added to `ArcaneServiceProvider` +- [NEW] A `setServices` method was added to `ArcaneServiceProvider`. Invoking this method with a list of `ArcaneService` instances will replace all existing services in the `ArcaneServiceProvider`. +- [DEPRECATED] `context.serviceOfType` has been deprecated in favor of `context.service`. +- [NEW] `context.requiredService` has been added to provide a mechanism for ensuring a particular service has been registered. +- [NEW] `ArcaneService.of(context)` has been added for easy access to service instances. It returns a nullable service instance. +- [NEW] `ArcaneService.requiredOf(context)` has been added. It returns a non-nullable service instance, and throws an exception if the service instance has not been registered. ### Authentication Service (ArcaneAuth) diff --git a/README.md b/README.md index 83ac62c..b167e02 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,11 @@ To use Arcane Framework in your Dart or Flutter project, follow these steps: 1. Add the dependency to your pubspec.yaml: - ```yaml - dependencies: - arcane_framework: + ```shell + flutter pub add arcane_framework ``` - 2. Wrap your `MaterialApp` or `CupertinoApp` with the `ArcaneApp` Widget, providing the necessary services and your root widget. + 2. Wrap your `MaterialApp` or `CupertinoApp` with the `ArcaneApp` Widget, providing the necessary services and your root widget. ```dart import 'package:arcane_framework/arcane_framework.dart'; @@ -36,7 +35,7 @@ To use Arcane Framework in your Dart or Flutter project, follow these steps: services: [ MyArcaneService.I, ], - child: MyApp(...), + child: MainApp(), ), ); } @@ -74,7 +73,6 @@ class FavoriteColorService extends ArcaneService { notifyListeners(); } } - ``` To register a service with Arcane, simply add the instance of the `ArcaneService` to your list of services when initializing the `ArcaneApp`. @@ -84,7 +82,7 @@ ArcaneApp( services: [ FavoriteColorService.I, ], - child: MyApp(...), + child: MainApp(), ), ``` diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index fb2fdf9..3a0cb21 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -17,89 +17,171 @@ import "package:flutter/widgets.dart"; /// ``` /// To access the provided services: /// ```dart -/// final provider = ArcaneServiceProvider.of(context); +/// final myService = ArcaneServiceProvider.of(context); /// ``` -class ArcaneServiceProvider extends InheritedNotifier { +class ArcaneServiceProvider + extends InheritedNotifier>> { /// A list of `ArcaneService` instances available through the provider. - final List serviceInstances; + List get serviceInstances => notifier!.value; /// Creates an `ArcaneServiceProvider` that provides [serviceInstances] to the widget tree. /// /// The [child] widget will be the root of the widget subtree that has access to the services. - @override - const ArcaneServiceProvider({ - required this.serviceInstances, + ArcaneServiceProvider({ + required List serviceInstances, required super.child, super.key, - }); + }) : super( + notifier: ValueNotifier>(serviceInstances), + ); - @override - bool updateShouldNotify(covariant ArcaneServiceProvider oldWidget) { - return !const DeepCollectionEquality().equals( - serviceInstances, - oldWidget.serviceInstances, - ); + /// Retrieves the nearest `ArcaneServiceProvider` in the widget tree. + /// + /// Returns null if no provider is found. + /// + /// Example: + /// ```dart + /// final provider = ArcaneServiceProvider.maybeOf(context); + /// ``` + static ArcaneServiceProvider? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); } /// Retrieves the nearest `ArcaneServiceProvider` in the widget tree. /// - /// This method is used to access the `ArcaneServiceProvider` and its provided services - /// from any descendant widget. It throws an exception if no `ArcaneServiceProvider` - /// is found in the widget tree. + /// Throws an assertion error if no provider is found. /// /// Example: /// ```dart /// final provider = ArcaneServiceProvider.of(context); /// ``` - static ArcaneServiceProvider? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); + static ArcaneServiceProvider of( + BuildContext context, + ) { + final provider = maybeOf(context); + assert(provider != null, "No ArcaneServiceProvider found in context"); + return provider!; + } + + /// Retrieves a service of type `T` from the nearest provider. + /// + /// Returns null if no service of type `T` is found or if no provider exists. + /// + /// Example: + /// ```dart + /// final myService = ArcaneServiceProvider.of(context); + /// ``` + static T? serviceOfType(BuildContext context) { + final provider = maybeOf(context); + if (provider == null) return null; + + return provider.serviceInstances.whereType().firstOrNull; + } + + /// Updates the service instances in this provider. + /// + /// This will trigger a rebuild of all widgets that depend on this provider. + void setServices(List newServices) { + notifier?.value = newServices; + } + + /// Adds a new service to this provider. + /// + /// If a service of the same type already exists, it will be replaced. + void addService(ArcaneService service) { + final int existingIndex = serviceInstances.indexWhere( + (s) => s.runtimeType == service.runtimeType, + ); + + final List newList = + List.from(serviceInstances); + + if (existingIndex >= 0) { + newList[existingIndex] = service; + } else { + newList.add(service); + } + + notifier?.value = newList; } } /// An extension on `BuildContext` to provide easy access to `ArcaneService` instances /// that are registered in an `ArcaneServiceProvider`. /// -/// This extension provides a `serviceOfType` method, which searches for a specific -/// service of type `T` in the current `ArcaneServiceProvider` or in the list of built-in -/// services. +/// This extension provides methods for retrieving services in various ways. /// /// Example usage: /// ```dart -/// final MyService? myService = context.serviceOfType(); +/// final myService = context.service(); /// ``` -extension ServiceProvider on BuildContext { +extension ServiceProviderExtension on BuildContext { /// Finds and returns the `ArcaneService` instance of type `T` that has been registered /// in the `ArcaneServiceProvider` or in the list of built-in services (`Arcane.services`). /// /// If no such service is found, it returns `null`. /// - /// - `T`: The type of the service to be retrieved, which extends `ArcaneService`. + /// Example: + /// ```dart + /// final myService = context.service(); + /// ``` + T? service() { + // First check built-in services + final builtInService = Arcane.services.whereType().firstOrNull; + if (builtInService != null) return builtInService; + + // Then check provider + return ArcaneServiceProvider.serviceOfType(this); + } + + /// Finds and returns the `ArcaneService` instance of type `T` that has been registered + /// in the `ArcaneServiceProvider` or in the list of built-in services (`Arcane.services`). + /// + /// Throws an assertion error if no service is found. /// /// Example: /// ```dart - /// final MyService? myService = context.serviceOfType(); + /// final myService = context.requiredService(); /// ``` - T? serviceOfType() { - final T? builtInService = - Arcane.services.firstWhereOrNull((s) => s.runtimeType == T) as T?; - - if (builtInService != null) return builtInService; - - final T? foundService = - dependOnInheritedWidgetOfExactType() - ?.serviceInstances - .firstWhereOrNull((s) => s.runtimeType == T) as T?; - return foundService; + T requiredService() { + final service = this.service(); + assert(service != null, "No service of type $T found"); + return service!; } + + /// Legacy method to maintain backward compatibility. + /// + /// Prefer using `service()` instead. + @Deprecated("Use service() instead") + T? serviceOfType() => service(); } /// An abstract class representing a service in the Arcane architecture. /// /// Classes that extend `ArcaneService` can use `ChangeNotifier` functionality /// to notify listeners of changes. Services are typically registered in -/// `ArcaneServiceProvider` and can be accessed using the `serviceOfType` +/// `ArcaneServiceProvider` and can be accessed using the `service` /// method on `BuildContext`. -abstract class ArcaneService with ChangeNotifier { +abstract class ArcaneService with ChangeNotifier { + /// Retrieves a service of the specified type from the context. + /// + /// Returns null if no service of type `T` is found. + /// + /// Example: + /// ```dart + /// final myService = ArcaneService.of(context); + /// ``` static T? of(BuildContext context) => - context.serviceOfType(); + context.service(); + + /// Retrieves a service of the specified type from the context. + /// + /// Throws an assertion error if no service of type `T` is found. + /// + /// Example: + /// ```dart + /// final myService = ArcaneService.requiredOf(context); + /// ``` + static T requiredOf(BuildContext context) => + context.requiredService(); } diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index dc2124c..4285f7b 100644 --- a/test/providers/service_provider_test.dart +++ b/test/providers/service_provider_test.dart @@ -20,7 +20,7 @@ void main() { child: Builder( builder: (context) { final provider = ArcaneServiceProvider.of(context); - expect(provider?.serviceInstances, equals(testServices)); + expect(provider.serviceInstances, equals(testServices)); return const SizedBox(); }, ), @@ -28,13 +28,122 @@ void main() { ); }); - testWidgets("serviceOfType extension returns correct service", + testWidgets("static serviceOfType method returns correct service", (tester) async { await tester.pumpWidget( ArcaneApp( services: testServices, child: Builder( builder: (context) { + final service = + ArcaneServiceProvider.serviceOfType( + context, + ); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets( + "static serviceOfType method returns correct service and returns null when not found", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // Should find this service + final service = + ArcaneServiceProvider.serviceOfType( + context, + ); + + expect(service, isA()); + + // Returns null for unregistered services + expect( + ArcaneServiceProvider.serviceOfType( + context, + ), + isNull, + ); + + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("service extension returns correct service", (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.service(); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets( + "requiredService extension returns correct service and throws when not found", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // Should find this service + final service = context.requiredService(); + expect(service, isA()); + + // Should throw for missing service + expect( + () => context.requiredService(), + throwsA(isA()), + ); + + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("service returns null for unregistered service", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.service(); + expect(service, isNull); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("legacy serviceOfType method still works but is deprecated", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // ignore: deprecated_member_use_from_same_package final service = context.serviceOfType(); expect(service, isNotNull); expect(service, isA()); @@ -45,15 +154,49 @@ void main() { ); }); - testWidgets("serviceOfType returns null for unregistered service", - (tester) async { + testWidgets("service updates trigger rebuilds", (tester) async { + late ArcaneServiceProvider provider; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneServiceProvider( + serviceInstances: testServices, + child: Builder( + builder: (context) { + provider = ArcaneServiceProvider.of(context); + buildCount++; + // Access a service to create dependency + context.service(); + return const Text("Test"); + }, + ), + ), + ), + ); + + expect(buildCount, 1); + + // Update services and verify rebuild + provider.setServices([MockArcaneService(), AnotherMockService()]); + await tester.pump(); + expect(buildCount, 2); + + // Add a service and verify rebuild + provider.addService(UnregisteredService()); + await tester.pump(); + expect(buildCount, 3); + }); + + testWidgets("ArcaneService.of static helper works", (tester) async { await tester.pumpWidget( ArcaneApp( services: testServices, child: Builder( builder: (context) { - final service = context.serviceOfType(); - expect(service, isNull); + final service = ArcaneService.of(context); + expect(service, isNotNull); + expect(service, isA()); return const SizedBox(); }, ), @@ -61,25 +204,32 @@ void main() { ); }); - testWidgets("updateShouldNotify always returns true", (tester) async { - final provider = ArcaneServiceProvider( - serviceInstances: testServices, - child: const SizedBox(), - ); + testWidgets("ArcaneService.requiredOf static helper works", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = + ArcaneService.requiredOf(context); + expect(service, isA()); - expect( - provider.updateShouldNotify( - ArcaneServiceProvider( - serviceInstances: testServices, - child: const SizedBox(), + expect( + () => ArcaneService.requiredOf(context), + throwsA(isA()), + ); + + return const SizedBox(); + }, ), ), - true, ); }); }); } +// Mock classes for testing class MockArcaneService extends ArcaneService {} class AnotherMockService extends ArcaneService {}