From 87eabf02939bf2e39413da0814d9b28a2f225d07 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 14:33:30 +0200 Subject: [PATCH 01/58] Made ArcaneTheme private (_ArcaneTheme) Signed-off-by: Hans Kokx --- example/lib/main.dart | 2 +- lib/arcane_framework.dart | 1 - .../services/reactive_theme/arcane_theme.dart | 26 -------------- .../reactive_theme_extensions.dart | 8 ----- .../reactive_theme_switcher.dart | 34 ++++++++++++++++++- 5 files changed, 34 insertions(+), 37 deletions(-) delete mode 100644 lib/src/services/reactive_theme/arcane_theme.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index fba2056..64b97c1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -235,7 +235,7 @@ class _HomeScreenState extends State { ), ), Text( - "The current theme mode is ${context.themeMode.name} and " + "The current theme mode is ${Arcane.theme.currentModeOf(context).name} and " "is ${Arcane.theme.isFollowingSystemTheme ? "" : "not "}" "following the system theme.", ), diff --git a/lib/arcane_framework.dart b/lib/arcane_framework.dart index 25b13a3..46a1ccb 100644 --- a/lib/arcane_framework.dart +++ b/lib/arcane_framework.dart @@ -44,7 +44,6 @@ export "package:arcane_framework/src/providers/service_provider.dart"; export "package:arcane_framework/src/services/authentication/authentication_service.dart"; export "package:arcane_framework/src/services/feature_flags/feature_flags_service.dart"; export "package:arcane_framework/src/services/logging/logging_service.dart"; -export "package:arcane_framework/src/services/reactive_theme/arcane_theme.dart"; export "package:arcane_framework/src/services/reactive_theme/reactive_theme_service.dart"; export "package:arcane_framework/src/services/reactive_theme/reactive_theme_switcher.dart"; export "package:result_monad/result_monad.dart"; diff --git a/lib/src/services/reactive_theme/arcane_theme.dart b/lib/src/services/reactive_theme/arcane_theme.dart deleted file mode 100644 index d91ea3d..0000000 --- a/lib/src/services/reactive_theme/arcane_theme.dart +++ /dev/null @@ -1,26 +0,0 @@ -import "package:flutter/material.dart"; - -class ArcaneTheme extends InheritedWidget { - final ThemeMode themeMode; - final bool followSystem; - final ThemeData? theme; - - const ArcaneTheme({ - required super.child, - this.themeMode = ThemeMode.light, - this.followSystem = false, - this.theme, - super.key, - }); - - static ArcaneTheme? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } - - @override - bool updateShouldNotify(ArcaneTheme oldWidget) { - return themeMode != oldWidget.themeMode || - followSystem != oldWidget.followSystem || - theme != oldWidget.theme; - } -} diff --git a/lib/src/services/reactive_theme/reactive_theme_extensions.dart b/lib/src/services/reactive_theme/reactive_theme_extensions.dart index 9ad45a2..edb18f6 100644 --- a/lib/src/services/reactive_theme/reactive_theme_extensions.dart +++ b/lib/src/services/reactive_theme/reactive_theme_extensions.dart @@ -19,11 +19,3 @@ extension DarkMode on BuildContext { return brightness == Brightness.dark; } } - -extension ArcaneThemeContext on BuildContext { - /// Get the current theme mode from the nearest ArcaneThemeInherited widget - ThemeMode get themeMode { - return ArcaneTheme.of(this)?.themeMode ?? - ArcaneReactiveTheme.I.currentThemeMode; - } -} diff --git a/lib/src/services/reactive_theme/reactive_theme_switcher.dart b/lib/src/services/reactive_theme/reactive_theme_switcher.dart index 99689f4..568c391 100644 --- a/lib/src/services/reactive_theme/reactive_theme_switcher.dart +++ b/lib/src/services/reactive_theme/reactive_theme_switcher.dart @@ -45,7 +45,7 @@ class _ArcaneThemeSwitcherState extends State @override Widget build(BuildContext context) { - return ArcaneTheme( + return _ArcaneTheme( themeMode: ArcaneReactiveTheme.I.currentThemeMode, followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme, theme: ArcaneReactiveTheme.I.currentTheme, @@ -68,3 +68,35 @@ class _ArcaneThemeSwitcherState extends State super.didChangePlatformBrightness(); } } + +class _ArcaneTheme extends InheritedWidget { + final ThemeMode themeMode; + final bool followSystem; + final ThemeData? theme; + + const _ArcaneTheme({ + required super.child, + this.themeMode = ThemeMode.light, + this.followSystem = false, + this.theme, + }); + + static _ArcaneTheme? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_ArcaneTheme>(); + } + + @override + bool updateShouldNotify(_ArcaneTheme oldWidget) { + return themeMode != oldWidget.themeMode || + followSystem != oldWidget.followSystem || + theme != oldWidget.theme; + } +} + +extension ArcaneThemeContext on BuildContext { + /// Get the current theme mode from the nearest ArcaneThemeInherited widget + ThemeMode get themeMode { + return _ArcaneTheme.of(this)?.themeMode ?? + ArcaneReactiveTheme.I.currentThemeMode; + } +} From 3e62ffc8088c1c97db1241e555b098370276290c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 14:42:18 +0200 Subject: [PATCH 02/58] Updated changelog Signed-off-by: Hans Kokx --- CHANGELOG.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ca824..4c55c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.0.0-dev +## 2.0.0 ### Arcane @@ -10,7 +10,8 @@ - [CHANGE] The feature has been completely rewritten as an inherited widget, rather than using a `Cubit`. - [NEW] The `ArcaneEnvironment` widget now includes the `maybeOf(context)` and `of(context)` service locators. - [NEW] An `ArcaneEnvironmentProvider` widget has been added. This is used by `ArcaneApp` but can also be used independently when not using the `ArcaneApp` widget. -- [NEW] The `ArcaneEnvironment` +- [BREAKING] The locator for `ArcaneEnvironment` has been changed from `context.read()` to `ArcaneEnvironment.of(context)` +- [BREAKING] Reading the current environment has been changed from `context.read().state` to `ArcaneEnvironment.of(context).environment`; ### ArcaneServiceProvider @@ -33,13 +34,13 @@ - [NEW] In addition to the existing `registerInterfaces` method, a new `registerInterface` method has been added. - [NEW] The following methods have been added: `unregisterInterface`, `unregisterInterfaces`, and `unregisterAllInterfaces`. - [NEW] Added a `reset` method that clears all registered interfaces, clears all persistent metadata, and de-initializes `ArcaneLogger` +- [BREAKING] Added a `skipAutodetection` option (defaults to `false`) when invoking the `log` method. When set to `true`, automatic file and line number detection, as well as automatic module and method detection will not be performed (the module and method can still be added as properties). Skipping autodetection may help to increase performance, as a `StackTrace` is no longer generated and parsed. This property will need to be added to existing `LoggingInterface` implementations. ### Theme (ArcaneTheme) -- [NEW] Added the `ArcaneTheme` inherited widget - [NEW] Added `themeMode` extension to `BuildContext` to get the current `ThemeMode` (e.g., light/dark) - [BREAKING] Completely rewrote `ArcaneReactiveTheme` -- [NEW] Added the `ArcaneThemeSwitcher` widget. +- [NEW] Added the `ArcaneThemeSwitcher` widget #### ArcaneReactiveTheme @@ -54,6 +55,8 @@ - [CHANGE] The `switchTheme` method now (optionally) takes in a `ThemeMode` parameter. If it is omitted, the new mode will be automatically determined. - [FIX] The `followSystemTheme` method will now correctly trigger widget rebuilds under the correct circumstances. - [FIX] Invoking the `setDarkTheme` and `setLightTheme` methods will trigger widget rebuilds under the correct circumstances. +- [BREAKING] In order to enable following the system brightness changes, the `Arcane.theme.followSystemTheme(context)`/`ArcaneReactiveTheme.I.followSystemTheme(context)` method will need to be invoked once. +- [NEW] When manually switching from following the system theme to a specific theme (e.g., `switchTheme()`), the system theme will no longer be followed. To follow the system theme once again, the `followSystemTheme(context)` method should be invoked. #### ArcaneThemeSwitcher @@ -66,7 +69,11 @@ ### Example -- [FIX] The example has been completely reworked. It now includes examples of all features that Arcane has to offer +- [FIX] The example has been completely reworked. It now includes examples of all features that Arcane has to offer. + +### Misc + +- [FIX] Dartdoc comments have been added throughout the framework where they were previously missing. ## 1.2.5 From 8711eae4d8e8864b4abe0a1d8e0f156a1e6bfd84 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 15:00:48 +0200 Subject: [PATCH 03/58] Fix updateShouldNotify on ArcaneServiceProvider Signed-off-by: Hans Kokx --- lib/src/providers/service_provider.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 68856ec..fb2fdf9 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -33,12 +33,13 @@ class ArcaneServiceProvider extends InheritedNotifier { super.key, }); - /// Determines whether the widget should notify its dependents. - /// - /// This always returns `true`, meaning dependents will always be notified - /// when this widget is rebuilt. @override - bool updateShouldNotify(_) => true; + bool updateShouldNotify(covariant ArcaneServiceProvider oldWidget) { + return !const DeepCollectionEquality().equals( + serviceInstances, + oldWidget.serviceInstances, + ); + } /// Retrieves the nearest `ArcaneServiceProvider` in the widget tree. /// From 87f86d811764a984073b02df3cc82e1258923a97 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 16:32:53 +0200 Subject: [PATCH 04/58] 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 | 158 ++++++++++++---- test/providers/service_provider_test.dart | 178 +++++++++++++++++-- 5 files changed, 299 insertions(+), 60 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? myService = context.serviceOfType(); + /// final myService = context.service(); /// ``` - T? serviceOfType() { - final T? builtInService = - Arcane.services.firstWhereOrNull((s) => s.runtimeType == T) as T?; - + T? service() { + // First check built-in services + final builtInService = Arcane.services.whereType().firstOrNull; if (builtInService != null) return builtInService; - final T? foundService = - dependOnInheritedWidgetOfExactType() - ?.serviceInstances - .firstWhereOrNull((s) => s.runtimeType == T) as T?; - return foundService; + // 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 = context.requiredService(); + /// ``` + 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,14 +28,17 @@ 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 = context.serviceOfType(); + final service = + ArcaneServiceProvider.serviceOfType( + context, + ); expect(service, isNotNull); expect(service, isA()); return const SizedBox(); @@ -45,14 +48,86 @@ void main() { ); }); - testWidgets("serviceOfType returns null for unregistered service", + 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) { - final service = context.serviceOfType(); + // 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(); }, @@ -61,25 +136,100 @@ void main() { ); }); - testWidgets("updateShouldNotify always returns true", (tester) async { - final provider = ArcaneServiceProvider( - serviceInstances: testServices, - child: 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()); + return const SizedBox(); + }, + ), + ), ); + }); + + testWidgets("service updates trigger rebuilds", (tester) async { + late ArcaneServiceProvider provider; + int buildCount = 0; - expect( - provider.updateShouldNotify( - ArcaneServiceProvider( + await tester.pumpWidget( + MaterialApp( + home: ArcaneServiceProvider( serviceInstances: testServices, - child: const SizedBox(), + 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 = ArcaneService.of(context); + expect(service, isNotNull); + expect(service, isA()); + return 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( + () => ArcaneService.requiredOf(context), + throwsA(isA()), + ); + + return const SizedBox(); + }, ), ), - true, ); }); }); } +// Mock classes for testing class MockArcaneService extends ArcaneService {} class AnotherMockService extends ArcaneService {} From a04038afc33f1cdecd4357ec1ea0fc5d332cc81a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 16:40:20 +0200 Subject: [PATCH 05/58] Put service locators in an extension Signed-off-by: Hans Kokx --- lib/src/providers/service_provider.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 3a0cb21..4eb06fa 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -162,7 +162,9 @@ extension ServiceProviderExtension on BuildContext { /// to notify listeners of changes. Services are typically registered in /// `ArcaneServiceProvider` and can be accessed using the `service` /// method on `BuildContext`. -abstract class ArcaneService with ChangeNotifier { +abstract class ArcaneService with ChangeNotifier {} + +extension ArcaneServiceLocators on ArcaneService { /// Retrieves a service of the specified type from the context. /// /// Returns null if no service of type `T` is found. From c3eb677b05455f8e6dc02660d14978a9841b9cc8 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 16:41:34 +0200 Subject: [PATCH 06/58] Try using a mixin instead Signed-off-by: Hans Kokx --- lib/src/providers/service_provider.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 4eb06fa..0fb6dfc 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -162,9 +162,9 @@ extension ServiceProviderExtension on BuildContext { /// to notify listeners of changes. Services are typically registered in /// `ArcaneServiceProvider` and can be accessed using the `service` /// method on `BuildContext`. -abstract class ArcaneService with ChangeNotifier {} +abstract class ArcaneService with ChangeNotifier, ArcaneServiceLocators {} -extension ArcaneServiceLocators on ArcaneService { +mixin ArcaneServiceLocators { /// Retrieves a service of the specified type from the context. /// /// Returns null if no service of type `T` is found. From d069eb0fd3b05680173b0bf38787904ee0df105e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 16:43:21 +0200 Subject: [PATCH 07/58] Remove 'of' and 'requiredOf' locators Signed-off-by: Hans Kokx --- README.md | 4 ++-- lib/src/providers/service_provider.dart | 31 +------------------------ 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b167e02..9e5bf76 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ To use Arcane Framework in your Dart or Flutter project, follow these steps: runApp( ArcaneApp( services: [ - MyArcaneService.I, + MyArcaneService(), ], child: MainApp(), ), @@ -55,7 +55,7 @@ A service's purpose is to facilitate cross-feature communication of small pieces class FavoriteColorService extends ArcaneService { static final FavoriteColorService _instance = FavoriteColorService._internal(); - static FavoriteColorService get I => _instance; + factory FavoriteColorService() => I._instance; FavoriteColorService._internal(); diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 0fb6dfc..7326e5b 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -157,33 +157,4 @@ extension ServiceProviderExtension on BuildContext { } /// 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 `service` -/// method on `BuildContext`. -abstract class ArcaneService with ChangeNotifier, ArcaneServiceLocators {} - -mixin ArcaneServiceLocators { - /// 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.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(); -} +abstract class ArcaneService with ChangeNotifier {} From 304c6c8094aeafb61f76d43afc006e9cc263894f Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 17:09:55 +0200 Subject: [PATCH 08/58] Re-add service locators. They should be called as: ArcaneService.of(context) Signed-off-by: Hans Kokx --- lib/src/providers/service_provider.dart | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 7326e5b..3a0cb21 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -157,4 +157,31 @@ extension ServiceProviderExtension on BuildContext { } /// An abstract class representing a service in the Arcane architecture. -abstract class ArcaneService with ChangeNotifier {} +/// +/// 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 `service` +/// method on `BuildContext`. +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.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(); +} From d1daf0e39f71994e2f27ade218a693512e3a0522 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 17:25:27 +0200 Subject: [PATCH 09/58] Renamed getters on ArcaneService Signed-off-by: Hans Kokx --- CHANGELOG.md | 3 +-- lib/src/providers/service_provider.dart | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e134e19..ef1806a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,7 @@ - [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. +- [NEW] Added `ArcaneService.ofType(context)` and `ArcaneService.requiredOfType(context)` locators, returning a nullable and non-nullable instance of a given service, respectively. ### Authentication Service (ArcaneAuth) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 3a0cb21..33d105e 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -169,9 +169,9 @@ abstract class ArcaneService with ChangeNotifier { /// /// Example: /// ```dart - /// final myService = ArcaneService.of(context); + /// final myService = ArcaneService.ofType(context); /// ``` - static T? of(BuildContext context) => + static T? ofType(BuildContext context) => context.service(); /// Retrieves a service of the specified type from the context. @@ -180,8 +180,8 @@ abstract class ArcaneService with ChangeNotifier { /// /// Example: /// ```dart - /// final myService = ArcaneService.requiredOf(context); + /// final myService = ArcaneService.requiredOfType(context); /// ``` - static T requiredOf(BuildContext context) => + static T requiredOfType(BuildContext context) => context.requiredService(); } From 3ef93bb3f93d40df8d74686525476117daf74b6a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 18:56:48 +0200 Subject: [PATCH 10/58] Renamed serviceInstances => registeredServices and added a removeService method Signed-off-by: Hans Kokx --- CHANGELOG.md | 4 +++- lib/src/providers/service_provider.dart | 25 +++++++++++++++++++---- test/providers/service_provider_test.dart | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1806a..ee90f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,13 @@ - [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] An `addService` method was added to `ArcaneServiceProvider`. +- [NEW] A `removeService` 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] Added `ArcaneService.ofType(context)` and `ArcaneService.requiredOfType(context)` locators, returning a nullable and non-nullable instance of a given service, respectively. +- [BREAKING] Renamed `serviceInstances` to `registeredServices`. ### Authentication Service (ArcaneAuth) diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 33d105e..a7ad80c 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -22,7 +22,8 @@ import "package:flutter/widgets.dart"; class ArcaneServiceProvider extends InheritedNotifier>> { /// A list of `ArcaneService` instances available through the provider. - List get serviceInstances => notifier!.value; + List get registeredServices => + List.from(notifier?.value ?? []); /// Creates an `ArcaneServiceProvider` that provides [serviceInstances] to the widget tree. /// @@ -75,7 +76,7 @@ class ArcaneServiceProvider final provider = maybeOf(context); if (provider == null) return null; - return provider.serviceInstances.whereType().firstOrNull; + return provider.registeredServices.whereType().firstOrNull; } /// Updates the service instances in this provider. @@ -89,12 +90,12 @@ class ArcaneServiceProvider /// /// If a service of the same type already exists, it will be replaced. void addService(ArcaneService service) { - final int existingIndex = serviceInstances.indexWhere( + final int existingIndex = registeredServices.indexWhere( (s) => s.runtimeType == service.runtimeType, ); final List newList = - List.from(serviceInstances); + List.from(registeredServices); if (existingIndex >= 0) { newList[existingIndex] = service; @@ -104,6 +105,22 @@ class ArcaneServiceProvider notifier?.value = newList; } + + /// Removes all services of the specified type from the registry. + /// Returns true if any services were removed, false otherwise. + void removeService() { + final int existingIndex = registeredServices.indexWhere( + (s) => s.runtimeType == T, + ); + + if (existingIndex >= 0) { + final List newList = + List.from(registeredServices); + + newList.removeAt(existingIndex); + notifier?.value = newList; + } + } } /// An extension on `BuildContext` to provide easy access to `ArcaneService` instances diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index 4285f7b..bf2c4ca 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.registeredServices, equals(testServices)); return const SizedBox(); }, ), From 25b456a664125e2d47c5abf0a13c53bef1ae7624 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 19:18:38 +0200 Subject: [PATCH 11/58] Broke up arcane service into separate files Signed-off-by: Hans Kokx --- lib/arcane_framework.dart | 2 +- lib/src/providers/service/arcane_service.dart | 36 ++++++++ .../{ => service}/service_provider.dart | 84 +------------------ .../service/service_provider_extensions.dart | 51 +++++++++++ 4 files changed, 89 insertions(+), 84 deletions(-) create mode 100644 lib/src/providers/service/arcane_service.dart rename lib/src/providers/{ => service}/service_provider.dart (56%) create mode 100644 lib/src/providers/service/service_provider_extensions.dart diff --git a/lib/arcane_framework.dart b/lib/arcane_framework.dart index 46a1ccb..6ca32ec 100644 --- a/lib/arcane_framework.dart +++ b/lib/arcane_framework.dart @@ -40,7 +40,7 @@ library; export "package:arcane_framework/src/arcane.dart"; export "package:arcane_framework/src/arcane_app.dart"; export "package:arcane_framework/src/providers/environment_provider.dart"; -export "package:arcane_framework/src/providers/service_provider.dart"; +export "package:arcane_framework/src/providers/service/arcane_service.dart"; export "package:arcane_framework/src/services/authentication/authentication_service.dart"; export "package:arcane_framework/src/services/feature_flags/feature_flags_service.dart"; export "package:arcane_framework/src/services/logging/logging_service.dart"; diff --git a/lib/src/providers/service/arcane_service.dart b/lib/src/providers/service/arcane_service.dart new file mode 100644 index 0000000..f782142 --- /dev/null +++ b/lib/src/providers/service/arcane_service.dart @@ -0,0 +1,36 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:collection/collection.dart"; +import "package:flutter/widgets.dart"; + +part "service_provider.dart"; +part "service_provider_extensions.dart"; + +/// 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 `service` +/// method on `BuildContext`. +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.ofType(context); + /// ``` + static T? ofType(BuildContext context) => + 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.requiredOfType(context); + /// ``` + static T requiredOfType(BuildContext context) => + context.requiredService(); +} diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service/service_provider.dart similarity index 56% rename from lib/src/providers/service_provider.dart rename to lib/src/providers/service/service_provider.dart index a7ad80c..990691d 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service/service_provider.dart @@ -1,6 +1,4 @@ -import "package:arcane_framework/arcane_framework.dart"; -import "package:collection/collection.dart"; -import "package:flutter/widgets.dart"; +part of "arcane_service.dart"; /// A provider that makes a list of `ArcaneService` instances available to the widget tree. /// @@ -122,83 +120,3 @@ class ArcaneServiceProvider } } } - -/// An extension on `BuildContext` to provide easy access to `ArcaneService` instances -/// that are registered in an `ArcaneServiceProvider`. -/// -/// This extension provides methods for retrieving services in various ways. -/// -/// Example usage: -/// ```dart -/// final myService = context.service(); -/// ``` -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`. - /// - /// 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 = context.requiredService(); - /// ``` - 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 `service` -/// method on `BuildContext`. -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.ofType(context); - /// ``` - static T? ofType(BuildContext context) => - 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.requiredOfType(context); - /// ``` - static T requiredOfType(BuildContext context) => - context.requiredService(); -} diff --git a/lib/src/providers/service/service_provider_extensions.dart b/lib/src/providers/service/service_provider_extensions.dart new file mode 100644 index 0000000..e71647d --- /dev/null +++ b/lib/src/providers/service/service_provider_extensions.dart @@ -0,0 +1,51 @@ +part of "arcane_service.dart"; + +/// An extension on `BuildContext` to provide easy access to `ArcaneService` instances +/// that are registered in an `ArcaneServiceProvider`. +/// +/// This extension provides methods for retrieving services in various ways. +/// +/// Example usage: +/// ```dart +/// final myService = context.service(); +/// ``` +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`. + /// + /// 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 = context.requiredService(); + /// ``` + 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(); +} From 88210ce1bce73e56d5fc4cc30d892d3579256985 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 19:52:58 +0200 Subject: [PATCH 12/58] Added requiredServiceOfType getter on ArcaneServiceProvider Signed-off-by: Hans Kokx --- .../providers/service/service_provider.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/src/providers/service/service_provider.dart b/lib/src/providers/service/service_provider.dart index 990691d..e317438 100644 --- a/lib/src/providers/service/service_provider.dart +++ b/lib/src/providers/service/service_provider.dart @@ -77,6 +77,27 @@ class ArcaneServiceProvider return provider.registeredServices.whereType().firstOrNull; } + /// Retrieves a service of type `T` from the nearest provider. + /// + /// Throws an exception if no service of type `T` is found or if no provider exists. + /// + /// Example: + /// ```dart + /// final myService = ArcaneServiceProvider.of(context); + /// ``` + static T requiredServiceOfType( + BuildContext context, + ) { + final provider = maybeOf(context); + assert(provider != null, "No ArcaneServiceProvider found"); + + final T? service = provider!.registeredServices.whereType().firstOrNull; + + assert(service != null, "No service of type $T found"); + + return service!; + } + /// Updates the service instances in this provider. /// /// This will trigger a rebuild of all widgets that depend on this provider. From 2e26f907486ac5689aee2a257fc9d6939b2ec503 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 29 Apr 2025 20:05:02 +0200 Subject: [PATCH 13/58] Updated services section of the readme Signed-off-by: Hans Kokx --- README.md | 119 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9e5bf76..1ee4d9d 100644 --- a/README.md +++ b/README.md @@ -47,35 +47,49 @@ The following sections provide more information about how to use the framework f ### Services -The Arcane Framework provides a centralized way to manage services across your application. This allows you to easily access and configure all of your services from anywhere in your app, without having to pass them down through multiple widgets. +The Arcane Framework provides a centralized way to manage services across your application via a built-in service locator. -A service's purpose is to facilitate cross-feature communication of small pieces of data. For example, one feature may ask a user for their favorite color, while another feature may use that color to change the background of a screen. The feature ingesting the users' favorite color should not care how the favorite color has been determined, nor should it rely directly upon the feature that determines said color. A service can be used to hold the color in question, effectively decoupling these two features. One service sets the value while another ingests it. +#### Services overview -```dart -class FavoriteColorService extends ArcaneService { - static final FavoriteColorService _instance = FavoriteColorService._internal(); +Unlike most of the features in Arcane, a _service_ is broadly user-defined. What a service is, or does, is not rigorously enforced by the framework. - factory FavoriteColorService() => I._instance; +The following tools are available for use in crafting your own services: - FavoriteColorService._internal(); +- `ArcaneService`: The base class from which to extend your own services. Includes a `ChangeNotifier` and locators. +- `ArcaneServiceProvider`: A widget which extends the `InheritedNotifier` class, used to manage `ArcaneService` instances. _This widget is already part of the `ArcaneApp` widget._ +- `service` and `requiredService` extensions on `BuildContext`: A nullable and non-nullable getter, respectively, to locate a given `ArcaneService` via `BuildContext`. - Color? get myFavoriteColor => _notifier.value; +### Defining an example `ArcaneService` - final ValueNotifier _notifier = ValueNotifier(null); +As noted previously, _what_ a service is or does is not enforced by the framework. Therefore, the following example is only in service of the remainder of the documentation of the Arcane services feature. + +This example service is a singleton service that stores and provides access to a user's favorite color. +```dart +class FavoriteColorService extends ArcaneService { + FavoriteColorService._internal(); + static final FavoriteColorService _instance = FavoriteColorService._internal(); + static FavoriteColorService get I => _instance; + + final ValueNotifier _notifier = ValueNotifier(null); ValueNotifier get notifier => _notifier; - void setMyFavoriteColor(Color? newValue) { - if (_notifier.value != newValue) { - _notifier.value = newValue; - } + Color? get myFavoriteColor => _notifier.value; - notifyListeners(); + void setMyFavoriteColor(Color? color) { + if (_notifier.value != color) { + _notifier.value = color; + notifyListeners(); + } } } ``` -To register a service with Arcane, simply add the instance of the `ArcaneService` to your list of services when initializing the `ArcaneApp`. +### Registering and unregistering an `ArcaneService` + +The quickest and easiest way to register an `ArcaneService` is to use the built-in `ArcaneApp` widget. However, this is not the _only_ method available. + +To register your `ArcaneService` using an app with the `ArcaneApp` widget, you have a couple of options. First, you can simply add the service (in our case, a singleton instance) to the `services` list directly: ```dart ArcaneApp( @@ -86,15 +100,82 @@ ArcaneApp( ), ``` -Service properties can be accessed either directly (e.g., `FavoriteColorService.I.myFavoriteColor`) or via `BuildContext` (e.g., `context.serviceOfType()?.myFavoriteColor`). If the `notifyListeners()` method is included within your service, any widgets that are referencing the service property through `BuildContext` will automatically be notified of the change. Additionally, a listener can be added to watch the value for changes. +You can also defer adding the service by invoking `ArcaneServiceProvider`. Note that this requires either `ArcaneServiceProvider` _or_ `ArcaneApp` (which already includes `ArcaneServiceProvider`) to be in your widget tree. + +```dart +// The service is not included at compile-time +ArcaneApp( + child: MainApp(), +), + +// Add the service at runtime +ArcaneServiceProvider.of(context).addService(FavoriteColorService.I); +``` + +Unregistering an already registered `ArcaneService` is as simple as: ```dart -FavoriteColorService.I.notifier.addListener(() { - final Color? color = FavoriteColorService.I.myFavoriteColor; - // Do something with the value +ArcaneServiceProvider.of(context).removeService() +``` + +### Locating an `ArcaneService` + +There are numerous ways to locate a registered `ArcaneService`. Feel free to use whatever method you prefer: + +```dart +// If a service of the given type is not registered, `null` is returned. +final FavoriteColorService? nullableService = ArcaneService.ofType(context); +final FavoriteColorService? nullableViaContext = context.service(); +final FavoriteColorService? nullableViaProvider = ArcaneServiceProvider.serviceOfType(context); + +// If a service of the given type is not registered, an exception is thrown. +final FavoriteColorService nonNullableService = ArcaneService.requiredOfType(context); +final FavoriteColorService nonNullableViaContext = context.requiredService(); +final FavoriteColorService nonNullableViaProvider = ArcaneServiceProvider.requiredServiceOfType(context); +``` + +In addition, you can locate a `ArcaneServiceProvider` in a similar way: + +```dart +// Returns `null` if no `ArcaneServiceProvider` is found in the widget tree. +final ArcaneServiceProvider? nullableProvider = ArcaneServiceProvider.maybeOf(context); + +// Throws an exception if no `ArcaneServiceProvider` is found in the widget tree. +final ArcaneServiceProvider nonNullableProvider = ArcaneServiceProvider.of(context); +``` + +### Using `ArcaneService` services + +Since the `ArcaneService` class includes a `ChangeNotifier`, invoking the `notifyListeners()` method inside a service will trigger a rebuild. Using our `FavoriteColorService` from earlier, we can add a listener to our notifier value: + +```dart +final FavoriteColorService service = ArcaneService.requiredOfType(context); + +service.notifier.addListener(() { + final Color? color = service.myFavoriteColor; + // Do something with our value }); ``` +We can also simply user a `ValueListenableBuilder`: + +```dart +ValueListenableBuilder( + valueListenable: ArcaneService.requiredOfType(context).notifier, + builder: (context, color, _) { + return Text("My favorite color is $color"), + } +) +``` + +Meanwhile, setting the value in our service can be accomplished in the following manner: + +```dart +ArcaneService.requiredOfType(context).setMyFavoriteColor(Colors.purple); +``` + +Again, this example is _not_ the only way the Arcane Service system can be utilized. One is limited only by their imagination! + ### Feature Flags You can easily manage feature flags using the `ArcaneFeatureFlags` built-in service. Feature flags are useful for enabling or disabling different parts of your application under different circumstances. For example, you may want to enable a new feature only once it has finished development and testing, while still having the ability to ship the unfinished code. You could also leverage feature flags to enable different modes within your application (e.g., "free" vs "paid"). Furthermore, they can be used for A/B testing. The options are truly unlimited. From 0caea70b2fbe4ade14fc6a876fc4500929381ef4 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 30 Apr 2025 11:45:20 +0200 Subject: [PATCH 14/58] Updated example to add a services example Signed-off-by: Hans Kokx --- example/lib/main.dart | 123 ++++++++++++++++-- .../lib/services/favorite_color_service.dart | 43 ++++++ test/providers/service_provider_test.dart | 7 +- 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 example/lib/services/favorite_color_service.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 64b97c1..017ff09 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,6 +5,7 @@ import "package:example/config.dart"; import "package:example/interfaces/debug_auth_interface.dart"; import "package:example/interfaces/debug_print_interface.dart"; import "package:example/services/demo_service.dart"; +import "package:example/services/favorite_color_service.dart"; import "package:example/theme/theme.dart"; import "package:flutter/material.dart"; @@ -81,8 +82,9 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { late final StreamSubscription _subscription; + late final StreamSubscription _serviceSubscription; final List latestLogs = []; - final List themeColors = [ + static const List colors = [ Colors.red, Colors.orange, Colors.yellow, @@ -94,7 +96,6 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { - final bool isSignedIn = Arcane.auth.isSignedIn.value; return Column( children: [ Expanded( @@ -192,7 +193,7 @@ class _HomeScreenState extends State { const Text("Color"), Expanded( child: ListView.separated( - itemCount: themeColors.length, + itemCount: colors.length, scrollDirection: Axis.horizontal, separatorBuilder: (_, __) => const SizedBox(width: 4), @@ -204,7 +205,7 @@ class _HomeScreenState extends State { Arcane.theme.setDarkTheme( ThemeData( brightness: Brightness.dark, - colorSchemeSeed: themeColors[index], + colorSchemeSeed: colors[index], ), ); } else if (Arcane @@ -213,17 +214,17 @@ class _HomeScreenState extends State { Arcane.theme.setLightTheme( ThemeData( brightness: Brightness.light, - colorSchemeSeed: themeColors[index], + colorSchemeSeed: colors[index], ), ); } Arcane.log( - "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${themeColors[index]}", + "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index]}", ); }, child: Container( - color: themeColors[index], + color: colors[index], width: 20, height: 20, ), @@ -259,7 +260,7 @@ class _HomeScreenState extends State { ElevatedButton( onPressed: Feature.authentication.enabled ? () async { - if (isSignedIn) { + if (Arcane.auth.isSignedIn.value) { await Arcane.auth.logOut( onLoggedOut: () async { setState(() {}); @@ -278,7 +279,9 @@ class _HomeScreenState extends State { } } : null, - child: Text(isSignedIn ? "Sign out" : "Sign in"), + child: Text( + Arcane.auth.isSignedIn.value ? "Sign out" : "Sign in", + ), ), Center( child: Text("Status: ${Arcane.auth.status.name}"), @@ -377,6 +380,108 @@ class _HomeScreenState extends State { ), ), ), + + // Services + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Services", + style: Theme.of(context).textTheme.headlineSmall, + ), + ValueListenableBuilder( + valueListenable: + ArcaneService.ofType( + context, + )?.notifier ?? + ValueNotifier(null), + builder: (context, color, _) { + return Text( + color != null + ? "Favorite color: ${color.name}" + : "", + ); + }, + ), + ElevatedButton( + onPressed: ArcaneServiceProvider.serviceOfType< + FavoriteColorService>(context) == + null + ? () { + ArcaneServiceProvider.of(context).addService( + FavoriteColorService.I, + ); + Arcane.log( + "Service registered.", + metadata: { + "service": "FavoriteColorService", + }, + ); + } + : () { + ArcaneServiceProvider.of(context) + .removeService(); + Arcane.log( + "Service removed.", + metadata: { + "service": "FavoriteColorService", + }, + ); + }, + child: Text( + '${ArcaneServiceProvider.serviceOfType(context) == null ? 'Register' : 'Remove'} service', + ), + ), + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text("Color"), + Expanded( + child: ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => + const SizedBox(width: 4), + itemBuilder: (context, index) { + return InkWell( + onTap: () { + ArcaneService.ofType< + FavoriteColorService>( + context, + )?.setMyFavoriteColor(colors[index]); + Arcane.log( + "Set a color in FavoriteColorService", + metadata: { + "color": colors[index].name, + }, + ); + }, + child: Container( + color: colors[index], + width: 20, + height: 20, + ), + ); + }, + ), + ), + ], + ), + ), + Text( + "Service is ${ArcaneService.ofType(context) != null ? "" : "not "}registered", + ), + ], + ), + ), + ), ], ), ), diff --git a/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart new file mode 100644 index 0000000..53b8fba --- /dev/null +++ b/example/lib/services/favorite_color_service.dart @@ -0,0 +1,43 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; + +class FavoriteColorService extends ArcaneService { + static final FavoriteColorService _instance = + FavoriteColorService._internal(); + + static FavoriteColorService get I => _instance; + + FavoriteColorService._internal(); + + MaterialColor? get myFavoriteColor => _notifier.value; + + final ValueNotifier _notifier = + ValueNotifier(null); + + ValueNotifier get notifier => _notifier; + + void setMyFavoriteColor(MaterialColor? newValue) { + if (_notifier.value != newValue) { + _notifier.value = newValue; + } + + notifyListeners(); + } +} + +extension ColorName on MaterialColor { + String get name { + final double red = double.parse(r.toStringAsFixed(4)); + final double green = double.parse(g.toStringAsFixed(4)); + final double blue = double.parse(b.toStringAsFixed(4)); + if (red == 0.9569 && green == 0.2627 && blue == 0.2118) return "red"; + if (red == 1 && green == 0.5961 && blue == 0) return "orange"; + if (red == 1 && green == 0.9216 && blue == 0.2314) return "yellow"; + if (red == 0.2980 && green == 0.6863 && blue == 0.3137) return "green"; + if (red == 0.1294 && green == 0.5882 && blue == 0.9529) return "blue"; + if (red == 0.6118 && green == 0.1529 && blue == 0.6902) return "indigo"; + if (red == 0.4039 && green == 0.2275 && blue == 0.7176) return "violet"; + + return ""; + } +} diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index bf2c4ca..fa10d76 100644 --- a/test/providers/service_provider_test.dart +++ b/test/providers/service_provider_test.dart @@ -194,7 +194,7 @@ void main() { services: testServices, child: Builder( builder: (context) { - final service = ArcaneService.of(context); + final service = ArcaneService.ofType(context); expect(service, isNotNull); expect(service, isA()); return const SizedBox(); @@ -212,11 +212,12 @@ void main() { child: Builder( builder: (context) { final service = - ArcaneService.requiredOf(context); + ArcaneService.requiredOfType(context); expect(service, isA()); expect( - () => ArcaneService.requiredOf(context), + () => + ArcaneService.requiredOfType(context), throwsA(isA()), ); From ad63b15826e15c4205e4d9fc90e69567a4e4c36d Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 30 Apr 2025 11:45:51 +0200 Subject: [PATCH 15/58] Removed unused variable Signed-off-by: Hans Kokx --- example/lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 017ff09..06891f6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -82,7 +82,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { late final StreamSubscription _subscription; - late final StreamSubscription _serviceSubscription; + final List latestLogs = []; static const List colors = [ Colors.red, From 1eab50b0f5d7efbd025aa4c9173f6fdfbe679cf9 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 30 Apr 2025 14:41:26 +0200 Subject: [PATCH 16/58] Update example documentation Signed-off-by: Hans Kokx --- example/lib/main.dart | 132 +++++++++++++++++-------- example/lib/services/demo_service.dart | 33 ------- lib/src/arcane.dart | 4 + 3 files changed, 94 insertions(+), 75 deletions(-) delete mode 100644 example/lib/services/demo_service.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 06891f6..c55028b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,51 +4,54 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:example/config.dart"; import "package:example/interfaces/debug_auth_interface.dart"; import "package:example/interfaces/debug_print_interface.dart"; -import "package:example/services/demo_service.dart"; import "package:example/services/favorite_color_service.dart"; import "package:example/theme/theme.dart"; import "package:flutter/material.dart"; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - + // If any Feature enum items are `enabledAtStartup`, enable them within Arcane. for (final Feature feature in Feature.values) { if (feature.enabledAtStartup) Arcane.features.enableFeature(feature); } - await Future.wait([ - Arcane.logger.registerInterfaces([ - DebugPrint.I, - ]), - IdService.I.init(), - ]); + // Register the logging interface + await Arcane.logger.registerInterface(DebugPrint.I); + // Add some persistent metadata to be used in every future log message Arcane.logger.addPersistentMetadata({ - "session_id": IdService.I.sessionId.value, + "demo": "This message will be included in all log messages.", }); + // Register the authentication interface await Arcane.auth.registerInterface(DebugAuthInterface.I); + // Set the light and dark mode themes using our pre-defined ThemeData classes Arcane.theme - ..setDarkTheme(darkTheme) - ..setLightTheme(lightTheme); + ..setLightTheme(lightTheme) + ..setDarkTheme(darkTheme); + // Log a message that the app has been initialized Arcane.log( "Initialization complete.", + // Set an appropriate log level level: Level.info, + // The `module` and `method` are _often_ automatically determined, but they can be overridden. module: "main", method: "main", + // Skip autodetction of the `module`, `method`, and file/line number where logs originated from. + skipAutodetection: true, + // Add some optional metadata to be included in this single log message. This is added to the + // persistent metadata, if any has been set. metadata: { "ready": "true", }, ); runApp( - ArcaneApp( - services: [ - IdService.I, - ], - child: const MainApp(), + // The `ArcaneApp` widget is optional but provides the `ArcaneEnvironmentProvider`, + // `ArcaneServiceProvider`, and `ArcaneThemeSwitcher` widgets. + const ArcaneApp( + child: MainApp(), ), ); } @@ -59,9 +62,13 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - debugShowCheckedModeBanner: false, + // Use the light and dark theme objects registered in Arcane. If either style is + // updated in Arcane, the changes will reflect here. This allows for on-the-fly + // customizations without requiring compile-time themes to be pre-defined. theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, + // By fetching the current ThemeMode from Arcane, the app will automatically rebuild + // when the theme is switched, either manually or automatically. themeMode: Arcane.theme.currentModeOf(context), home: Scaffold( appBar: AppBar( @@ -81,9 +88,37 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - late final StreamSubscription _subscription; + // Set up a subscriber that we can use to listen to logs in realtime. + // Note: this is completely optional and does _not_ impact whether logs are + // sent to any registered logging interfaces. + late final StreamSubscription _logStreamSubscriber; + // Used to collect the logs from the stream. final List latestLogs = []; + + @override + void initState() { + super.initState(); + // Listens to the Arcane logger stream of logs and adds them to the latestLogs list. + _logStreamSubscriber = Arcane.logger.logStream.listen((message) { + // If [Feature.logging] is disabled, we won't add the logs to the list or trigger + // a rebuild. + if (Feature.logging.enabled) { + setState(() { + latestLogs.insert(0, message); + }); + } + }); + } + + @override + void dispose() { + // Don't forget to properly dispose of the subscriber + _logStreamSubscriber.cancel(); + super.dispose(); + } + + // Some colors we'll use for our example static const List colors = [ Colors.red, Colors.orange, @@ -103,7 +138,11 @@ class _HomeScreenState extends State { maxCrossAxisExtent: 300, padding: const EdgeInsets.all(16), children: [ - // Theme + // * Theme + // Arcane enables easy, dynamic theme switching. Themes can be switched + // at any time between light mode and dark mode, or set to follow the + // system theme. In addition, themes can be swapped out on-the-fly, + // enabling dynamic customizations and remote theme fetching. Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -220,7 +259,7 @@ class _HomeScreenState extends State { } Arcane.log( - "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index]}", + "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", ); }, child: Container( @@ -245,7 +284,12 @@ class _HomeScreenState extends State { ), ), - // Authentication + // * Authentication + // Arcane's authentication system provides a simple, standard interface + // for common authentication tasks - including registration and account + // management, logging in and out, etc. Authentication status is reflected + // in realtime within the application as changes happen, so you can focus + // on what's most important. Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -291,7 +335,13 @@ class _HomeScreenState extends State { ), ), - // Feature flags + // * Feature flags + // Arcane's feature flag system is extremely simple and flexible to use. + // By registering _any_ enum (or even multiple enums!), features can be + // toggled on and off at any point. The feature flag system even offers + // a notifier, so you can listen to changes as they happen. Fetch your + // remote config and use it to dynamically enable and disable features + // with ease! Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -330,7 +380,11 @@ class _HomeScreenState extends State { ), ), - // Environment + // * Environment + // Quickly and easily toggle between a "normal" and "debug" environment + // within your application. This is particularly useful during development + // when you may want to change the behavior of the application under + // certain conditions. Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -381,7 +435,10 @@ class _HomeScreenState extends State { ), ), - // Services + // * Services + // Arcane's services system is flexible and minimal, leaving the power + // and control in developers' hands. This system powers much of Arcane + // internally, so you know it's reliable. Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -486,7 +543,14 @@ class _HomeScreenState extends State { ), ), - // Logging + // * Logging + // Arcane's logging system gives developers the power to dynamically add and + // remove logging interfaces on-the-fly: try enabling a debug logging interface + // when the app is running in debug mode, adding a third-party logging interface + // when in production, and waiting until after the user has gone through the + // login process to ask them for permission to track. Include useful metadata, + // including persistent metadata, in your log messages. All of these things, and + // more, are possible when using Arcane's logging system. Padding( padding: const EdgeInsets.all(16.0), child: SizedBox( @@ -527,20 +591,4 @@ class _HomeScreenState extends State { ], ); } - - @override - void initState() { - super.initState(); - _subscription = Arcane.logger.logStream.listen((message) { - setState(() { - if (Feature.logging.enabled) latestLogs.insert(0, message); - }); - }); - } - - @override - void dispose() { - _subscription.cancel(); - super.dispose(); - } } diff --git a/example/lib/services/demo_service.dart b/example/lib/services/demo_service.dart deleted file mode 100644 index d4f7f3e..0000000 --- a/example/lib/services/demo_service.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "package:arcane_framework/arcane_framework.dart"; -import "package:flutter/foundation.dart"; -import "package:uuid/uuid.dart"; - -class IdService extends ArcaneService { - static final IdService _instance = IdService._internal(); - static IdService get I => _instance; - - IdService._internal(); - - bool _initialized = false; - bool get initialized => I._initialized; - - String? _sessionId; - ValueListenable get sessionId => - ValueNotifier(I._sessionId); - - String get newId => uuid.v7(); - - /// The `Uuid` instance used for generating unique IDs. - static const Uuid uuid = Uuid(); - - Future init() async { - Arcane.log( - "Initializing ID Service", - level: Level.debug, - ); - - I._sessionId = uuid.v7(); - I._initialized = true; - notifyListeners(); - } -} diff --git a/lib/src/arcane.dart b/lib/src/arcane.dart index 557a2bd..3ebe1a0 100644 --- a/lib/src/arcane.dart +++ b/lib/src/arcane.dart @@ -58,6 +58,8 @@ abstract class Arcane { /// - [stackTrace]: Optional stack trace information. /// - [metadata]: Optional additional metadata in key-value pairs. /// - [extra]: Optional data passed to the logger. + /// - [skipAutodetection]: Bypass automatically determining the module, method, + /// and file/line number of log messages. static void log( String message, { String? module, @@ -66,6 +68,7 @@ abstract class Arcane { StackTrace? stackTrace, Map? metadata, Object? extra, + bool skipAutodetection = false, }) { ArcaneLogger.I.log( message, @@ -75,6 +78,7 @@ abstract class Arcane { stackTrace: stackTrace, metadata: metadata, extra: extra, + skipAutodetection: skipAutodetection, ); } } From 2f18c4213f730216de054b1c4c88c1932ae5af11 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 30 Apr 2025 14:52:41 +0200 Subject: [PATCH 17/58] Update example Signed-off-by: Hans Kokx --- example/lib/main.dart | 23 +++++++++++++++--- .../lib/services/favorite_color_service.dart | 24 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index c55028b..d892e26 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -263,7 +263,14 @@ class _HomeScreenState extends State { ); }, child: Container( - color: colors[index], + decoration: BoxDecoration( + color: colors[index], + border: Arcane.theme.currentTheme + .colorScheme.primary.name == + colors[index].name + ? Border.all(width: 2) + : null, + ), width: 20, height: 20, ), @@ -516,12 +523,22 @@ class _HomeScreenState extends State { Arcane.log( "Set a color in FavoriteColorService", metadata: { - "color": colors[index].name, + "color": + colors[index].name ?? "Unknown", }, ); }, child: Container( - color: colors[index], + decoration: BoxDecoration( + color: colors[index], + border: ArcaneService.ofType< + FavoriteColorService>( + context, + )?.myFavoriteColor?.name == + colors[index].name + ? Border.all(width: 2) + : null, + ), width: 20, height: 20, ), diff --git a/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart index 53b8fba..c9f2193 100644 --- a/example/lib/services/favorite_color_service.dart +++ b/example/lib/services/favorite_color_service.dart @@ -25,8 +25,8 @@ class FavoriteColorService extends ArcaneService { } } -extension ColorName on MaterialColor { - String get name { +extension MaterialColorName on MaterialColor { + String? get name { final double red = double.parse(r.toStringAsFixed(4)); final double green = double.parse(g.toStringAsFixed(4)); final double blue = double.parse(b.toStringAsFixed(4)); @@ -38,6 +38,24 @@ extension ColorName on MaterialColor { if (red == 0.6118 && green == 0.1529 && blue == 0.6902) return "indigo"; if (red == 0.4039 && green == 0.2275 && blue == 0.7176) return "violet"; - return ""; + return null; + } +} + +extension ColorName on Color { + String? get name { + final double red = double.parse(r.toStringAsFixed(4)); + final double green = double.parse(g.toStringAsFixed(4)); + final double blue = double.parse(b.toStringAsFixed(4)); + + if (red == 0.5647 && green == 0.2902 && blue == 0.2588) return "red"; + if (red == 0.5216 && green == 0.3255 && blue == 0.0941) return "orange"; + if (red == 0.4078 && green == 0.3725 && blue == 0.0706) return "yellow"; + if (red == 0.2314 && green == 0.4118 && blue == 0.2235) return "green"; + if (red == 0.2118 && green == 0.3804 && blue == 0.5569) return "blue"; + if (red == 0.4824 && green == 0.3059 && blue == 0.498) return "indigo"; + if (red == 0.4078 && green == 0.3294 && blue == 0.5569) return "violet"; + + return null; } } From 68ce73abf5d0bd4154b0e438a7d0965b31546897 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 30 Apr 2025 15:04:14 +0200 Subject: [PATCH 18/58] Breaking up example into smaller widgets Signed-off-by: Hans Kokx --- example/lib/config.dart | 13 + example/lib/main.dart | 947 ++++++++++++++++++++-------------------- 2 files changed, 494 insertions(+), 466 deletions(-) diff --git a/example/lib/config.dart b/example/lib/config.dart index fc094c0..8aa998a 100644 --- a/example/lib/config.dart +++ b/example/lib/config.dart @@ -1,3 +1,5 @@ +import "package:flutter/material.dart"; + enum Feature { logging(true), authentication(true), @@ -6,3 +8,14 @@ enum Feature { final bool enabledAtStartup; const Feature(this.enabledAtStartup); } + +// Some colors we'll use for our example +const List colors = [ + Colors.red, + Colors.orange, + Colors.yellow, + Colors.green, + Colors.blue, + Colors.purple, + Colors.deepPurple, +]; diff --git a/example/lib/main.dart b/example/lib/main.dart index d892e26..c10f960 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -74,20 +74,47 @@ class MainApp extends StatelessWidget { appBar: AppBar( title: const Text("Arcane Framework Example"), ), - body: const HomeScreen(), + body: Column( + children: [ + Expanded( + child: GridView.extent( + maxCrossAxisExtent: 300, + padding: const EdgeInsets.all(16), + children: const [ + ArcaneThemeExample(), + ArcaneAuthExample(), + ArcaneFeatureFlagsExample(), + ArcaneEnvironmentExample(), + ArcaneServicesExample(), + ], + ), + ), + const ArcaneLoggingExample(), + ], + ), ), ); } } -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); +// * Logging +// Arcane's logging system gives developers the power to dynamically add and +// remove logging interfaces on-the-fly: try enabling a debug logging interface +// when the app is running in debug mode, adding a third-party logging interface +// when in production, and waiting until after the user has gone through the +// login process to ask them for permission to track. Include useful metadata, +// including persistent metadata, in your log messages. All of these things, and +// more, are possible when using Arcane's logging system. +class ArcaneLoggingExample extends StatefulWidget { + const ArcaneLoggingExample({ + super.key, + }); @override - State createState() => _HomeScreenState(); + State createState() => _ArcaneLoggingExampleState(); } -class _HomeScreenState extends State { +class _ArcaneLoggingExampleState extends State { // Set up a subscriber that we can use to listen to logs in realtime. // Note: this is completely optional and does _not_ impact whether logs are // sent to any registered logging interfaces. @@ -118,494 +145,482 @@ class _HomeScreenState extends State { super.dispose(); } - // Some colors we'll use for our example - static const List colors = [ - Colors.red, - Colors.orange, - Colors.yellow, - Colors.green, - Colors.blue, - Colors.purple, - Colors.deepPurple, - ]; - @override Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: GridView.extent( - maxCrossAxisExtent: 300, - padding: const EdgeInsets.all(16), - children: [ - // * Theme - // Arcane enables easy, dynamic theme switching. Themes can be switched - // at any time between light mode and dark mode, or set to follow the - // system theme. In addition, themes can be swapped out on-the-fly, - // enabling dynamic customizations and remote theme fetching. - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Theme", - style: Theme.of(context).textTheme.headlineSmall, - ), - Column( - children: [ - Switch( - value: - Arcane.theme.currentThemeMode == ThemeMode.dark, - thumbIcon: - WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const Icon(Icons.dark_mode); - } - return const Icon(Icons.light_mode); - }), - onChanged: (_) { - final ThemeMode oldTheme = - Arcane.theme.currentThemeMode; - Arcane.theme.switchTheme(); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); - }, - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: Arcane.theme.isFollowingSystemTheme, - onChanged: (value) { - final ThemeMode oldTheme = - Arcane.theme.currentThemeMode; - if (value == true) { - Arcane.theme.followSystemTheme(context); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": - Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); - } else { - Arcane.theme.switchTheme( - themeMode: Arcane.theme.systemThemeMode, - ); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": - Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); - } - }, - ), - const Text("Follow system"), - ], - ), - ], - ), - SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8, - children: [ - const Text("Color"), - Expanded( - child: ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => - const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - if (Arcane.theme.currentThemeMode == - ThemeMode.dark) { - Arcane.theme.setDarkTheme( - ThemeData( - brightness: Brightness.dark, - colorSchemeSeed: colors[index], - ), - ); - } else if (Arcane - .theme.currentThemeMode == - ThemeMode.light) { - Arcane.theme.setLightTheme( - ThemeData( - brightness: Brightness.light, - colorSchemeSeed: colors[index], - ), - ); - } + return Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Logging", + style: Theme.of(context).textTheme.headlineSmall, + ), + if (latestLogs.isEmpty) + Text( + "Log messages will appear here", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + if (Feature.logging.disabled) + Text( + "Logging feature is disabled.", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: ListView.builder( + itemCount: latestLogs.length, + itemBuilder: (context, index) { + return Text(latestLogs[index]); + }, + ), + ), + ], + ), + ), + ); + } +} + +// * Authentication +// Arcane's authentication system provides a simple, standard interface +// for common authentication tasks - including registration and account +// management, logging in and out, etc. Authentication status is reflected +// in realtime within the application as changes happen, so you can focus +// on what's most important. +class ArcaneAuthExample extends StatelessWidget { + const ArcaneAuthExample({ + super.key, + }); - Arcane.log( - "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", - ); - }, - child: Container( - decoration: BoxDecoration( - color: colors[index], - border: Arcane.theme.currentTheme - .colorScheme.primary.name == - colors[index].name - ? Border.all(width: 2) - : null, - ), - width: 20, - height: 20, - ), - ); - }, + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ValueListenableBuilder( + valueListenable: Arcane.auth.isSignedIn, + builder: (context, isSignedIn, _) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Authentication", + style: Theme.of(context).textTheme.headlineSmall, + ), + ElevatedButton( + onPressed: Feature.authentication.enabled + ? () async { + if (isSignedIn) { + await Arcane.auth.logOut(); + } else { + await Arcane.auth.login( + input: ( + email: "email", + password: "password", ), - ), - ], - ), - ), - Text( - "The current theme mode is ${Arcane.theme.currentModeOf(context).name} and " - "is ${Arcane.theme.isFollowingSystemTheme ? "" : "not "}" - "following the system theme.", - ), - ], + ); + } + } + : null, + child: Text( + isSignedIn ? "Sign out" : "Sign in", ), ), - ), - - // * Authentication - // Arcane's authentication system provides a simple, standard interface - // for common authentication tasks - including registration and account - // management, logging in and out, etc. Authentication status is reflected - // in realtime within the application as changes happen, so you can focus - // on what's most important. - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Authentication", - style: Theme.of(context).textTheme.headlineSmall, - ), - ElevatedButton( - onPressed: Feature.authentication.enabled - ? () async { - if (Arcane.auth.isSignedIn.value) { - await Arcane.auth.logOut( - onLoggedOut: () async { - setState(() {}); - }, - ); - } else { - await Arcane.auth.login( - input: ( - email: "email", - password: "password", - ), - onLoggedIn: () async { - setState(() {}); - }, - ); - } - } - : null, - child: Text( - Arcane.auth.isSignedIn.value ? "Sign out" : "Sign in", - ), - ), - Center( - child: Text("Status: ${Arcane.auth.status.name}"), - ), - ], - ), + Center( + child: Text("Status: ${Arcane.auth.status.name}"), ), - ), + ], + ); + }, + ), + ), + ); + } +} - // * Feature flags - // Arcane's feature flag system is extremely simple and flexible to use. - // By registering _any_ enum (or even multiple enums!), features can be - // toggled on and off at any point. The feature flag system even offers - // a notifier, so you can listen to changes as they happen. Fetch your - // remote config and use it to dynamically enable and disable features - // with ease! - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Feature Flags", - style: Theme.of(context).textTheme.headlineSmall, - ), - Expanded( - child: ListView.builder( - itemCount: Feature.values.length, - itemBuilder: (context, i) { - final Feature feature = Feature.values[i]; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(feature.name), - Switch( - value: feature.enabled, - onChanged: (_) { - feature.enabled - ? feature.disable() - : feature.enable(); - }, - ), - ], - ); - }, - ), - ), - ], - ), +// * Theme +// Arcane enables easy, dynamic theme switching. Themes can be switched +// at any time between light mode and dark mode, or set to follow the +// system theme. In addition, themes can be swapped out on-the-fly, +// enabling dynamic customizations and remote theme fetching. +class ArcaneThemeExample extends StatelessWidget { + const ArcaneThemeExample({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Theme", + style: Theme.of(context).textTheme.headlineSmall, + ), + Column( + children: [ + Switch( + value: Arcane.theme.currentThemeMode == ThemeMode.dark, + thumbIcon: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Icon(Icons.dark_mode); + } + return const Icon(Icons.light_mode); + }), + onChanged: (_) { + final ThemeMode oldTheme = Arcane.theme.currentThemeMode; + Arcane.theme.switchTheme(); + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + "oldMode": oldTheme.name, + }, + ); + }, ), - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: Arcane.theme.isFollowingSystemTheme, + onChanged: (value) { + final ThemeMode oldTheme = + Arcane.theme.currentThemeMode; + if (value == true) { + Arcane.theme.followSystemTheme(context); + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + "oldMode": oldTheme.name, + }, + ); + } else { + Arcane.theme.switchTheme( + themeMode: Arcane.theme.systemThemeMode, + ); + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + "oldMode": oldTheme.name, + }, + ); + } + }, + ), + const Text("Follow system"), + ], + ), + ], + ), + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text("Color"), + Expanded( + child: ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => const SizedBox(width: 4), + itemBuilder: (context, index) { + return InkWell( + onTap: () { + if (Arcane.theme.currentThemeMode == + ThemeMode.dark) { + Arcane.theme.setDarkTheme( + ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: colors[index], + ), + ); + } else if (Arcane.theme.currentThemeMode == + ThemeMode.light) { + Arcane.theme.setLightTheme( + ThemeData( + brightness: Brightness.light, + colorSchemeSeed: colors[index], + ), + ); + } - // * Environment - // Quickly and easily toggle between a "normal" and "debug" environment - // within your application. This is particularly useful during development - // when you may want to change the behavior of the application under - // certain conditions. - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Environment", - style: Theme.of(context).textTheme.headlineSmall, - ), - ElevatedButton( - onPressed: () { - final Environment currentEnvironment = - ArcaneEnvironment.of(context).environment; - if (currentEnvironment == Environment.normal) { - ArcaneEnvironment.of(context).enableDebugMode(); Arcane.log( - "Environment changed.", - metadata: { - "previous": ArcaneEnvironment.of(context) - .environment - .name, - "current": Environment.debug.name, - }, + "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", ); - } else { - ArcaneEnvironment.of(context).disableDebugMode(); - Arcane.log( - "Environment changed.", - metadata: { - "previous": ArcaneEnvironment.of(context) - .environment - .name, - "current": Environment.normal.name, - }, - ); - } - }, - child: const Text("Switch environment"), - ), - Text( - "Environment: ${ArcaneEnvironment.of(context).environment.name}", - textAlign: TextAlign.center, - ), - ], + }, + child: Container( + decoration: BoxDecoration( + color: colors[index], + border: Arcane.theme.currentTheme.colorScheme + .primary.name == + colors[index].name + ? Border.all(width: 2) + : null, + ), + width: 20, + height: 20, + ), + ); + }, + ), ), - ), + ], ), + ), + Text( + "The current theme mode is ${Arcane.theme.currentModeOf(context).name} and " + "is ${Arcane.theme.isFollowingSystemTheme ? "" : "not "}" + "following the system theme.", + ), + ], + ), + ), + ); + } +} + +// * Feature flags +// Arcane's feature flag system is extremely simple and flexible to use. +// By registering _any_ enum (or even multiple enums!), features can be +// toggled on and off at any point. The feature flag system even offers +// a notifier, so you can listen to changes as they happen. Fetch your +// remote config and use it to dynamically enable and disable features +// with ease! +class ArcaneFeatureFlagsExample extends StatelessWidget { + const ArcaneFeatureFlagsExample({ + super.key, + }); - // * Services - // Arcane's services system is flexible and minimal, leaving the power - // and control in developers' hands. This system powers much of Arcane - // internally, so you know it's reliable. - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Feature Flags", + style: Theme.of(context).textTheme.headlineSmall, + ), + Expanded( + child: ListView.builder( + itemCount: Feature.values.length, + itemBuilder: (context, i) { + final Feature feature = Feature.values[i]; + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - "Services", - style: Theme.of(context).textTheme.headlineSmall, - ), - ValueListenableBuilder( - valueListenable: - ArcaneService.ofType( - context, - )?.notifier ?? - ValueNotifier(null), - builder: (context, color, _) { - return Text( - color != null - ? "Favorite color: ${color.name}" - : "", - ); + Text(feature.name), + Switch( + value: feature.enabled, + onChanged: (_) { + feature.enabled + ? feature.disable() + : feature.enable(); }, ), - ElevatedButton( - onPressed: ArcaneServiceProvider.serviceOfType< - FavoriteColorService>(context) == - null - ? () { - ArcaneServiceProvider.of(context).addService( - FavoriteColorService.I, - ); - Arcane.log( - "Service registered.", - metadata: { - "service": "FavoriteColorService", - }, - ); - } - : () { - ArcaneServiceProvider.of(context) - .removeService(); - Arcane.log( - "Service removed.", - metadata: { - "service": "FavoriteColorService", - }, - ); - }, - child: Text( - '${ArcaneServiceProvider.serviceOfType(context) == null ? 'Register' : 'Remove'} service', - ), - ), - SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8, - children: [ - const Text("Color"), - Expanded( - child: ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => - const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - ArcaneService.ofType< - FavoriteColorService>( - context, - )?.setMyFavoriteColor(colors[index]); - Arcane.log( - "Set a color in FavoriteColorService", - metadata: { - "color": - colors[index].name ?? "Unknown", - }, - ); - }, - child: Container( - decoration: BoxDecoration( - color: colors[index], - border: ArcaneService.ofType< - FavoriteColorService>( - context, - )?.myFavoriteColor?.name == - colors[index].name - ? Border.all(width: 2) - : null, - ), - width: 20, - height: 20, - ), - ); - }, - ), - ), - ], - ), - ), - Text( - "Service is ${ArcaneService.ofType(context) != null ? "" : "not "}registered", - ), ], - ), - ), + ); + }, ), - ], - ), + ), + ], ), + ), + ); + } +} - // * Logging - // Arcane's logging system gives developers the power to dynamically add and - // remove logging interfaces on-the-fly: try enabling a debug logging interface - // when the app is running in debug mode, adding a third-party logging interface - // when in production, and waiting until after the user has gone through the - // login process to ask them for permission to track. Include useful metadata, - // including persistent metadata, in your log messages. All of these things, and - // more, are possible when using Arcane's logging system. - Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - height: 200, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Logging", - style: Theme.of(context).textTheme.headlineSmall, - ), - if (latestLogs.isEmpty) - Text( - "Log messages will appear here", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - if (Feature.logging.disabled) - Text( - "Logging feature is disabled.", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: ListView.builder( - itemCount: latestLogs.length, - itemBuilder: (context, index) { - return Text(latestLogs[index]); +// * Environment +// Quickly and easily toggle between a "normal" and "debug" environment +// within your application. This is particularly useful during development +// when you may want to change the behavior of the application under +// certain conditions. +class ArcaneEnvironmentExample extends StatelessWidget { + const ArcaneEnvironmentExample({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Environment", + style: Theme.of(context).textTheme.headlineSmall, + ), + ElevatedButton( + onPressed: () { + final Environment currentEnvironment = + ArcaneEnvironment.of(context).environment; + if (currentEnvironment == Environment.normal) { + ArcaneEnvironment.of(context).enableDebugMode(); + Arcane.log( + "Environment changed.", + metadata: { + "previous": + ArcaneEnvironment.of(context).environment.name, + "current": Environment.debug.name, + }, + ); + } else { + ArcaneEnvironment.of(context).disableDebugMode(); + Arcane.log( + "Environment changed.", + metadata: { + "previous": + ArcaneEnvironment.of(context).environment.name, + "current": Environment.normal.name, }, + ); + } + }, + child: const Text("Switch environment"), + ), + Text( + "Environment: ${ArcaneEnvironment.of(context).environment.name}", + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +/// * Services +/// Arcane's services system is flexible and minimal, leaving the power +/// and control in developers' hands. This system powers much of Arcane +/// internally, so you know it's reliable. +class ArcaneServicesExample extends StatelessWidget { + const ArcaneServicesExample({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final FavoriteColorService? service = + ArcaneServiceProvider.serviceOfType(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Services", + style: Theme.of(context).textTheme.headlineSmall, + ), + ValueListenableBuilder( + valueListenable: ArcaneService.ofType( + context, + )?.notifier ?? + ValueNotifier(null), + builder: (context, color, _) { + return Text( + color != null ? "Favorite color: ${color.name}" : "", + ); + }, + ), + ElevatedButton( + onPressed: () { + if (service == null) { + ArcaneServiceProvider.of(context).addService( + FavoriteColorService.I, + ); + + Arcane.log( + "Service registered.", + metadata: {"service": "FavoriteColorService"}, + ); + } else { + ArcaneServiceProvider.of(context) + .removeService(); + + Arcane.log( + "Service removed.", + metadata: {"service": "FavoriteColorService"}, + ); + } + }, + child: Text('${service == null ? 'Register' : 'Remove'} service'), + ), + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text("Color"), + Expanded( + child: ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => const SizedBox(width: 4), + itemBuilder: (context, index) { + return InkWell( + onTap: () { + service?.setMyFavoriteColor(colors[index]); + Arcane.log( + "Set a color in FavoriteColorService", + metadata: { + "color": colors[index].name ?? "Unknown", + }, + ); + }, + child: Container( + decoration: BoxDecoration( + color: colors[index], + border: service?.myFavoriteColor?.name == + colors[index].name + ? Border.all(width: 2) + : null, + ), + width: 20, + height: 20, + ), + ); + }, + ), ), - ), - ], + ], + ), ), - ), + Text( + "Service is ${service != null ? "" : "not "}registered", + ), + ], ), - ], + ), ); } } From e6646d308c14581dca0800f9c079b1d56080e1b7 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 2 May 2025 08:51:56 +0200 Subject: [PATCH 19/58] Remove bloc dependency Signed-off-by: Hans Kokx --- pubspec.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 5bf8d2a..9459b68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,11 +12,10 @@ environment: flutter: ">=1.17.0" dependencies: - arcane_helper_utils: ^1.3.2 + arcane_helper_utils: ^1.4.1 collection: ^1.19.0 flutter: sdk: flutter - flutter_bloc: ^9.1.0 result_monad: ^2.3.2 dev_dependencies: From 515c7fb5b195c79c0f90b9ba74853dd851bb5f49 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 13 May 2025 14:31:21 +0200 Subject: [PATCH 20/58] Fixes the notifier for feature flags and updates the example Signed-off-by: Hans Kokx --- example/lib/main.dart | 237 ++++++++++-------- .../feature_flags/feature_flags_service.dart | 20 +- 2 files changed, 138 insertions(+), 119 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index c10f960..fbf0780 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -147,42 +147,47 @@ class _ArcaneLoggingExampleState extends State { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - height: 200, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Logging", - style: Theme.of(context).textTheme.headlineSmall, - ), - if (latestLogs.isEmpty) - Text( - "Log messages will appear here", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - if (Feature.logging.disabled) - Text( - "Logging feature is disabled.", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: ListView.builder( - itemCount: latestLogs.length, - itemBuilder: (context, index) { - return Text(latestLogs[index]); - }, - ), + return ValueListenableBuilder( + valueListenable: Arcane.features.notifier, + builder: (context, enabledFeatures, _) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Logging", + style: Theme.of(context).textTheme.headlineSmall, + ), + if (latestLogs.isEmpty) + Text( + "Log messages will appear here", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + if (Feature.logging.disabled) + Text( + "Logging feature is disabled.", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: ListView.builder( + itemCount: latestLogs.length, + itemBuilder: (context, index) { + return Text(latestLogs[index]); + }, + ), + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } @@ -200,47 +205,52 @@ class ArcaneAuthExample extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ValueListenableBuilder( - valueListenable: Arcane.auth.isSignedIn, - builder: (context, isSignedIn, _) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Authentication", - style: Theme.of(context).textTheme.headlineSmall, - ), - ElevatedButton( - onPressed: Feature.authentication.enabled - ? () async { - if (isSignedIn) { - await Arcane.auth.logOut(); - } else { - await Arcane.auth.login( - input: ( - email: "email", - password: "password", - ), - ); - } - } - : null, - child: Text( - isSignedIn ? "Sign out" : "Sign in", - ), - ), - Center( - child: Text("Status: ${Arcane.auth.status.name}"), - ), - ], - ); - }, - ), - ), + return ValueListenableBuilder( + valueListenable: Arcane.features.notifier, + builder: (context, enabledFeatures, _) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ValueListenableBuilder( + valueListenable: Arcane.auth.isSignedIn, + builder: (context, isSignedIn, _) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Authentication", + style: Theme.of(context).textTheme.headlineSmall, + ), + ElevatedButton( + onPressed: Feature.authentication.enabled + ? () async { + if (isSignedIn) { + await Arcane.auth.logOut(); + } else { + await Arcane.auth.login( + input: ( + email: "email", + password: "password", + ), + ); + } + } + : null, + child: Text( + isSignedIn ? "Sign out" : "Sign in", + ), + ), + Center( + child: Text("Status: ${Arcane.auth.status.name}"), + ), + ], + ); + }, + ), + ), + ); + }, ); } } @@ -414,42 +424,47 @@ class ArcaneFeatureFlagsExample extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Feature Flags", - style: Theme.of(context).textTheme.headlineSmall, - ), - Expanded( - child: ListView.builder( - itemCount: Feature.values.length, - itemBuilder: (context, i) { - final Feature feature = Feature.values[i]; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(feature.name), - Switch( - value: feature.enabled, - onChanged: (_) { - feature.enabled - ? feature.disable() - : feature.enable(); - }, - ), - ], - ); - }, - ), + return ValueListenableBuilder( + valueListenable: Arcane.features.notifier, + builder: (context, enabledFeatures, _) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Feature Flags", + style: Theme.of(context).textTheme.headlineSmall, + ), + Expanded( + child: ListView.builder( + itemCount: Feature.values.length, + itemBuilder: (context, i) { + final Feature feature = Feature.values[i]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(feature.name), + Switch( + value: feature.enabled, + onChanged: (_) { + feature.enabled + ? feature.disable() + : feature.enable(); + }, + ), + ], + ); + }, + ), + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/lib/src/services/feature_flags/feature_flags_service.dart b/lib/src/services/feature_flags/feature_flags_service.dart index 99a1512..ec04657 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -70,8 +70,7 @@ class ArcaneFeatureFlags extends ArcaneService { if (_enabledFeatures.contains(feature)) return I; - _enabledFeatures.add(feature); - _notifier.value.add(feature); + _notifier.value = [..._enabledFeatures, feature]; if (Arcane.logger.initialized) { Arcane.logger.log( @@ -100,8 +99,7 @@ class ArcaneFeatureFlags extends ArcaneService { if (!I._initialized) _init(); if (!_enabledFeatures.contains(feature)) return I; - _enabledFeatures.remove(feature); - _notifier.value.remove(feature); + _notifier.value = [..._enabledFeatures]..removeWhere((i) => i == feature); if (Arcane.logger.initialized) { Arcane.logger.log( @@ -123,8 +121,9 @@ class ArcaneFeatureFlags extends ArcaneService { /// It is called automatically when enabling or disabling features if they haven't /// already been initialized. void _init() { - _enabledFeatures.clear(); - _notifier.value.clear(); + _notifier.value = []; + + notifier.addListener(_listener); I._initialized = true; notifyListeners(); @@ -135,10 +134,15 @@ class ArcaneFeatureFlags extends ArcaneService { /// This method clears all enabled features, resets notification values, /// marks the flags as uninitialized, and notifies listeners of the changes. void reset() { - _enabledFeatures.clear(); - _notifier.value.clear(); + _notifier.value = []; I._initialized = false; notifyListeners(); } + + void _listener() { + _enabledFeatures + ..clear() + ..addAll(notifier.value); + } } From 0c5568ab74c5d8c66d781525bfc123d9ad0a7dca Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 13 May 2025 14:41:24 +0200 Subject: [PATCH 21/58] Fixed a bug in the example Signed-off-by: Hans Kokx --- example/lib/main.dart | 242 ++++++++++++++++++++++-------------------- 1 file changed, 127 insertions(+), 115 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index fbf0780..348a38f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -152,38 +152,43 @@ class _ArcaneLoggingExampleState extends State { builder: (context, enabledFeatures, _) { return Padding( padding: const EdgeInsets.all(16.0), - child: SizedBox( - height: 200, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Logging", - style: Theme.of(context).textTheme.headlineSmall, - ), - if (latestLogs.isEmpty) - Text( - "Log messages will appear here", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - if (Feature.logging.disabled) - Text( - "Logging feature is disabled.", - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: ListView.builder( - itemCount: latestLogs.length, - itemBuilder: (context, index) { - return Text(latestLogs[index]); - }, - ), + child: Card( + child: SizedBox( + height: MediaQuery.sizeOf(context).height / 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Logging", + style: Theme.of(context).textTheme.headlineSmall, + ), + if (latestLogs.isEmpty) + Text( + "Log messages will appear here", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + if (Feature.logging.disabled) + Text( + "Logging feature is disabled.", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: ListView.builder( + itemCount: latestLogs.length, + itemBuilder: (context, index) { + return Text(latestLogs[index]); + }, + ), + ), + ], ), - ], + ), ), ), ); @@ -544,98 +549,105 @@ class ArcaneServicesExample extends StatelessWidget { Widget build(BuildContext context) { final FavoriteColorService? service = ArcaneServiceProvider.serviceOfType(context); - return Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Services", - style: Theme.of(context).textTheme.headlineSmall, - ), - ValueListenableBuilder( - valueListenable: ArcaneService.ofType( - context, - )?.notifier ?? - ValueNotifier(null), - builder: (context, color, _) { - return Text( + final ValueNotifier notifier = + service?.notifier ?? ValueNotifier(null); + return ValueListenableBuilder( + valueListenable: notifier, + builder: (context, color, _) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Services", + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( color != null ? "Favorite color: ${color.name}" : "", - ); - }, - ), - ElevatedButton( - onPressed: () { - if (service == null) { - ArcaneServiceProvider.of(context).addService( - FavoriteColorService.I, - ); + ), + ElevatedButton( + onPressed: () { + if (service == null) { + ArcaneServiceProvider.of(context).addService( + FavoriteColorService.I, + ); - Arcane.log( - "Service registered.", - metadata: {"service": "FavoriteColorService"}, - ); - } else { - ArcaneServiceProvider.of(context) - .removeService(); + Arcane.log( + "Service registered.", + metadata: {"service": "FavoriteColorService"}, + ); + } else { + ArcaneServiceProvider.of(context) + .removeService(); - Arcane.log( - "Service removed.", - metadata: {"service": "FavoriteColorService"}, - ); - } - }, - child: Text('${service == null ? 'Register' : 'Remove'} service'), - ), - SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8, - children: [ - const Text("Color"), - Expanded( - child: ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - service?.setMyFavoriteColor(colors[index]); - Arcane.log( - "Set a color in FavoriteColorService", - metadata: { - "color": colors[index].name ?? "Unknown", + Arcane.log( + "Service removed.", + metadata: {"service": "FavoriteColorService"}, + ); + } + }, + child: Text( + '${service == null ? 'Register' : 'Remove'} service', + ), + ), + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text("Color"), + Expanded( + child: ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => const SizedBox(width: 4), + itemBuilder: (context, index) { + return InkWell( + onTap: () { + if (service == null) { + Arcane.log( + "FavoriteColorService is not registered", + ); + return; + } + + service.setMyFavoriteColor(colors[index]); + Arcane.log( + "Set a color in FavoriteColorService", + metadata: { + "color": colors[index].name ?? "Unknown", + }, + ); }, + child: Container( + decoration: BoxDecoration( + color: colors[index], + border: color?.name == colors[index].name + ? Border.all(width: 2) + : null, + ), + width: 20, + height: 20, + ), ); }, - child: Container( - decoration: BoxDecoration( - color: colors[index], - border: service?.myFavoriteColor?.name == - colors[index].name - ? Border.all(width: 2) - : null, - ), - width: 20, - height: 20, - ), - ); - }, - ), + ), + ), + ], ), - ], - ), - ), - Text( - "Service is ${service != null ? "" : "not "}registered", + ), + Text( + "Service is ${service != null ? "" : "not "}registered", + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } From 7bf5a6d33ddce3d7b67c7bae621b76ce7693ed2a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 13 May 2025 15:00:58 +0200 Subject: [PATCH 22/58] Update ServiceProvider to make service instances optional Signed-off-by: Hans Kokx --- lib/src/providers/service/service_provider.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/providers/service/service_provider.dart b/lib/src/providers/service/service_provider.dart index e317438..de6d8d2 100644 --- a/lib/src/providers/service/service_provider.dart +++ b/lib/src/providers/service/service_provider.dart @@ -21,14 +21,14 @@ class ArcaneServiceProvider extends InheritedNotifier>> { /// A list of `ArcaneService` instances available through the provider. List get registeredServices => - List.from(notifier?.value ?? []); + List.from([...?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. ArcaneServiceProvider({ - required List serviceInstances, required super.child, + List serviceInstances = const [], super.key, }) : super( notifier: ValueNotifier>(serviceInstances), From 55ff65ba3201053ef5462a96b1c656f889e209f1 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 14 May 2025 11:11:45 +0200 Subject: [PATCH 23/58] Remove unnecessary notifyListeners calls Signed-off-by: Hans Kokx --- example/lib/services/favorite_color_service.dart | 2 -- .../authentication/authentication_service.dart | 2 -- .../feature_flags/feature_flags_service.dart | 14 +++++--------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart index c9f2193..ac838cc 100644 --- a/example/lib/services/favorite_color_service.dart +++ b/example/lib/services/favorite_color_service.dart @@ -20,8 +20,6 @@ class FavoriteColorService extends ArcaneService { if (_notifier.value != newValue) { _notifier.value = newValue; } - - notifyListeners(); } } diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index 31974bc..6ad8045 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -67,7 +67,6 @@ class ArcaneAuthenticationService extends ArcaneService { _notifier.value = AuthenticationStatus.unauthenticated; _isSignedIn.value = isAuthenticated; _previousModeWhenSettingDebug = null; - notifyListeners(); } /// Registers an `ArcaneAuthInterface` within the `ArcaneAuthenticationService`. @@ -157,7 +156,6 @@ class ArcaneAuthenticationService extends ArcaneService { _notifier.value = newStatus; _isSignedIn.value = isAuthenticated; } - notifyListeners(); } /// Logs the current user out. Upon successful logout, `status` will be set to diff --git a/lib/src/services/feature_flags/feature_flags_service.dart b/lib/src/services/feature_flags/feature_flags_service.dart index ec04657..93544fc 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -82,7 +82,6 @@ class ArcaneFeatureFlags extends ArcaneService { ); } - notifyListeners(); return I; } @@ -111,7 +110,6 @@ class ArcaneFeatureFlags extends ArcaneService { ); } - notifyListeners(); return I; } @@ -121,12 +119,9 @@ class ArcaneFeatureFlags extends ArcaneService { /// It is called automatically when enabling or disabling features if they haven't /// already been initialized. void _init() { - _notifier.value = []; - - notifier.addListener(_listener); - + if (I._initialized) return; + reset(); I._initialized = true; - notifyListeners(); } /// Resets the feature flags to their initial state. @@ -134,10 +129,11 @@ class ArcaneFeatureFlags extends ArcaneService { /// This method clears all enabled features, resets notification values, /// marks the flags as uninitialized, and notifies listeners of the changes. void reset() { + notifier + ..removeListener(_listener) + ..addListener(_listener); _notifier.value = []; - I._initialized = false; - notifyListeners(); } void _listener() { From 077956b8ba4f2eeda5ade2d7c2e38502e0f04c5e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 14 May 2025 11:12:02 +0200 Subject: [PATCH 24/58] Organize theme service Signed-off-by: Hans Kokx --- .../reactive_theme_service.dart | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart index 316aeb2..8436689 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -14,22 +14,25 @@ part "reactive_theme_extensions.dart"; /// System theme changes are detected by the `ArcaneApp` widget, which ensures /// theme updates happen automatically when the device theme changes. class ArcaneReactiveTheme extends ArcaneService { - /// The singleton instance of `ArcaneReactiveTheme`. + ArcaneReactiveTheme._internal(); static final ArcaneReactiveTheme _instance = ArcaneReactiveTheme._internal(); - - /// Provides access to the singleton instance of `ArcaneReactiveTheme`. static ArcaneReactiveTheme get I => _instance; - ArcaneReactiveTheme._internal(); - - // Whether to follow system theme - bool _followingSystemTheme = false; - + // ************************************************************************ // + // * MARK: System theme + // ************************************************************************ // /// Whether the theme service is currently following the system theme. /// /// When `true`, the theme will automatically switch between light and dark /// based on the system's brightness setting. bool get isFollowingSystemTheme => _followingSystemTheme; + bool _followingSystemTheme = false; + + /// Returns the `ThemeData` corresponding to the current system theme + ThemeMode get systemThemeMode => _currentSystemThemeMode; + + /// Tracks the current system theme mode + ThemeMode _currentSystemThemeMode = ThemeMode.system; final StreamController _systemStreamController = StreamController.broadcast( @@ -38,6 +41,20 @@ class ArcaneReactiveTheme extends ArcaneService { }, ); + // ************************************************************************ // + // * MARK: ThemeMode + // ************************************************************************ // + /// Returns the current `ThemeMode` being used by `ArcaneReactiveTheme`. + /// Will automatically update when the theme changes. + ThemeMode currentModeOf(BuildContext context) => context.themeMode; + + /// The currently active theme mode (light or dark). + ThemeMode get currentThemeMode => _currentThemeMode; + ThemeMode _currentThemeMode = ThemeMode.light; + + /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. + Stream get themeModeChanges => I._themeModeStreamController.stream; + final StreamController _themeModeStreamController = StreamController.broadcast( onCancel: () { @@ -45,6 +62,16 @@ class ArcaneReactiveTheme extends ArcaneService { }, ); + // ************************************************************************ // + // * MARK: ThemeData + // ************************************************************************ // + /// The currently active theme style. + ThemeData get currentTheme => _currentTheme; + ThemeData _currentTheme = ThemeData(); + + /// Stream of `ThemeData` changes that can be listened to for reactive UI updates. + Stream get themeDataChanges => I._themeStreamController.stream; + final StreamController _themeStreamController = StreamController.broadcast( onCancel: () { @@ -52,50 +79,26 @@ class ArcaneReactiveTheme extends ArcaneService { }, ); - /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. - Stream get themeModeChanges => I._themeModeStreamController.stream; - - /// Stream of `ThemeData` changes that can be listened to for reactive UI updates. - Stream get themeDataChanges => I._themeStreamController.stream; - - /// Returns the `ThemeData` corresponding to the current system theme - ThemeMode get systemThemeMode => _currentSystemThemeMode; - - /// Tracks the current system theme mode - ThemeMode _currentSystemThemeMode = ThemeMode.system; - - ThemeMode _currentThemeMode = ThemeMode.light; - - /// The currently active theme mode (light or dark). - ThemeMode get currentThemeMode => _currentThemeMode; - - ThemeData _currentTheme = ThemeData(); - - /// The currently active theme style. - ThemeData get currentTheme => _currentTheme; - - /// The `ThemeData` for the dark theme. - final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); - - /// The `ThemeData` for the light theme. - final ValueNotifier _lightTheme = ValueNotifier(ThemeData.light()); - + // ************************************************************************ // + // * MARK: Light/Dark theme + // ************************************************************************ // /// Returns the current dark theme `ThemeData`. ThemeData get dark => _darkTheme.value; /// ValueNotifier for the dark theme that can be observed for changes. ValueNotifier get darkTheme => I._darkTheme; + final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); /// Returns the current light theme `ThemeData`. ThemeData get light => _lightTheme.value; /// ValueNotifier for the light theme that can be observed for changes. ValueNotifier get lightTheme => I._lightTheme; + final ValueNotifier _lightTheme = ValueNotifier(ThemeData.light()); - /// Returns the current `ThemeMode` being used by `ArcaneReactiveTheme`. - /// Will automatically update when the theme changes. - ThemeMode currentModeOf(BuildContext context) => context.themeMode; - + // ************************************************************************ // + // * MARK: Methods + // ************************************************************************ // /// Switches the current theme between light and dark modes. /// /// If the theme is currently light, it switches to dark, and vice versa. It @@ -120,7 +123,6 @@ class ArcaneReactiveTheme extends ArcaneService { ); } - notifyListeners(); return I; } @@ -147,7 +149,6 @@ class ArcaneReactiveTheme extends ArcaneService { final ThemeData theme = systemThemeMode == ThemeMode.dark ? dark : light; _themeStreamController.add(theme); _currentTheme = theme; - notifyListeners(); return I; } @@ -165,7 +166,7 @@ class ArcaneReactiveTheme extends ArcaneService { _darkTheme.value = theme; _themeStreamController.add(theme); _currentTheme = theme; - notifyListeners(); + return I; } @@ -182,7 +183,7 @@ class ArcaneReactiveTheme extends ArcaneService { _lightTheme.value = theme; _themeStreamController.add(theme); _currentTheme = theme; - notifyListeners(); + return I; } @@ -198,7 +199,6 @@ class ArcaneReactiveTheme extends ArcaneService { _updateTheme(ThemeMode.light); _themeStreamController.add(_lightTheme.value); _currentTheme = _lightTheme.value; - notifyListeners(); } /// Updates the current theme mode and broadcasts the change. From c2cfb46d825fa448bfee768250b148f0864759e9 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 19 May 2025 17:00:01 +0200 Subject: [PATCH 25/58] Fix authentication interface and service methods for logout and login. --- .../authentication/authentication_interface.dart | 8 +++++++- .../authentication/authentication_service.dart | 15 ++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/src/services/authentication/authentication_interface.dart b/lib/src/services/authentication/authentication_interface.dart index 06f0205..9cff69a 100644 --- a/lib/src/services/authentication/authentication_interface.dart +++ b/lib/src/services/authentication/authentication_interface.dart @@ -36,7 +36,11 @@ abstract class ArcaneAuthInterface { /// This method terminates the current session and removes any stored tokens. /// Returns a `Result` that either contains a `void` on success or an error /// message. - Future> logout(); + /// Upon a successful logout, the `onLoggedOut` method will be called if it + /// has been provided. + Future> logout({ + Future Function()? onLoggedOut, + }); /// Logs the user in using an optional, generic `T` type of input. /// This login method is a generic method that can be used to login with any @@ -44,6 +48,8 @@ abstract class ArcaneAuthInterface { /// and password. Any type of input can be passed in, and it will be handled /// by the implementation of the method wihin the specific authentication /// service. + /// Upon a successful login, the `onLoggedIn` method will be called if it + /// has been provided. /// /// Example: /// ```dart diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index 6ad8045..c2bdda3 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -50,13 +50,13 @@ class ArcaneAuthenticationService extends ArcaneService { /// Returns a JWT access token if the registered `ArcaneAuthInterface` /// provides one. This token is often used in the headers of HTTP requests /// to the backend API. - Future get accessToken => - authInterface?.accessToken ?? Future.value(""); + Future get accessToken async => + await authInterface?.accessToken ?? Future.value(""); /// Returns a JWT refresh token if the registered `ArcaneAuthInterface` /// provides one. - Future get refreshToken => - authInterface?.refreshToken ?? Future.value(""); + Future get refreshToken async => + await authInterface?.refreshToken ?? Future.value(""); AuthenticationStatus? _previousModeWhenSettingDebug; @@ -169,11 +169,12 @@ class ArcaneAuthenticationService extends ArcaneService { if (!isAuthenticated) Result.error("User is not authenticated."); - final Result loggedOut = await authInterface!.logout(); + final Result loggedOut = await authInterface!.logout( + onLoggedOut: onLoggedOut, + ); if (loggedOut.isSuccess) { setUnauthenticated(); - if (onLoggedOut != null) await onLoggedOut(); } _previousModeWhenSettingDebug = null; @@ -192,11 +193,11 @@ class ArcaneAuthenticationService extends ArcaneService { final Result result = await authInterface!.login( input: input, + onLoggedIn: onLoggedIn, ); if (result.isSuccess) { setAuthenticated(); - if (onLoggedIn != null) await onLoggedIn(); } return result; From 93f90a3f72a8baaf3f3669ccff8f5f53804c8012 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 19 May 2025 17:00:27 +0200 Subject: [PATCH 26/58] Fix DebugAuthInterface logout method Added environment key to ArcaneEnvironment constructor Renamed switchEnvironment parameter in ArcaneEnvironment constructor Bumped arcane_helper_utils dependency version --- example/lib/interfaces/debug_auth_interface.dart | 4 +++- lib/src/providers/environment_provider.dart | 7 +++---- pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/example/lib/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index 2c2f54e..5c99873 100644 --- a/example/lib/interfaces/debug_auth_interface.dart +++ b/example/lib/interfaces/debug_auth_interface.dart @@ -25,7 +25,9 @@ class DebugAuthInterface ); @override - Future> logout() async { + Future> logout({ + Future Function()? onLoggedOut, + }) async { Arcane.log("Logging out"); _isSignedIn = false; diff --git a/lib/src/providers/environment_provider.dart b/lib/src/providers/environment_provider.dart index 0331e6a..7a62d46 100644 --- a/lib/src/providers/environment_provider.dart +++ b/lib/src/providers/environment_provider.dart @@ -14,11 +14,10 @@ class ArcaneEnvironment extends InheritedWidget { /// Creates an `ArcaneEnvironment` widget. const ArcaneEnvironment({ required this.environment, - required Widget child, required void Function(Environment) switchEnvironment, - Key? key, - }) : _switchEnvironment = switchEnvironment, - super(key: key, child: child); + required super.child, + super.key, + }) : _switchEnvironment = switchEnvironment; /// Retrieves the `ArcaneEnvironment` instance from the nearest ancestor. /// diff --git a/pubspec.yaml b/pubspec.yaml index 9459b68..d778a15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ environment: flutter: ">=1.17.0" dependencies: - arcane_helper_utils: ^1.4.1 + arcane_helper_utils: ^1.4.5 collection: ^1.19.0 flutter: sdk: flutter From 1e84e8f6489993b778ce441d59f39c609cb97045 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 1 Jul 2025 08:54:10 +0200 Subject: [PATCH 27/58] Enhance theme management with StreamBuilder for dynamic updates Signed-off-by: Hans Kokx --- README.md | 229 ++++++++++++++++++++++++++++-------------- example/lib/main.dart | 93 +++++++++-------- 2 files changed, 206 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 1ee4d9d..a8ee3f5 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,109 @@ -# Arcane Framework: Agnostic Reusable Component Architecture for New Ecosystems - -The Arcane Framework is a powerful Dart package designed to provide a robust architecture for managing key application services such as logging, authentication, secure storage, feature flags, theming, and more. This framework is ideal for building scalable applications that require dynamic configuration and service management. - -[![style: arcane analysis](https://img.shields.io/badge/style-arcane_analysis-6E35AE)](https://pub.dev/packages/arcane_analysis) +# Arcane Framework + +> _**A**gnostic **R**eusable **C**omponent **A**rchitecture for **N**ew +> **E**cosystems_ + +![style: arcane analysis](https://img.shields.io/badge/style-arcane_analysis-6E35AE) + +The Arcane Framework is a powerful Dart package designed to provide a robust +architecture for managing key application services such as logging, +authentication, secure storage, feature flags, theming, and more. This framework +is ideal for building scalable applications that require dynamic configuration +and service management. + +- [Arcane Framework](#arcane-framework) + - [Features](#features) + - [Installation](#installation) + - [Usage](#usage) + - [Services](#services) + - [Defining an example `ArcaneService`](#defining-an-example-arcaneservice) + - [Registering and unregistering an `ArcaneService`](#registering-and-unregistering-an-arcaneservice) + - [Locating an `ArcaneService`](#locating-an-arcaneservice) + - [Using `ArcaneService` services](#using-arcaneservice-services) + - [Feature Flags](#feature-flags) + - [Logging](#logging) + - [Authentication](#authentication) + - [Dynamic Theming](#dynamic-theming) + - [Contributing](#contributing) ## Features -- **Service Management**: Centralized access to multiple services (logging, authentication, theming, etc.). -- **Feature Flags**: Dynamically enable or disable features using `ArcaneFeatureFlags`. -- **Logging**: Easily log messages with metadata, stack traces, and different log levels via `ArcaneLogger`. -- **Authentication**: Built-in support for handling user authentication workflows. -- **Theming**: Switch between light and dark themes with `ArcaneReactiveTheme`. +- **Service Management**: Centralized access to multiple services (logging, + authentication, theming, etc.). +- **Feature Flags**: Dynamically enable or disable features using + `ArcaneFeatureFlags`. +- **Logging**: Easily log messages with metadata, stack traces, and different + log levels via `ArcaneLogger`. +- **Authentication**: Built-in support for handling user authentication + workflows. +- **Dynamic Theming**: Switch between light and dark themes with + `ArcaneReactiveTheme`. -## Getting Started +## Installation To use Arcane Framework in your Dart or Flutter project, follow these steps: -### Installation - - 1. Add the dependency to your pubspec.yaml: + 1. Add the dependency to your `pubspec.yaml`: - ```shell - flutter pub add 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. (optional) Wrap your `MaterialApp` or `CupertinoApp` with the `ArcaneApp` + Widget: - ```dart - import 'package:arcane_framework/arcane_framework.dart'; - - void main() { - runApp( - ArcaneApp( - services: [ - MyArcaneService(), - ], - child: MainApp(), - ), - ); - } - ``` + ```dart + import 'package:arcane_framework/arcane_framework.dart'; + + void main() { + runApp( + ArcaneApp( + child: MainApp(), + ), + ); + } + ``` ## Usage -The following sections provide more information about how to use the framework features. +The following sections provide more information about how to use the package's +available features. ### Services -The Arcane Framework provides a centralized way to manage services across your application via a built-in service locator. +The Arcane Framework provides a centralized way to manage services across your +application, while optionally leveraging a built-in service locator. -#### Services overview +Unlike most of the features in Arcane, a _service_ is broadly user-defined. What +a service is, or does, is not rigorously enforced by the framework itself. What +an `ArcaneService` offers, however, is the ability to be registered (and +unregistered), as well as located via `BuildContext`. The locators are the key +value proposition that Arcane provides. -Unlike most of the features in Arcane, a _service_ is broadly user-defined. What a service is, or does, is not rigorously enforced by the framework. +The following tools are provided by Arcane to assist with creating and using +services: -The following tools are available for use in crafting your own services: +- `ArcaneService`: The base class from which to extend your own services. This + what Arcane uses to locate services. +- `ArcaneServiceProvider`: A widget used to provide access to registered + `ArcaneService` instances. **Note**: This widget is already part of the* + `ArcaneApp`*widget, however if you are not using the `ArcaneApp` widget you + can instead use this widget directly. +- The `service` and `requiredService` extensions on `BuildContext`: + nullable and non-nullable getters used to locate a given `ArcaneService` via + `BuildContext`. **Note**: Use of these extensions requires that an + `ArcaneServiceProvider` widget is in your widget tree, either by adding it + directly or by using the `ArcaneApp` widget. -- `ArcaneService`: The base class from which to extend your own services. Includes a `ChangeNotifier` and locators. -- `ArcaneServiceProvider`: A widget which extends the `InheritedNotifier` class, used to manage `ArcaneService` instances. _This widget is already part of the `ArcaneApp` widget._ -- `service` and `requiredService` extensions on `BuildContext`: A nullable and non-nullable getter, respectively, to locate a given `ArcaneService` via `BuildContext`. +#### Defining an example `ArcaneService` -### Defining an example `ArcaneService` +As noted previously, _what_ a service is or does is not enforced by the +framework. Therefore, the following example is only in service of the remainder +of the documentation of the Arcane services feature. -As noted previously, _what_ a service is or does is not enforced by the framework. Therefore, the following example is only in service of the remainder of the documentation of the Arcane services feature. - -This example service is a singleton service that stores and provides access to a user's favorite color. +This example service is a singleton service that stores and provides access to a +user's favorite color, leveraging a `ValueNotifier` to trigger rebuilds as +appropriate: ```dart class FavoriteColorService extends ArcaneService { @@ -79,17 +119,19 @@ class FavoriteColorService extends ArcaneService { void setMyFavoriteColor(Color? color) { if (_notifier.value != color) { _notifier.value = color; - notifyListeners(); } } } ``` -### Registering and unregistering an `ArcaneService` +#### Registering and unregistering an `ArcaneService` -The quickest and easiest way to register an `ArcaneService` is to use the built-in `ArcaneApp` widget. However, this is not the _only_ method available. +The quickest and easiest way to register an `ArcaneService` is to use the +built-in `ArcaneApp` widget. However, this is not the _only_ method available. -To register your `ArcaneService` using an app with the `ArcaneApp` widget, you have a couple of options. First, you can simply add the service (in our case, a singleton instance) to the `services` list directly: +To register your `ArcaneService` using an app with the `ArcaneApp` widget, you +have a couple of options. First, you can simply add the service (in our case, a +singleton instance) to the `services` list directly: ```dart ArcaneApp( @@ -100,7 +142,9 @@ ArcaneApp( ), ``` -You can also defer adding the service by invoking `ArcaneServiceProvider`. Note that this requires either `ArcaneServiceProvider` _or_ `ArcaneApp` (which already includes `ArcaneServiceProvider`) to be in your widget tree. +You can also defer adding the service by invoking `ArcaneServiceProvider`. Note +that this requires either `ArcaneServiceProvider` _or_ `ArcaneApp` (which +already includes `ArcaneServiceProvider`) to be in your widget tree. ```dart // The service is not included at compile-time @@ -118,9 +162,10 @@ Unregistering an already registered `ArcaneService` is as simple as: ArcaneServiceProvider.of(context).removeService() ``` -### Locating an `ArcaneService` +#### Locating an `ArcaneService` -There are numerous ways to locate a registered `ArcaneService`. Feel free to use whatever method you prefer: +There are numerous ways to locate a registered `ArcaneService`. Feel free to use +whatever method you prefer: ```dart // If a service of the given type is not registered, `null` is returned. @@ -144,9 +189,12 @@ final ArcaneServiceProvider? nullableProvider = ArcaneServiceProvider.maybeOf(co final ArcaneServiceProvider nonNullableProvider = ArcaneServiceProvider.of(context); ``` -### Using `ArcaneService` services +#### Using `ArcaneService` services -Since the `ArcaneService` class includes a `ChangeNotifier`, invoking the `notifyListeners()` method inside a service will trigger a rebuild. Using our `FavoriteColorService` from earlier, we can add a listener to our notifier value: +Since the `ArcaneService` class includes a `ChangeNotifier`, invoking the +`notifyListeners()` method inside a service will trigger a rebuild. Using our +`FavoriteColorService` from earlier, we can add a listener to our notifier +value: ```dart final FavoriteColorService service = ArcaneService.requiredOfType(context); @@ -168,17 +216,26 @@ ValueListenableBuilder( ) ``` -Meanwhile, setting the value in our service can be accomplished in the following manner: +Meanwhile, setting the value in our service can be accomplished in the following +manner: ```dart ArcaneService.requiredOfType(context).setMyFavoriteColor(Colors.purple); ``` -Again, this example is _not_ the only way the Arcane Service system can be utilized. One is limited only by their imagination! +Again, this example is _not_ the only way the Arcane Service system can be +utilized. One is limited only by their imagination! ### Feature Flags -You can easily manage feature flags using the `ArcaneFeatureFlags` built-in service. Feature flags are useful for enabling or disabling different parts of your application under different circumstances. For example, you may want to enable a new feature only once it has finished development and testing, while still having the ability to ship the unfinished code. You could also leverage feature flags to enable different modes within your application (e.g., "free" vs "paid"). Furthermore, they can be used for A/B testing. The options are truly unlimited. +You can easily manage feature flags using the `ArcaneFeatureFlags` built-in +service. Feature flags are useful for enabling or disabling different parts of +your application under different circumstances. For example, you may want to +enable a new feature only once it has finished development and testing, while +still having the ability to ship the unfinished code. You could also leverage +feature flags to enable different modes within your application (e.g., "free" vs +"paid"). Furthermore, they can be used for A/B testing. The options are truly +unlimited. To get started, create an `enum` to define your features: @@ -197,10 +254,11 @@ enum Feature { } ``` -Next, ensure that your features are enabled at startup by registering them within the feature flag service: +Next, ensure that your features are enabled at startup by registering them +within the feature flag service: ```dart - void main() { +void main() { WidgetsFlutterBinding.ensureInitialized(); // Register your Enum that you'll be using to enable and disable features. @@ -212,7 +270,8 @@ Next, ensure that your features are enabled at startup by registering them withi } ``` -When you want to determine if a feature is enabled, you can use one of the helper extensions: +When you want to determine if a feature is enabled, you can use one of the +helper extensions: ```dart // Via an enum extension @@ -234,13 +293,15 @@ Arcane.features.disableFeature(Feature.awesomeFeature); Arcane.features.enableFeature(Feature.prettyOkFeature); ``` -To get a list of the currently enabled features, simply ask the Arcane feature flag service: +To get a list of the currently enabled features, simply ask the Arcane feature +flag service: ```dart final List enabledFeatures = Arcane.features.enabledFeatures; ``` -It is also possible to add a listener to watch for changes in the enabled features. +It is also possible to add a listener to watch for changes in the enabled +features. ```dart Arcane.features.notifier.addListener(() { @@ -248,13 +309,18 @@ Arcane.features.notifier.addListener(() { }); ``` -Note that it is possible to register multiple different `Enum` types in the feature flag service, should one have a need to do so. +Note that it is possible to register multiple different `Enum` types in the +feature flag service, should one have a need to do so. ### Logging -The Arcane Framework provides a robust logging system for your application. This allows you to easily log messages with metadata, stack traces, and different log levels. The framework also provides an easy way to configure the logger's behavior (e.g., whether or not to show stack traces). +The Arcane Framework provides a robust logging system for your application. This +allows you to easily log messages with metadata, stack traces, and different log +levels. The framework also provides an easy way to configure the logger's +behavior (e.g., whether or not to show stack traces). -To get started, first create one or more logging interfaces, extending the `LoggingInterface` base class. +To get started, first create one or more logging interfaces, extending the +`LoggingInterface` base class. ```dart class DebugConsole implements LoggingInterface { @@ -299,7 +365,8 @@ await Arcane.logger.registerInterfaces([ await Arcane.logger.initializeInterfaces(); ``` -Finally, add any additional persistent metadata to your log messages (optional) and log a message: +Finally, add any additional persistent metadata to your log messages (optional) +and log a message: ```dart // Add metadata to the logger @@ -321,13 +388,20 @@ Arcane.log( Multiple logging interfaces can be registered simultaneously. -**Important**: Logging interfaces should generally be initialized after being registered with the logger service. This ensures that all logging interfaces are properly initialized before any messages are logged. This should typically be done manually in order to properly present the user with a message stating that they're about to be prompted for tracking permissions (on iOS). +**Important**: Logging interfaces should generally be initialized after being +registered with the logger service. This ensures that all logging interfaces are +properly initialized before any messages are logged. This should typically be +done manually in order to properly present the user with a message stating that +they're about to be prompted for tracking permissions (on iOS). ### Authentication -The Arcane Framework provides a useful interface for performing common authentication tasks, such as registration, password resets, login, log out, and enabling a debug mode. +The Arcane Framework provides a useful interface for performing common +authentication tasks, such as registration, password resets, login, log out, and +enabling a debug mode. -To get started, create an authentication interface provider and register it in the Arcane authentication module: +To get started, create an authentication interface provider and register it in +the Arcane authentication module: ```dart import "package:arcane_framework/arcane_framework.dart"; @@ -448,7 +522,8 @@ class DebugAuthInterface await Arcane.auth.registerInterface(AuthProviderInterface.I); ``` -Once your interface has been created and registered, you can use it to perform a number of common authentication tasks: +Once your interface has been created and registered, you can use it to perform a +number of common authentication tasks: ```dart // Register an account using the ArcaneAuthAccountRegistration mixin @@ -490,9 +565,12 @@ await Arcane.auth.logout(); ### Dynamic Theming -The Arcane Framework provides a simple interface for managing themes in your application, with dynamic switching between dark and light themes based on the user's system settings, or manually switching between themes. +The Arcane Framework provides a simple interface for managing themes in your +application, with dynamic switching between dark and light themes based on the +user's system settings, or manually switching between themes. -To get started, first register your `ThemeData` objects with the Arcane theme module: +To get started, first register your `ThemeData` objects with the Arcane theme +module: ```dart void main() { @@ -582,10 +660,11 @@ Arcane.theme.setLightTheme(customLightTheme); ## Contributing -We welcome contributions to the Arcane Framework. If you’d like to contribute, please: +We welcome contributions to the Arcane Framework. If you’d like to contribute, +please: - 1. Fork the repository. - 2. Create a new feature branch. - 3. Submit a pull request with a description of your changes. + 1. Fork the repository. + 2. Create a new feature branch. + 3. Submit a pull request with a description of your changes. For detailed information on how to contribute, please refer to CONTRIBUTING.md. diff --git a/example/lib/main.dart b/example/lib/main.dart index 348a38f..8249e30 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -270,6 +270,9 @@ class ArcaneThemeExample extends StatelessWidget { super.key, }); + static final Listenable themeListenable = + Listenable.merge([Arcane.theme.darkTheme, Arcane.theme.lightTheme]); + @override Widget build(BuildContext context) { return Card( @@ -355,49 +358,57 @@ class ArcaneThemeExample extends StatelessWidget { children: [ const Text("Color"), Expanded( - child: ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - if (Arcane.theme.currentThemeMode == - ThemeMode.dark) { - Arcane.theme.setDarkTheme( - ThemeData( - brightness: Brightness.dark, - colorSchemeSeed: colors[index], - ), - ); - } else if (Arcane.theme.currentThemeMode == - ThemeMode.light) { - Arcane.theme.setLightTheme( - ThemeData( - brightness: Brightness.light, - colorSchemeSeed: colors[index], - ), - ); - } + child: StreamBuilder( + stream: Arcane.theme.themeDataChanges, + builder: (context, themeData) => ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => const SizedBox(width: 4), + itemBuilder: (context, index) { + return InkWell( + onTap: () { + if (context.themeMode == ThemeMode.dark) { + Arcane.theme.setDarkTheme( + ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: colors[index], + ), + ); + } else if (context.themeMode == ThemeMode.light) { + Arcane.theme.setLightTheme( + ThemeData( + brightness: Brightness.light, + colorSchemeSeed: colors[index], + ), + ); + } - Arcane.log( - "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", - ); - }, - child: Container( - decoration: BoxDecoration( - color: colors[index], - border: Arcane.theme.currentTheme.colorScheme - .primary.name == - colors[index].name - ? Border.all(width: 2) - : null, + Arcane.log( + "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", + ); + }, + child: StreamBuilder( + stream: Arcane.theme.themeModeChanges, + builder: (context, themeMode) { + return Container( + key: + Key("${colors[index]}-${themeMode.data}"), + decoration: BoxDecoration( + color: colors[index], + border: themeData.data?.colorScheme.primary + .name == + colors[index].name + ? Border.all(width: 2) + : null, + ), + width: 20, + height: 20, + ); + }, ), - width: 20, - height: 20, - ), - ); - }, + ); + }, + ), ), ), ], From f5056c36df04887feea3807ca2d5775a47845dff Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 7 May 2026 21:03:50 +0200 Subject: [PATCH 28/58] Add configuration files and update authentication service error handling - Introduced .vscode/settings.json and .vscode/launch.json for IDE configuration. - Updated DebugAuthInterface and ArcaneAuthenticationService to return const Result.ok() for consistency. - Added ArcaneTheme class for theme management. - Updated pubspec.yaml to change result_monad dependency version. - Modified authentication_service_test to return const Result.ok() in mock setups. Signed-off-by: Hans Kokx --- .vscode/settings.json | 4 +++ example/.vscode/launch.json | 25 ++++++++++++++++ .../lib/interfaces/debug_auth_interface.dart | 26 ++++++++-------- .../authentication_service.dart | 30 +++++++++---------- .../services/reactive_theme/arcane_theme.dart | 26 ++++++++++++++++ pubspec.yaml | 5 ++-- .../authentication_service_test.dart | 6 ++-- 7 files changed, 89 insertions(+), 33 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 example/.vscode/launch.json create mode 100644 lib/src/services/reactive_theme/arcane_theme.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f3918d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "dart.flutterSdkPath": "/Users/hans/.puro/envs/stable/flutter", + "dart.sdkPath": "/Users/hans/.puro/envs/stable/flutter/bin/cache/dart-sdk" +} \ No newline at end of file diff --git a/example/.vscode/launch.json b/example/.vscode/launch.json new file mode 100644 index 0000000..08658c9 --- /dev/null +++ b/example/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/example/lib/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index 5c99873..ec9f2d9 100644 --- a/example/lib/interfaces/debug_auth_interface.dart +++ b/example/lib/interfaces/debug_auth_interface.dart @@ -32,19 +32,19 @@ class DebugAuthInterface _isSignedIn = false; - return Result.ok(null); + return const Result.ok(null); } @override - Future> login({ - Credentials? input, + Future> login({ + T? input, Future Function()? onLoggedIn, }) async { final bool alreadyLoggedIn = await isSignedIn; - if (alreadyLoggedIn) return Result.ok(null); + if (alreadyLoggedIn) return const Result.ok(null); - final credentials = input as ({String email, String password}); + final credentials = input as Credentials; final String email = credentials.email; final String password = credentials.password; @@ -53,7 +53,7 @@ class DebugAuthInterface _isSignedIn = true; - return Result.ok(null); + return const Result.ok(null); } @override @@ -61,15 +61,15 @@ class DebugAuthInterface T? input, }) async { Arcane.log("Re-sending verification code to $input"); - return Result.ok("Code sent"); + return const Result.ok("Code sent"); } @override - Future> register({ - Credentials? input, + Future> register({ + T? input, }) async { if (input != null) { - final credentials = input as ({String email, String password}); + final credentials = input as Credentials; final String email = credentials.email; final String password = credentials.password; @@ -77,7 +77,7 @@ class DebugAuthInterface Arcane.log("Creating account for $email with password $password"); } - return Result.ok(SignUpStep.confirmSignUp); + return const Result.ok(SignUpStep.confirmSignUp); } @override @@ -88,7 +88,7 @@ class DebugAuthInterface Arcane.log( "Confirming registration for $username with code $confirmationCode", ); - return Result.ok(true); + return const Result.ok(true); } @override @@ -98,7 +98,7 @@ class DebugAuthInterface String? code, }) async { Arcane.log("Resetting password for $email"); - return Result.ok(true); + return const Result.ok(true); } @override diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index c2bdda3..499fe00 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -164,10 +164,10 @@ class ArcaneAuthenticationService extends ArcaneService { Future Function()? onLoggedOut, }) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } - if (!isAuthenticated) Result.error("User is not authenticated."); + if (!isAuthenticated) const Result.error("User is not authenticated."); final Result loggedOut = await authInterface!.logout( onLoggedOut: onLoggedOut, @@ -188,7 +188,7 @@ class ArcaneAuthenticationService extends ArcaneService { Future Function()? onLoggedIn, }) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } final Result result = await authInterface!.login( @@ -210,11 +210,11 @@ class ArcaneAuthenticationService extends ArcaneService { T? input, }) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } if (authInterface is! ArcaneAuthAccountRegistration) { - return Result.error( + return const Result.error( "The provided ArcaneAuthInterface does not support account registration.", ); } @@ -226,7 +226,7 @@ class ArcaneAuthenticationService extends ArcaneService { ); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } @@ -241,11 +241,11 @@ class ArcaneAuthenticationService extends ArcaneService { required String confirmationCode, }) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } if (authInterface is! ArcaneAuthAccountRegistration) { - return Result.error( + return const Result.error( "The provided ArcaneAuthInterface does not support account registration.", ); } @@ -258,7 +258,7 @@ class ArcaneAuthenticationService extends ArcaneService { ); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } @@ -270,11 +270,11 @@ class ArcaneAuthenticationService extends ArcaneService { /// registration. Future> resendVerificationCode(String email) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } if (authInterface is! ArcaneAuthAccountRegistration) { - return Result.error( + return const Result.error( "The provided ArcaneAuthInterface does not support account registration.", ); } @@ -285,7 +285,7 @@ class ArcaneAuthenticationService extends ArcaneService { auth.resendVerificationCode(input: email); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } @@ -305,11 +305,11 @@ class ArcaneAuthenticationService extends ArcaneService { String? confirmationCode, }) async { if (_authInterface == null) { - return Result.error("No ArcaneAuthInterface has been registered"); + return const Result.error("No ArcaneAuthInterface has been registered"); } if (authInterface is! ArcaneAuthPasswordManagement) { - return Result.error( + return const Result.error( "The provided ArcaneAuthInterface does not support password management.", ); } @@ -323,7 +323,7 @@ class ArcaneAuthenticationService extends ArcaneService { ); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } diff --git a/lib/src/services/reactive_theme/arcane_theme.dart b/lib/src/services/reactive_theme/arcane_theme.dart new file mode 100644 index 0000000..d91ea3d --- /dev/null +++ b/lib/src/services/reactive_theme/arcane_theme.dart @@ -0,0 +1,26 @@ +import "package:flutter/material.dart"; + +class ArcaneTheme extends InheritedWidget { + final ThemeMode themeMode; + final bool followSystem; + final ThemeData? theme; + + const ArcaneTheme({ + required super.child, + this.themeMode = ThemeMode.light, + this.followSystem = false, + this.theme, + super.key, + }); + + static ArcaneTheme? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(ArcaneTheme oldWidget) { + return themeMode != oldWidget.themeMode || + followSystem != oldWidget.followSystem || + theme != oldWidget.theme; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index d778a15..92f88e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,6 @@ name: arcane_framework -description: "Agnostic Reusable Component Architecture for New Ecosystems: a modern framework for bootstrapping new applications" +description: "Agnostic Reusable Component Architecture for New Ecosystems: a + modern framework for bootstrapping new applications" version: 2.0.0-dev repository: https://github.com/hanskokx/arcane_framework issue_tracker: https://github.com/hanskokx/arcane_framework/issues @@ -16,7 +17,7 @@ dependencies: collection: ^1.19.0 flutter: sdk: flutter - result_monad: ^2.3.2 + result_monad: ^4.0.0 dev_dependencies: arcane_analysis: ^1.0.3 diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index 52eef3b..e6d88b9 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -23,10 +23,10 @@ void main() { // Set up default mock behaviors when(mockInterface.login(input: anyNamed("input"))).thenAnswer( - (_) async => Result.ok(null), + (_) async => const Result.ok(null), ); when(mockInterface.logout()).thenAnswer( - (_) async => Result.ok(null), + (_) async => const Result.ok(null), ); when(mockInterface.init()).thenAnswer( (_) async {}, @@ -61,7 +61,7 @@ void main() { testWidgets("login with failure", (WidgetTester tester) async { // Reset the mock behavior for this specific test when(mockInterface.login(input: anyNamed("input"))) - .thenAnswer((_) async => Result.error("error")); + .thenAnswer((_) async => const Result.error("error")); final result = await ArcaneAuthenticationService.I .login(input: {"username": "test"}); From bcd66f8b419680302b178bcf08b202190ba8a3a1 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 7 May 2026 21:04:53 +0200 Subject: [PATCH 29/58] Update dependency versions to use 'any' for arcane_helper_utils and arcane_analysis Signed-off-by: Hans Kokx --- pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 92f88e7..f55da3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,14 +13,14 @@ environment: flutter: ">=1.17.0" dependencies: - arcane_helper_utils: ^1.4.5 + arcane_helper_utils: any collection: ^1.19.0 flutter: sdk: flutter result_monad: ^4.0.0 dev_dependencies: - arcane_analysis: ^1.0.3 + arcane_analysis: any build_runner: ^2.4.1 flutter_test: sdk: flutter From 30b578708bf18f6e249d985640e6d6909103d6b7 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 13 May 2026 14:19:01 +0200 Subject: [PATCH 30/58] Remove unnecessary 'const' keyword from Result.ok calls in DebugAuthInterface methods Signed-off-by: Hans Kokx --- example/lib/interfaces/debug_auth_interface.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/example/lib/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index ec9f2d9..a08b92a 100644 --- a/example/lib/interfaces/debug_auth_interface.dart +++ b/example/lib/interfaces/debug_auth_interface.dart @@ -32,7 +32,7 @@ class DebugAuthInterface _isSignedIn = false; - return const Result.ok(null); + return Result.ok(null); } @override @@ -42,7 +42,7 @@ class DebugAuthInterface }) async { final bool alreadyLoggedIn = await isSignedIn; - if (alreadyLoggedIn) return const Result.ok(null); + if (alreadyLoggedIn) return Result.ok(null); final credentials = input as Credentials; @@ -53,7 +53,7 @@ class DebugAuthInterface _isSignedIn = true; - return const Result.ok(null); + return Result.ok(null); } @override @@ -61,7 +61,7 @@ class DebugAuthInterface T? input, }) async { Arcane.log("Re-sending verification code to $input"); - return const Result.ok("Code sent"); + return Result.ok("Code sent"); } @override @@ -77,7 +77,7 @@ class DebugAuthInterface Arcane.log("Creating account for $email with password $password"); } - return const Result.ok(SignUpStep.confirmSignUp); + return Result.ok(SignUpStep.confirmSignUp); } @override @@ -88,7 +88,7 @@ class DebugAuthInterface Arcane.log( "Confirming registration for $username with code $confirmationCode", ); - return const Result.ok(true); + return Result.ok(true); } @override @@ -98,7 +98,7 @@ class DebugAuthInterface String? code, }) async { Arcane.log("Resetting password for $email"); - return const Result.ok(true); + return Result.ok(true); } @override From 3ffaacc11ac8f776db290563b9c61e7101d568f5 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 13 May 2026 14:20:16 +0200 Subject: [PATCH 31/58] Add 'const' keyword to Result.ok calls in DebugAuthInterface methods Signed-off-by: Hans Kokx --- example/lib/interfaces/debug_auth_interface.dart | 14 +++++++------- pubspec.yaml | 3 --- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/example/lib/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index a08b92a..ec9f2d9 100644 --- a/example/lib/interfaces/debug_auth_interface.dart +++ b/example/lib/interfaces/debug_auth_interface.dart @@ -32,7 +32,7 @@ class DebugAuthInterface _isSignedIn = false; - return Result.ok(null); + return const Result.ok(null); } @override @@ -42,7 +42,7 @@ class DebugAuthInterface }) async { final bool alreadyLoggedIn = await isSignedIn; - if (alreadyLoggedIn) return Result.ok(null); + if (alreadyLoggedIn) return const Result.ok(null); final credentials = input as Credentials; @@ -53,7 +53,7 @@ class DebugAuthInterface _isSignedIn = true; - return Result.ok(null); + return const Result.ok(null); } @override @@ -61,7 +61,7 @@ class DebugAuthInterface T? input, }) async { Arcane.log("Re-sending verification code to $input"); - return Result.ok("Code sent"); + return const Result.ok("Code sent"); } @override @@ -77,7 +77,7 @@ class DebugAuthInterface Arcane.log("Creating account for $email with password $password"); } - return Result.ok(SignUpStep.confirmSignUp); + return const Result.ok(SignUpStep.confirmSignUp); } @override @@ -88,7 +88,7 @@ class DebugAuthInterface Arcane.log( "Confirming registration for $username with code $confirmationCode", ); - return Result.ok(true); + return const Result.ok(true); } @override @@ -98,7 +98,7 @@ class DebugAuthInterface String? code, }) async { Arcane.log("Resetting password for $email"); - return Result.ok(true); + return const Result.ok(true); } @override diff --git a/pubspec.yaml b/pubspec.yaml index f55da3a..2d6219d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,3 @@ dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.5 - -dependency_overrides: - analyzer: 7.3.0 From d3fdf7fabcb340c9b1f39b822443c3c34aef67bc Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 21 May 2026 17:55:10 +0200 Subject: [PATCH 32/58] Enhance logging service with new lifecycle capabilities and interceptor support - Updated `LoggingInterface` to remove singleton-style initialization. - Introduced `LoggingInitializable` and `LoggingInitializationMixin` for optional lifecycle management. - Added `LogEvent` and `LogInterceptor` classes for improved logging event handling and interception. - Updated `ArcaneLogger` to support multiple logging interfaces and global interceptors. - Modified `DebugPrint` and `DebugAuthInterface` to align with new logging structure. - Enhanced tests to cover new logging features and interceptor functionality. Signed-off-by: Hans Kokx --- CHANGELOG.md | 62 +++ README.md | 168 ++++++-- .../lib/interfaces/debug_auth_interface.dart | 5 +- .../lib/interfaces/debug_print_interface.dart | 17 +- example/lib/main.dart | 18 +- lib/src/services/logging/log_event.dart | 39 ++ lib/src/services/logging/log_interceptor.dart | 25 ++ .../services/logging/logging_interface.dart | 48 ++- lib/src/services/logging/logging_service.dart | 220 +++++++--- .../logging/logging_service_test.dart | 386 ++++++++++++++---- 10 files changed, 793 insertions(+), 195 deletions(-) create mode 100644 lib/src/services/logging/log_event.dart create mode 100644 lib/src/services/logging/log_interceptor.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ee90f3e..9527d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,65 @@ +## Unreleased + +### Logging Service (Upcoming Contract Changes) + +- [BREAKING] `LoggingInterface` no longer includes built-in singleton-style + initialization state. +- [NEW] Added optional lifecycle capability via `LoggingInitializable` and + `LoggingInitializationMixin`. +- [NEW] Added optional `feature` tag to `LoggingInterface` via constructor. +- [CHANGE] `initializeInterfaces()` now initializes only interfaces that + implement `LoggingInitializable`; other interfaces are skipped. + +#### Migration (LoggingInterface Contract) + +- For simple loggers (debug console style), remove `initialized`/`init` + boilerplate. + +Before: + +```dart +class DebugConsole implements LoggingInterface { + @override + bool get initialized => true; + + @override + Future init() async => this; + + @override + void log(String message, {Map? metadata, Level? level}) {} +} +``` + +After: + +```dart +class DebugConsole extends LoggingInterface { + @override + void log(String message, {Map? metadata, Level? level}) {} +} +``` + +- For SDK-backed loggers, opt into initialization with the mixin. + +```dart +class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { + @override + Future init() async { + if (initialized) return; + // Start SDK. + await super.init(); + } + + @override + void log(String message, {Map? metadata, Level? level}) { + if (!initialized) return; + // Send to SDK. + } +} +``` + +- If desired, adopt `feature` for destination-aware filtering in interceptors. + ## 2.0.0 ### Arcane diff --git a/README.md b/README.md index a8ee3f5..2847f8a 100644 --- a/README.md +++ b/README.md @@ -315,56 +315,103 @@ feature flag service, should one have a need to do so. ### Logging The Arcane Framework provides a robust logging system for your application. This -allows you to easily log messages with metadata, stack traces, and different log -levels. The framework also provides an easy way to configure the logger's -behavior (e.g., whether or not to show stack traces). +allows you to log messages with metadata, stack traces, and different log +levels while routing a single log event to multiple destinations. -To get started, first create one or more logging interfaces, extending the -`LoggingInterface` base class. +To get started, first create one or more logging interfaces by extending +`LoggingInterface`. ```dart -class DebugConsole implements LoggingInterface { - static final DebugConsole _instance = DebugConsole._internal(); - static DebugConsole get I => _instance; - DebugConsole._internal(); - - final bool _initialized = true; - - @override - bool get initialized => I._initialized; - - +class DebugConsole extends LoggingInterface { @override void log( String message, { - Map? metadata, + Map? metadata, Level? level, StackTrace? stackTrace, + Object? extra, }) { debugPrint( "$message\n" "$metadata\n", ); } +} +``` + +If your destination needs setup (SDK start, permission checks, etc.), opt into +the initialization lifecycle with `LoggingInitializationMixin`: +```dart +class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { @override - Future init() async => I; + Future init() async { + if (initialized) return; + + // Configure and start the SDK. + await super.init(); + } + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + if (!initialized) return; + // Forward to the SDK. + } } ``` -Next, register your logging interface with the Arcane logger service: +Next, register your logging interface with the Arcane logger service. You can +attach interceptors when registering an interface, or add global interceptors +later at runtime. ```dart -// Register your logging interface(s) -await Arcane.logger.registerInterfaces([ - DebugConsole.I, -]); +final DebugConsole debugConsole = DebugConsole(); + +await Arcane.logger.registerInterface( + debugConsole, + interceptors: [ + LogInterceptor((event, {required LogInterceptorContext context}) { + if (context.interface is DebugConsole && event.level == Level.debug) { + return null; + } + + return event; + }), + ], +); + +Arcane.logger.registerInterceptor( + LogInterceptor((event, {required context}) { + return event.copyWith( + metadata: { + ...?event.metadata, + "session": "startup", + }, + ); + }), +); -// Initialize registered logging interfaces -// NOTE: This step may be deferred until a user has consented to app tracking. +// Optional: initialize only interfaces that implement LoggingInitializable +// (for example, SDK-backed loggers that mix in LoggingInitializationMixin). await Arcane.logger.initializeInterfaces(); ``` +Global interceptors are evaluated for each registered interface, and interface +interceptors run immediately after them for that same destination. Every +interceptor receives a `LogInterceptorContext` whose `interface` value is the +current destination, which allows a single global interceptor to allow one +interface to receive an event while dropping it for another. + +Returning `null` from an interceptor drops the event for the current scope. +Returning a modified `LogEvent` allows you to rewrite the message, metadata, +level, stack trace, or extra payload before it is logged. + Finally, add any additional persistent metadata to your log messages (optional) and log a message: @@ -381,18 +428,75 @@ Arcane.log( level: Level.debug, module: "ModuleName", method: "MethodName", - metadata: {"key": "value"}, + metadata: {"key": "value", "attempt": 1}, stackTrace: StackTrace.current, ); ``` -Multiple logging interfaces can be registered simultaneously. +You can also add and remove global interceptors after startup. Because every +interceptor receives a `LogInterceptorContext`, a single global interceptor can +still make interface-specific decisions by checking `context.interface`. If you +prefer, you can also define your own interceptor class by implementing +`LogInterceptor` instead of using the callback constructor. + +```dart +final LogInterceptor redactSecrets = LogInterceptor(( + event, { + required LogInterceptorContext context, +}) { + final Object? token = event.metadata?["token"]; + if (token == null) return event; + + return event.copyWith( + metadata: { + ...?event.metadata, + "token": "[redacted]", + }, + ); +}); + +Arcane.logger.registerInterceptor(redactSecrets); +Arcane.logger.unregisterInterceptor(redactSecrets); +``` + +If you prefer a reusable named type, you can also implement `LogInterceptor` +directly: + +```dart +class RedactingLogInterceptor implements LogInterceptor { + const RedactingLogInterceptor(); + + @override + LogEvent? call( + LogEvent event, { + required LogInterceptorContext context, + }) { + final Object? token = event.metadata?["token"]; + if (token == null) return event; + + return event.copyWith( + metadata: { + ...?event.metadata, + "token": "[redacted]", + }, + ); + } +} + +final LogInterceptor redactSecrets = RedactingLogInterceptor(); + +Arcane.logger.registerInterceptor(redactSecrets); +Arcane.logger.unregisterInterceptor(redactSecrets); +``` + +Multiple logging interfaces and multiple interceptors can be registered +simultaneously. Interface-specific interceptors receive copied `LogEvent` +instances, so mutations made for one destination do not leak into another. -**Important**: Logging interfaces should generally be initialized after being -registered with the logger service. This ensures that all logging interfaces are -properly initialized before any messages are logged. This should typically be -done manually in order to properly present the user with a message stating that -they're about to be prompted for tracking permissions (on iOS). +**Important**: Initialization is now optional per interface. Call +`initializeInterfaces()` when you have interfaces that opt into +`LoggingInitializable` (for example via `LoggingInitializationMixin`). Simple +destinations like a debug console can skip initialization entirely. ### Authentication diff --git a/example/lib/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index ec9f2d9..83b3215 100644 --- a/example/lib/interfaces/debug_auth_interface.dart +++ b/example/lib/interfaces/debug_auth_interface.dart @@ -5,10 +5,7 @@ typedef Credentials = ({String email, String password}); class DebugAuthInterface with ArcaneAuthAccountRegistration, ArcaneAuthPasswordManagement implements ArcaneAuthInterface { - DebugAuthInterface._internal(); - - static final ArcaneAuthInterface _instance = DebugAuthInterface._internal(); - static ArcaneAuthInterface get I => _instance; + DebugAuthInterface(); @override Future get isSignedIn => Future.value(_isSignedIn); diff --git a/example/lib/interfaces/debug_print_interface.dart b/example/lib/interfaces/debug_print_interface.dart index d1a9b01..b42e91c 100644 --- a/example/lib/interfaces/debug_print_interface.dart +++ b/example/lib/interfaces/debug_print_interface.dart @@ -1,28 +1,15 @@ import "package:arcane_framework/arcane_framework.dart"; -import "package:example/config.dart"; import "package:flutter/foundation.dart"; -class DebugPrint implements LoggingInterface { - DebugPrint._internal(); - static final DebugPrint _instance = DebugPrint._internal(); - static DebugPrint get I => _instance; - - @override - bool get initialized => true; - +class DebugPrint extends LoggingInterface { @override void log( String message, { - Map? metadata, + Map? metadata, Level? level = Level.debug, StackTrace? stackTrace, Object? extra, }) { - if (Feature.logging.disabled) return; - debugPrint("[${level!.name}] $message ($metadata)"); } - - @override - Future init() async => I; } diff --git a/example/lib/main.dart b/example/lib/main.dart index 8249e30..b6a1180 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,13 +9,27 @@ import "package:example/theme/theme.dart"; import "package:flutter/material.dart"; Future main() async { + final DebugPrint debugPrintInterface = DebugPrint(); + final DebugAuthInterface debugAuthInterface = DebugAuthInterface(); + // If any Feature enum items are `enabledAtStartup`, enable them within Arcane. for (final Feature feature in Feature.values) { if (feature.enabledAtStartup) Arcane.features.enableFeature(feature); } // Register the logging interface - await Arcane.logger.registerInterface(DebugPrint.I); + await Arcane.logger.registerInterface( + debugPrintInterface, + interceptors: [ + LogInterceptor((event, {required context}) { + if (context.interface is DebugPrint && Feature.logging.disabled) { + return null; + } + + return event; + }), + ], + ); // Add some persistent metadata to be used in every future log message Arcane.logger.addPersistentMetadata({ @@ -23,7 +37,7 @@ Future main() async { }); // Register the authentication interface - await Arcane.auth.registerInterface(DebugAuthInterface.I); + await Arcane.auth.registerInterface(debugAuthInterface); // Set the light and dark mode themes using our pre-defined ThemeData classes Arcane.theme diff --git a/lib/src/services/logging/log_event.dart b/lib/src/services/logging/log_event.dart new file mode 100644 index 0000000..aabd2c5 --- /dev/null +++ b/lib/src/services/logging/log_event.dart @@ -0,0 +1,39 @@ +part of "logging_service.dart"; + +final class LogEvent { + static const Object _sentinel = Object(); + + const LogEvent({ + required this.message, + this.metadata, + this.level, + this.stackTrace, + this.extra, + }); + + final String message; + final Map? metadata; + final Level? level; + final StackTrace? stackTrace; + final Object? extra; + + LogEvent copyWith({ + String? message, + Object? metadata = _sentinel, + Object? level = _sentinel, + Object? stackTrace = _sentinel, + Object? extra = _sentinel, + }) { + return LogEvent( + message: message ?? this.message, + metadata: identical(metadata, _sentinel) + ? this.metadata + : metadata as Map?, + level: identical(level, _sentinel) ? this.level : level as Level?, + stackTrace: identical(stackTrace, _sentinel) + ? this.stackTrace + : stackTrace as StackTrace?, + extra: identical(extra, _sentinel) ? this.extra : extra, + ); + } +} diff --git a/lib/src/services/logging/log_interceptor.dart b/lib/src/services/logging/log_interceptor.dart new file mode 100644 index 0000000..60930a8 --- /dev/null +++ b/lib/src/services/logging/log_interceptor.dart @@ -0,0 +1,25 @@ +part of "logging_service.dart"; + +final class LogInterceptorContext { + const LogInterceptorContext({ + this.interface, + }); + + final LoggingInterface? interface; +} + +class LogInterceptor { + const LogInterceptor(this._callback); + + final LogEvent? Function( + LogEvent event, { + required LogInterceptorContext context, + }) _callback; + + LogEvent? call( + LogEvent event, { + required LogInterceptorContext context, + }) { + return _callback(event, context: context); + } +} diff --git a/lib/src/services/logging/logging_interface.dart b/lib/src/services/logging/logging_interface.dart index 269061f..065b57f 100644 --- a/lib/src/services/logging/logging_interface.dart +++ b/lib/src/services/logging/logging_interface.dart @@ -5,36 +5,42 @@ part of "logging_service.dart"; /// Concrete implementations of this class should override the [log] method to provide /// platform-specific logging behavior. abstract class LoggingInterface { - LoggingInterface._internal(); - static late final LoggingInterface _instance; + const LoggingInterface([this.feature]); - /// Provides access to the singleton instance of the `LoggingInterface`. This - /// ensures that the logging interface, once configured, remains so. - static LoggingInterface get I => _instance; - - bool _initialized = false; - - /// Whether the logging interface has been initialized. - bool get initialized => I._initialized; - - /// Initializes the logging interface. - /// - /// If any configuration needs to be performed on the logging interface prior - /// to use, this is where it should be done. - /// This method should, at a minimum, set `I._initialized = true`. - Future init() async { - I._initialized = true; - return I; - } + /// An optional tag that can be used to identify the destination this + /// interface represents (for example, "my-feature" or "auth"). + final String? feature; /// This method is called by the `ArcaneLogger` when a log message is /// received. See `ArcaneLogger.log` for further details on how logging /// works and what options are available. void log( String message, { - Map? metadata, + Map? metadata, Level? level, StackTrace? stackTrace, Object? extra, }); } + +/// Optional lifecycle contract for logging interfaces that require setup. +abstract interface class LoggingInitializable { + /// Whether this logging destination has completed initialization. + bool get initialized; + + /// Initializes this logging destination. + Future init(); +} + +/// Default initialization behavior for interfaces that opt into lifecycle. +mixin LoggingInitializationMixin implements LoggingInitializable { + bool _initialized = false; + + @override + bool get initialized => _initialized; + + @override + Future init() async { + _initialized = true; + } +} diff --git a/lib/src/services/logging/logging_service.dart b/lib/src/services/logging/logging_service.dart index 6994add..05677de 100644 --- a/lib/src/services/logging/logging_service.dart +++ b/lib/src/services/logging/logging_service.dart @@ -2,6 +2,8 @@ import "dart:async"; import "package:arcane_helper_utils/arcane_helper_utils.dart"; +part "log_event.dart"; +part "log_interceptor.dart"; part "logging_enums.dart"; part "logging_interface.dart"; @@ -19,10 +21,21 @@ class ArcaneLogger { /// Provides access to the singleton instance of `ArcaneLogger`. static ArcaneLogger get I => _instance; - final List _interfaces = []; + final List<_LoggingInterfaceRegistration> _interfaceRegistrations = []; + + final List _interceptors = []; /// A list of registered logging interfaces. - List get interfaces => I._interfaces; + List get interfaces => [ + for (final _LoggingInterfaceRegistration registration + in I._interfaceRegistrations) + registration.interface, + ]; + + /// A list of globally registered interceptors. + List get interceptors => [ + ...I._interceptors, + ]; final Map _additionalMetadata = {}; @@ -86,7 +99,7 @@ class ArcaneLogger { /// The stack trace associated with the log event. Useful for error and /// warning logs to trace the execution path leading to the log event. /// - /// - `metadata` ([Map?], _optional_): + /// - `metadata` ([Map?], _optional_): /// Additional key-value pairs providing extra context for the log. Commonly /// used for custom information that can aid in diagnosing issues or /// understanding the log in context. If not provided, an empty map is used. @@ -157,7 +170,7 @@ class ArcaneLogger { /// [false] (default), the module, method, and/or [filenameAndLineNumber] /// will be automatically determined and added to the [metadata] if the /// values are not already present. - Map? metadata, + Map? metadata, /// The [extra] parameter can be used to pass _any_ object into the /// registered [LoggingInterface]s. @@ -180,8 +193,13 @@ class ArcaneLogger { /// impact performance. bool skipAutodetection = false, }) { - metadata ??= {}; - metadata.putIfAbsent("timestamp", () => DateTime.now().toIso8601String()); + final Map logMetadata = { + ...?metadata, + }; + logMetadata.putIfAbsent( + "timestamp", + () => DateTime.now().toIso8601String(), + ); String? filenameAndLineNumber; if (!skipAutodetection) { @@ -217,80 +235,138 @@ class ArcaneLogger { // Module management if (module.isNotEmptyOrNull) { - metadata.putIfAbsent("module", () => module!); + logMetadata.putIfAbsent("module", () => module!); } // Method managmeent if (method.isNotEmptyOrNull) { - metadata.putIfAbsent("method", () => method!); + logMetadata.putIfAbsent("method", () => method!); } // Filename and line number management if (filenameAndLineNumber.isNotNullOrEmpty) { - metadata.putIfAbsent( + logMetadata.putIfAbsent( "filenameAndLineNumber", () => filenameAndLineNumber!, ); } - metadata.addAll(additionalMetadata); - - module ??= metadata.containsKey("module") ? metadata["module"] : null; - method ??= metadata.containsKey("method") ? metadata["method"] : null; + logMetadata.addAll(additionalMetadata); + + module ??= logMetadata.containsKey("module") + ? logMetadata["module"] as String? + : null; + method ??= logMetadata.containsKey("method") + ? logMetadata["method"] as String? + : null; + + final LogEvent event = LogEvent( + message: message, + metadata: logMetadata, + level: level, + stackTrace: stackTrace, + extra: extra, + ); // Send logs to registered interface(s) - for (final LoggingInterface i in I._interfaces) { + for (final _LoggingInterfaceRegistration registration + in I._interfaceRegistrations) { if (initialized) { - i.log( - message, - level: level, - metadata: metadata, - stackTrace: stackTrace, - extra: extra, + final LogEvent? interfaceEvent = _runInterceptors( + event.copyWith( + metadata: event.metadata == null + ? null + : Map.from(event.metadata!), + ), + interceptors: [ + ...I._interceptors, + ...registration.interceptors, + ], + context: LogInterceptorContext(interface: registration.interface), + ); + + if (interfaceEvent == null) continue; + + registration.interface.log( + interfaceEvent.message, + level: interfaceEvent.level, + metadata: interfaceEvent.metadata, + stackTrace: interfaceEvent.stackTrace, + extra: interfaceEvent.extra, ); } } _logStreamController.add( - "$message ${{ - "level": level, - "metadata": metadata, - "extra": extra, + "${event.message} ${{ + "level": event.level, + "metadata": event.metadata, + "extra": event.extra, }}", ); } /// Registers a [LoggingInterface] with the [ArcaneLogger]. - /// Due to iOS app tracking permissions, permission to track must first be - /// checked for and (optionally) granted before the interface is automatically - /// initialized. /// - /// Once your [LoggingInterface] has been registered and initialized, logs - /// will automatically be sent to the interface. + /// Once your [LoggingInterface] has been registered, logs are eligible to be + /// sent to the interface immediately. Future registerInterface( - LoggingInterface loggingInterface, - ) async { + LoggingInterface loggingInterface, { + List? interceptors, + }) async { if (!initialized) await _init(); - I._interfaces.add(loggingInterface); + I._interfaceRegistrations.add( + _LoggingInterfaceRegistration( + interface: loggingInterface, + interceptors: interceptors, + ), + ); + + return I; + } + + /// Registers a global [LogInterceptor] to run before interface fan-out. + ArcaneLogger registerInterceptor(LogInterceptor interceptor) { + I._interceptors.add(interceptor); + return I; + } + + /// Registers a `List` of global [LogInterceptor]s. + ArcaneLogger registerInterceptors(List interceptors) { + I._interceptors.addAll(interceptors); + return I; + } + /// Unregisters a previously registered global [LogInterceptor]. + ArcaneLogger unregisterInterceptor(LogInterceptor interceptor) { + I._interceptors.remove(interceptor); + return I; + } + + /// Removes all previously registered global interceptors. + ArcaneLogger clearInterceptors() { + I._interceptors.clear(); return I; } /// Registers a `List` of [LoggingInterface] with the [ArcaneLogger]. - /// Due to iOS app tracking permissions, permission to track must first be - /// checked for and (optionally) granted before the interface is automatically - /// initialized. /// - /// Once your [LoggingInterface] has been registered and initialized, logs - /// will automatically be sent to the interface. + /// Once registered, logs are eligible to be sent to these interfaces + /// immediately. Future registerInterfaces( - List interfaces, - ) async { + List interfaces, { + Map>? interceptors, + }) async { if (!initialized) await _init(); for (final LoggingInterface i in interfaces) { - I._interfaces.add(i); + I._interfaceRegistrations.add( + _LoggingInterfaceRegistration( + interface: i, + interceptors: interceptors?[i], + ), + ); } return I; @@ -303,7 +379,10 @@ class ArcaneLogger { ) async { if (!initialized) await _init(); - I._interfaces.remove(interface); + I._interfaceRegistrations.removeWhere( + (_LoggingInterfaceRegistration registration) => + identical(registration.interface, interface), + ); return I; } @@ -316,7 +395,10 @@ class ArcaneLogger { if (!initialized) await _init(); for (final LoggingInterface i in interfaces) { - I._interfaces.remove(i); + I._interfaceRegistrations.removeWhere( + (_LoggingInterfaceRegistration registration) => + identical(registration.interface, i), + ); } return I; @@ -326,21 +408,31 @@ class ArcaneLogger { /// [ArcaneLogger], if any were previously registered. Future unregisterAllInterfaces() async { if (!initialized) await _init(); - I._interfaces.clear(); + I._interfaceRegistrations.clear(); return I; } - /// Initializes all registered [LoggingInterface]s by calling their - /// [LoggingInterface.init] methods. + /// Initializes registered interfaces that opt into [LoggingInitializable]. + /// + /// Interfaces that do not implement [LoggingInitializable] are skipped. Future initializeInterfaces() async { - if (I._interfaces.isEmptyOrNull) { + if (I._interfaceRegistrations.isEmptyOrNull) { throw Exception("No logging interfaces have been registered."); } if (!initialized) await _init(); - for (final LoggingInterface i in I._interfaces) { - if (!i.initialized) await i.init(); + for (final _LoggingInterfaceRegistration registration + in I._interfaceRegistrations) { + final LoggingInterface loggingInterface = registration.interface; + final LoggingInitializable? initializable = + loggingInterface is LoggingInitializable + ? loggingInterface as LoggingInitializable + : null; + + if (initializable != null && !initializable.initialized) { + await initializable.init(); + } } return I; @@ -388,8 +480,38 @@ class ArcaneLogger { /// clearing all registered [LoggingInterface]s and marking the logging /// service as no longer being initialized. void reset() { - I._interfaces.clear(); + I._interfaceRegistrations.clear(); + I._interceptors.clear(); I._initialized = false; I._additionalMetadata.clear(); } + + LogEvent? _runInterceptors( + LogEvent event, { + required List interceptors, + required LogInterceptorContext context, + }) { + LogEvent? currentEvent = event; + + for (final LogInterceptor interceptor in List.from( + interceptors, + )) { + if (currentEvent == null) return null; + currentEvent = interceptor(currentEvent, context: context); + } + + return currentEvent; + } +} + +final class _LoggingInterfaceRegistration { + _LoggingInterfaceRegistration({ + required this.interface, + List? interceptors, + }) : interceptors = [ + ...?interceptors, + ]; + + final LoggingInterface interface; + final List interceptors; } diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart index c9dc943..d1539ac 100644 --- a/test/services/logging/logging_service_test.dart +++ b/test/services/logging/logging_service_test.dart @@ -1,22 +1,98 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter_test/flutter_test.dart"; -import "package:mockito/annotations.dart"; -import "package:mockito/mockito.dart"; -import "logging_service_test.mocks.dart"; +class TestLoggingInterface extends LoggingInterface + with LoggingInitializationMixin { + TestLoggingInterface(this.name, [super.feature]); + + final String name; + int initCallCount = 0; + final List events = []; + + @override + Future init() async { + await super.init(); + initCallCount += 1; + } + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + events.add( + LogEvent( + message: message, + metadata: metadata == null ? null : Map.from(metadata), + level: level, + stackTrace: stackTrace, + extra: extra, + ), + ); + } +} + +class TestPassiveLoggingInterface extends LoggingInterface { + TestPassiveLoggingInterface(this.name, [super.feature]); + + final String name; + final List events = []; + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + events.add( + LogEvent( + message: message, + metadata: metadata == null ? null : Map.from(metadata), + level: level, + stackTrace: stackTrace, + extra: extra, + ), + ); + } +} -class MyOtherLoggingInterface extends Mock implements MockLoggingInterface {} +class RedactingLogInterceptor implements LogInterceptor { + const RedactingLogInterceptor(); + + @override + LogEvent? call( + LogEvent event, { + required LogInterceptorContext context, + }) { + final Object? token = event.metadata?["token"]; + if (token == null) return event; + + return event.copyWith( + metadata: { + ...?event.metadata, + "token": "[redacted]", + }, + ); + } +} -@GenerateNiceMocks([ - MockSpec( - onMissingStub: OnMissingStub.returnDefault, - ), -]) void main() { - final LoggingInterface myInterface = MockLoggingInterface(); + late TestLoggingInterface myInterface; + late LogInterceptor prefixInterceptor; setUp(() { Arcane.logger.reset(); + myInterface = TestLoggingInterface("primary"); + prefixInterceptor = LogInterceptor( + (event, {required context}) { + return event.copyWith(message: "[global] ${event.message}"); + }, + ); }); group("ArcaneLogger", () { @@ -36,7 +112,7 @@ void main() { expect(Arcane.logger.interfaces.first, isA()); expect(myInterface.initialized, false); - verifyNever(myInterface.init()); + expect(myInterface.initCallCount, 0); }); test("registering an interface initializes the logger", () async { @@ -50,26 +126,82 @@ void main() { test("interfaces can be initialized through the logger", () async { await Arcane.logger.registerInterface(myInterface); - expect(Arcane.logger.interfaces.first.initialized, false); + expect(myInterface.initialized, false); + + await Arcane.logger.initializeInterfaces(); + + expect(myInterface.initCallCount, 1); + }); + + test("non-initializable interfaces are skipped by initializeInterfaces", + () async { + final TestPassiveLoggingInterface passiveInterface = + TestPassiveLoggingInterface("passive"); + + await Arcane.logger.registerInterfaces([ + myInterface, + passiveInterface, + ]); await Arcane.logger.initializeInterfaces(); - verify(Arcane.logger.interfaces.first.init()).called(1); + expect(myInterface.initCallCount, 1); + + Arcane.log("hello"); + + expect(myInterface.events.single.message, "hello"); + expect(passiveInterface.events.single.message, "hello"); }); test("multiple interfaces can be registered", () async { await Arcane.logger.registerInterfaces([ - MockLoggingInterface(), - MyOtherLoggingInterface(), + TestLoggingInterface("first"), + TestLoggingInterface("second"), ]); expect( Arcane.logger.interfaces, - contains(isA()), + contains(isA()), ); expect( - Arcane.logger.interfaces, - contains(isA()), + Arcane.logger.interfaces.length, + 2, + ); + }); + + test("global interceptors can be registered at runtime", () async { + await Arcane.logger.registerInterface(myInterface); + + Arcane.log("before"); + Arcane.logger.registerInterceptor(prefixInterceptor); + Arcane.log("after"); + + expect(myInterface.events[0].message, "before"); + expect(myInterface.events[1].message, "[global] after"); + }); + + test("interface interceptors can be registered at runtime", () async { + final LogInterceptor dropForPrimary = LogInterceptor( + (event, {required LogInterceptorContext context}) { + if ((context.interface as TestLoggingInterface).name == "primary") { + return null; + } + + return event; + }, + ); + + await Arcane.logger.registerInterface(myInterface); + + Arcane.log("before"); + Arcane.logger.registerInterceptor(dropForPrimary); + Arcane.log("blocked"); + Arcane.logger.unregisterInterceptor(dropForPrimary); + Arcane.log("after"); + + expect( + myInterface.events.map((LogEvent event) => event.message), + ["before", "after"], ); }); }); @@ -105,15 +237,7 @@ void main() { test("logging a basic message works", () async { Arcane.log(logMessage); - verify( - myInterface.log( - logMessage, - metadata: anyNamed("metadata"), - level: anyNamed("level"), - stackTrace: anyNamed("stackTrace"), - extra: anyNamed("extra"), - ), - ).called(1); + expect(myInterface.events.single.message, logMessage); }); test("logging at a different level works", () async { @@ -122,45 +246,21 @@ void main() { level: Level.info, ); - verify( - myInterface.log( - logMessage, - metadata: anyNamed("metadata"), - level: Level.info, - stackTrace: anyNamed("stackTrace"), - extra: anyNamed("extra"), - ), - ).called(1); + expect(myInterface.events.last.level, Level.info); Arcane.log( logMessage, level: Level.warning, ); - verify( - myInterface.log( - logMessage, - metadata: anyNamed("metadata"), - level: Level.warning, - stackTrace: anyNamed("stackTrace"), - extra: anyNamed("extra"), - ), - ).called(1); + expect(myInterface.events.last.level, Level.warning); }); test("logging a stacktrace works", () async { final stackTrace = StackTrace.current; Arcane.log(logMessage, stackTrace: stackTrace); - verify( - myInterface.log( - logMessage, - metadata: anyNamed("metadata"), - level: anyNamed("level"), - stackTrace: stackTrace, - extra: anyNamed("extra"), - ), - ).called(1); + expect(myInterface.events.single.stackTrace, stackTrace); }); test("logging an extra object works", () async { @@ -170,15 +270,7 @@ void main() { extra: extraObject, ); - verify( - myInterface.log( - logMessage, - metadata: anyNamed("metadata"), - level: anyNamed("level"), - stackTrace: anyNamed("stackTrace"), - extra: extraObject, - ), - ).called(1); + expect(myInterface.events.single.extra, extraObject); }); test("logging metadata works", () async { @@ -188,15 +280,165 @@ void main() { metadata: metadata, ); - verify( - myInterface.log( - logMessage, - metadata: metadata, - level: anyNamed("level"), - stackTrace: anyNamed("stackTrace"), - extra: anyNamed("extra"), - ), - ).called(1); + expect(myInterface.events.single.metadata?["test"], "value"); + expect( + myInterface.events.single.metadata?.containsKey("timestamp"), + true, + ); + }); + + test("global interceptors run in registration order", () async { + Arcane.logger.registerInterceptors([ + LogInterceptor((event, {required context}) { + expect(context.interface, same(myInterface)); + return event.copyWith(message: "${event.message}:first"); + }), + LogInterceptor((event, {required context}) { + expect(context.interface, same(myInterface)); + return event.copyWith(message: "${event.message}:second"); + }), + ]); + + Arcane.log(logMessage); + + expect(myInterface.events.single.message, "Test:first:second"); + }); + + test("global interceptors can drop events for all interfaces", () async { + await Arcane.logger.registerInterface(myInterface); + Arcane.logger.registerInterceptor( + LogInterceptor((event, {required context}) => null), + ); + + Arcane.log(logMessage); + + expect(myInterface.events, isEmpty); + }); + + test("custom interceptor classes can implement LogInterceptor", () async { + Arcane.logger.registerInterceptor(const RedactingLogInterceptor()); + + Arcane.log( + logMessage, + metadata: {"token": "secret-token"}, + ); + + expect( + myInterface.events.single.metadata?["token"], + "[redacted]", + ); + }); + + test("interface interceptors can drop events per destination", () async { + final TestLoggingInterface secondaryInterface = + TestLoggingInterface("secondary"); + final LogInterceptor allowPrimaryOnly = LogInterceptor( + (event, {required LogInterceptorContext context}) { + final String name = + (context.interface as TestLoggingInterface).name; + return name == "primary" ? event : null; + }, + ); + + Arcane.logger.registerInterceptor(allowPrimaryOnly); + await Arcane.logger.registerInterface( + secondaryInterface, + ); + + Arcane.log(logMessage); + + expect(myInterface.events.single.message, logMessage); + expect(secondaryInterface.events, isEmpty); + }); + + test("interface interceptors receive the current interface", () async { + final TestLoggingInterface secondaryInterface = + TestLoggingInterface("secondary"); + + Arcane.logger.registerInterceptor( + LogInterceptor((event, {required context}) { + final TestLoggingInterface currentInterface = + context.interface! as TestLoggingInterface; + return event.copyWith( + metadata: { + ...?event.metadata, + "target": currentInterface.name, + }, + ); + }), + ); + await Arcane.logger.registerInterface( + secondaryInterface, + ); + + Arcane.log(logMessage); + + expect(myInterface.events.single.metadata?["target"], "primary"); + expect( + secondaryInterface.events.single.metadata?["target"], + "secondary", + ); + }); + + test("interface interceptors cannot mutate sibling interface events", + () async { + final TestLoggingInterface secondaryInterface = + TestLoggingInterface("secondary"); + + Arcane.logger.registerInterceptor( + LogInterceptor((event, {required context}) { + event.metadata?["mutatedBy"] = + (context.interface as TestLoggingInterface).name; + return event; + }), + ); + await Arcane.logger.registerInterface( + secondaryInterface, + ); + + Arcane.log( + logMessage, + metadata: {"test": "value"}, + ); + + expect(myInterface.events.single.metadata?["mutatedBy"], "primary"); + expect( + secondaryInterface.events.single.metadata?["mutatedBy"], + "secondary", + ); + }); + + test("unregistering an interface clears registration interceptors", + () async { + final TestLoggingInterface secondaryInterface = + TestLoggingInterface("secondary"); + + await Arcane.logger.unregisterInterface(myInterface); + myInterface = TestLoggingInterface("primary-with-drop"); + await Arcane.logger.registerInterface( + myInterface, + interceptors: [ + LogInterceptor((event, {required context}) => null), + ], + ); + + await Arcane.logger.unregisterInterface(myInterface); + await Arcane.logger.registerInterface(secondaryInterface); + + Arcane.log(logMessage); + + expect(myInterface.events, isEmpty); + expect(secondaryInterface.events.single.message, logMessage); + }); + + test("reset clears global interceptors", () async { + Arcane.logger.registerInterceptor(prefixInterceptor); + Arcane.logger.reset(); + await Arcane.logger.registerInterface(myInterface); + + Arcane.log(logMessage); + + expect(myInterface.events.single.message, logMessage); }); }); }); From d7fd6428c6f9b5b1ba6736a8035f6ff5980e5d9c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 21 May 2026 18:34:21 +0200 Subject: [PATCH 33/58] Add stream-based updates for authentication, feature flags, logging, and theming - Added `statusChanges` and `signedInChanges` streams to `ArcaneAuth` for real-time authentication updates. - Introduced `enabledFeaturesChanges` stream in `ArcaneFeatureFlags` for observing feature updates. - Improved `ArcaneLogger` to maintain `logStream` usability after listener cancellation. - Updated `ArcaneTheme` to provide streams for theme mode and theme data changes. - Enhanced documentation and examples to reflect new stream APIs and usage patterns. - Added regression tests for stream listener cancellation and re-subscription across services. Signed-off-by: Hans Kokx --- CHANGELOG.md | 36 +++ README.md | 117 +++++++- example/README.md | 43 ++- .../authentication_service.dart | 34 +++ .../feature_flags/feature_flags_service.dart | 24 ++ lib/src/services/logging/logging_service.dart | 23 +- .../reactive_theme_service.dart | 65 +++-- pubspec.yaml | 3 +- test/arcane_test.dart | 3 - .../authentication_service_test.dart | 86 ++++-- .../authentication_service_test.mocks.dart | 275 ------------------ .../feature_flags_service_test.dart | 50 +++- .../logging/logging_service_test.dart | 26 ++ .../logging/logging_service_test.mocks.dart | 69 ----- .../reactive_theme_service_test.dart | 80 ++++- 15 files changed, 496 insertions(+), 438 deletions(-) delete mode 100644 test/services/authentication/authentication_service_test.mocks.dart delete mode 100644 test/services/logging/logging_service_test.mocks.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9527d24..55a3dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## Unreleased +### Authentication Service (ArcaneAuth) Updates + +- [NEW] Added `statusChanges` stream to observe `AuthenticationStatus` updates. +- [NEW] Added `signedInChanges` stream to observe sign-in state changes. +- [FIX] Added stream lifecycle cleanup in `dispose` with safe lazy recreation. + +### Feature Flags Service (ArcaneFeatureFlags) Updates + +- [NEW] Added `enabledFeaturesChanges` stream to observe enabled feature + updates in realtime. +- [FIX] Added stream lifecycle cleanup in `dispose` with safe lazy recreation. + +### Logging Service (ArcaneLogger) Updates + +- [FIX] `logStream` no longer closes when an individual listener cancels + (prevents stale stream state during widget lifecycle changes and hot reload + flows). +- [NEW] Added explicit `dispose` cleanup for logger stream resources. + +### Theme (ArcaneTheme) Updates + +- [FIX] Reactive theme stream controllers now close only during service dispose, + preventing stream shutdown when a single subscriber cancels. + +### Test Coverage Updates + +- [NEW] Added regression tests for stream listener cancellation and + re-subscription in logging, theme, feature flags, and authentication. + +### Documentation and Example Updates + +- [UPDATE] README now documents stream APIs for authentication, feature flags, + logging, and reactive theme, including subscription lifecycle patterns. +- [UPDATE] Example README now includes run instructions and references for + stream lifecycle usage in the demo app. + ### Logging Service (Upcoming Contract Changes) - [BREAKING] `LoggingInterface` no longer includes built-in singleton-style diff --git a/README.md b/README.md index 2847f8a..25a59d4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ and service management. workflows. - **Dynamic Theming**: Switch between light and dark themes with `ArcaneReactiveTheme`. +- **Realtime Streams**: In addition to `ValueNotifier`s, core services expose + broadcast streams for reactive consumers. ## Installation @@ -309,6 +311,27 @@ Arcane.features.notifier.addListener(() { }); ``` +If you prefer stream-based subscriptions, you can listen to +`enabledFeaturesChanges` and cancel the subscription in `dispose`. + +```dart +late final StreamSubscription> subscription; + +@override +void initState() { + super.initState(); + subscription = Arcane.features.enabledFeaturesChanges.listen((features) { + print("Features changed: $features"); + }); +} + +@override +void dispose() { + subscription.cancel(); + super.dispose(); +} +``` + Note that it is possible to register multiple different `Enum` types in the feature flag service, should one have a need to do so. @@ -433,6 +456,27 @@ Arcane.log( ); ``` +You can also listen to `logStream` for realtime log events, and cancel and +re-register subscribers as widget lifecycles change: + +```dart +late final StreamSubscription logSubscription; + +@override +void initState() { + super.initState(); + logSubscription = Arcane.logger.logStream.listen((message) { + debugPrint("Log stream event: $message"); + }); +} + +@override +void dispose() { + logSubscription.cancel(); + super.dispose(); +} +``` + You can also add and remove global interceptors after startup. Because every interceptor receives a `LogInterceptorContext`, a single global interceptor can still make interface-specific decisions by checking `context.interface`. If you @@ -667,6 +711,33 @@ final result = await Arcane.auth.login( await Arcane.auth.logout(); ``` +Authentication updates can also be consumed through streams: + +```dart +late final StreamSubscription statusSubscription; +late final StreamSubscription signedInSubscription; + +@override +void initState() { + super.initState(); + + statusSubscription = Arcane.auth.statusChanges.listen((status) { + debugPrint("Auth status changed: $status"); + }); + + signedInSubscription = Arcane.auth.signedInChanges.listen((signedIn) { + debugPrint("Is signed in: $signedIn"); + }); +} + +@override +void dispose() { + statusSubscription.cancel(); + signedInSubscription.cancel(); + super.dispose(); +} +``` + ### Dynamic Theming The Arcane Framework provides a simple interface for managing themes in your @@ -703,22 +774,22 @@ class MainApp extends StatefulWidget { } class _MainAppState extends State { + @override + void didChangeDependencies() { + Arcane.theme.followSystemTheme(context); + super.didChangeDependencies(); + } + @override Widget build(BuildContext context) { return ArcaneApp( child: MaterialApp( theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.systemTheme.value, + themeMode: Arcane.theme.currentModeOf(context), ), ); } - - @override - void didChangeDependencies() { - Arcane.theme.followSystemTheme(context); - super.didChangeDependencies(); - } } ``` @@ -727,13 +798,15 @@ or manually control the theme mode: ```dart // Manually control the theme mode class MainApp extends StatelessWidget { + const MainApp({super.key}); + @override Widget build(BuildContext context) { return ArcaneApp( child: MaterialApp( theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.currentMode, + themeMode: Arcane.theme.currentModeOf(context), ), ); } @@ -747,7 +820,7 @@ Then, you can switch modes whenever you want: Arcane.theme.switchTheme(); // Access current theme data -final ThemeData currentTheme = Arcane.theme.currentMode == ThemeMode.dark +final ThemeData currentTheme = Arcane.theme.currentThemeMode == ThemeMode.dark ? Arcane.theme.dark : Arcane.theme.light; @@ -762,6 +835,32 @@ Arcane.theme.setDarkTheme(customDarkTheme); Arcane.theme.setLightTheme(customLightTheme); ``` +You can subscribe to theme streams to react to theme updates outside of widget +build methods: + +```dart +late final StreamSubscription modeSubscription; +late final StreamSubscription themeSubscription; + +@override +void initState() { + super.initState(); + modeSubscription = Arcane.theme.themeModeChanges.listen((mode) { + debugPrint("Theme mode changed: $mode"); + }); + themeSubscription = Arcane.theme.themeDataChanges.listen((themeData) { + debugPrint("Theme data changed: ${themeData.brightness}"); + }); +} + +@override +void dispose() { + modeSubscription.cancel(); + themeSubscription.cancel(); + super.dispose(); +} +``` + ## Contributing We welcome contributions to the Arcane Framework. If you’d like to contribute, diff --git a/example/README.md b/example/README.md index 1b7a4e3..e12ef93 100644 --- a/example/README.md +++ b/example/README.md @@ -1,3 +1,42 @@ -# example +# Arcane Framework Example -A new Flutter project. +This example app demonstrates the major Arcane services together in a single +Flutter UI: + +- Logging with interceptors and realtime log stream subscriptions +- Authentication with sign-in and sign-out actions +- Feature flag toggling with live UI updates +- Environment switching +- Theme switching and system theme following +- Custom app services via `ArcaneService` + +## Run the example + +From the repository root: + +```shell +cd example +flutter pub get +flutter run +``` + +## Where to look + +- `example/lib/main.dart`: app bootstrap, Arcane service setup, and all feature demos +- `example/lib/interfaces/debug_print_interface.dart`: sample logging interface +- `example/lib/interfaces/debug_auth_interface.dart`: sample auth provider +- `example/lib/services/favorite_color_service.dart`: custom Arcane service example + +## Stream subscription lifecycle + +The example intentionally uses stream subscriptions in widgets and cancels them +in `dispose` to model lifecycle-safe usage. + +Key patterns shown in the app include: + +- Subscribing to `Arcane.logger.logStream` in `initState` +- Canceling subscriptions in `dispose` +- Rebuilding UI from stream and notifier changes + +Use this app as a reference for combining Arcane streams and `ValueNotifier` +listeners in the same codebase. diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index 499fe00..6cdca49 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -25,6 +25,17 @@ class ArcaneAuthenticationService extends ArcaneService { /// A `ValueNotifier` that emits the current `AuthenticationStatus`. ValueNotifier get notifier => _notifier; + StreamController? _statusStreamController; + + StreamController get _statusController { + _statusStreamController ??= + StreamController.broadcast(); + return _statusStreamController!; + } + + /// Stream of authentication status updates. + Stream get statusChanges => I._statusController.stream; + /// Returns the current `AuthenticationStatus`. /// /// Available values: @@ -47,6 +58,16 @@ class ArcaneAuthenticationService extends ArcaneService { /// A `ValueNotifier` that emits `true` if the user is currently signed in. ValueNotifier get isSignedIn => _isSignedIn; + StreamController? _signedInStreamController; + + StreamController get _signedInController { + _signedInStreamController ??= StreamController.broadcast(); + return _signedInStreamController!; + } + + /// Stream of signed-in boolean updates. + Stream get signedInChanges => I._signedInController.stream; + /// Returns a JWT access token if the registered `ArcaneAuthInterface` /// provides one. This token is often used in the headers of HTTP requests /// to the backend API. @@ -66,6 +87,8 @@ class ArcaneAuthenticationService extends ArcaneService { _authInterface = null; _notifier.value = AuthenticationStatus.unauthenticated; _isSignedIn.value = isAuthenticated; + _statusController.add(_notifier.value); + _signedInController.add(_isSignedIn.value); _previousModeWhenSettingDebug = null; } @@ -155,9 +178,20 @@ class ArcaneAuthenticationService extends ArcaneService { if (_notifier.value != newStatus) { _notifier.value = newStatus; _isSignedIn.value = isAuthenticated; + _statusController.add(_notifier.value); + _signedInController.add(_isSignedIn.value); } } + @override + void dispose() { + unawaited(_statusStreamController?.close()); + unawaited(_signedInStreamController?.close()); + _statusStreamController = null; + _signedInStreamController = null; + super.dispose(); + } + /// Logs the current user out. Upon successful logout, `status` will be set to /// `AuthenticationStatus.unauthenticated`. Future> logOut({ diff --git a/lib/src/services/feature_flags/feature_flags_service.dart b/lib/src/services/feature_flags/feature_flags_service.dart index 93544fc..f33bc0b 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/foundation.dart"; @@ -37,6 +39,18 @@ class ArcaneFeatureFlags extends ArcaneService { /// A `ValueNotifier` that notifies listeners when the list of enabled features changes. ValueNotifier> get notifier => _notifier; + StreamController>? _enabledFeaturesStreamController; + + StreamController> get _enabledFeaturesController { + _enabledFeaturesStreamController ??= + StreamController>.broadcast(); + return _enabledFeaturesStreamController!; + } + + /// Stream of enabled feature list updates. + Stream> get enabledFeaturesChanges => + I._enabledFeaturesController.stream; + /// Indicates whether the feature flags have been initialized. bool _initialized = false; @@ -71,6 +85,7 @@ class ArcaneFeatureFlags extends ArcaneService { if (_enabledFeatures.contains(feature)) return I; _notifier.value = [..._enabledFeatures, feature]; + _enabledFeaturesController.add(List.from(_notifier.value)); if (Arcane.logger.initialized) { Arcane.logger.log( @@ -99,6 +114,7 @@ class ArcaneFeatureFlags extends ArcaneService { if (!_enabledFeatures.contains(feature)) return I; _notifier.value = [..._enabledFeatures]..removeWhere((i) => i == feature); + _enabledFeaturesController.add(List.from(_notifier.value)); if (Arcane.logger.initialized) { Arcane.logger.log( @@ -133,9 +149,17 @@ class ArcaneFeatureFlags extends ArcaneService { ..removeListener(_listener) ..addListener(_listener); _notifier.value = []; + _enabledFeaturesController.add(List.from(_notifier.value)); I._initialized = false; } + @override + void dispose() { + unawaited(_enabledFeaturesStreamController?.close()); + _enabledFeaturesStreamController = null; + super.dispose(); + } + void _listener() { _enabledFeatures ..clear() diff --git a/lib/src/services/logging/logging_service.dart b/lib/src/services/logging/logging_service.dart index 05677de..dbace2e 100644 --- a/lib/src/services/logging/logging_service.dart +++ b/lib/src/services/logging/logging_service.dart @@ -42,15 +42,15 @@ class ArcaneLogger { /// Additional metadata that is included in all logs. Map get additionalMetadata => I._additionalMetadata; - final StreamController _logStreamController = - StreamController.broadcast( - onCancel: () { - I._logStreamController.close(); - }, - ); + StreamController? _logStreamController; + + StreamController get _logController { + _logStreamController ??= StreamController.broadcast(); + return _logStreamController!; + } /// Stream of log messages being received and sent to the registered interfaces. - Stream get logStream => I._logStreamController.stream; + Stream get logStream => I._logController.stream; bool _initialized = false; @@ -297,7 +297,7 @@ class ArcaneLogger { } } - _logStreamController.add( + _logController.add( "${event.message} ${{ "level": event.level, "metadata": event.metadata, @@ -480,12 +480,19 @@ class ArcaneLogger { /// clearing all registered [LoggingInterface]s and marking the logging /// service as no longer being initialized. void reset() { + dispose(); I._interfaceRegistrations.clear(); I._interceptors.clear(); I._initialized = false; I._additionalMetadata.clear(); } + /// Closes logger streams and allows lazy recreation on subsequent access. + void dispose() { + unawaited(_logStreamController?.close()); + _logStreamController = null; + } + LogEvent? _runInterceptors( LogEvent event, { required List interceptors, diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart index 8436689..0c28cc3 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -34,12 +34,12 @@ class ArcaneReactiveTheme extends ArcaneService { /// Tracks the current system theme mode ThemeMode _currentSystemThemeMode = ThemeMode.system; - final StreamController _systemStreamController = - StreamController.broadcast( - onCancel: () { - I._systemStreamController.close(); - }, - ); + StreamController? _systemStreamController; + + StreamController get _systemController { + _systemStreamController ??= StreamController.broadcast(); + return _systemStreamController!; + } // ************************************************************************ // // * MARK: ThemeMode @@ -53,14 +53,14 @@ class ArcaneReactiveTheme extends ArcaneService { ThemeMode _currentThemeMode = ThemeMode.light; /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. - Stream get themeModeChanges => I._themeModeStreamController.stream; + Stream get themeModeChanges => I._themeModeController.stream; - final StreamController _themeModeStreamController = - StreamController.broadcast( - onCancel: () { - I._themeModeStreamController.close(); - }, - ); + StreamController? _themeModeStreamController; + + StreamController get _themeModeController { + _themeModeStreamController ??= StreamController.broadcast(); + return _themeModeStreamController!; + } // ************************************************************************ // // * MARK: ThemeData @@ -70,14 +70,14 @@ class ArcaneReactiveTheme extends ArcaneService { ThemeData _currentTheme = ThemeData(); /// Stream of `ThemeData` changes that can be listened to for reactive UI updates. - Stream get themeDataChanges => I._themeStreamController.stream; + Stream get themeDataChanges => I._themeController.stream; + + StreamController? _themeStreamController; - final StreamController _themeStreamController = - StreamController.broadcast( - onCancel: () { - I._themeStreamController.close(); - }, - ); + StreamController get _themeController { + _themeStreamController ??= StreamController.broadcast(); + return _themeStreamController!; + } // ************************************************************************ // // * MARK: Light/Dark theme @@ -143,11 +143,11 @@ class ArcaneReactiveTheme extends ArcaneService { _currentSystemThemeMode = context.isDarkMode ? ThemeMode.dark : ThemeMode.light; - _systemStreamController.add(_currentSystemThemeMode); + _systemController.add(_currentSystemThemeMode); _updateTheme(_currentSystemThemeMode); final ThemeData theme = systemThemeMode == ThemeMode.dark ? dark : light; - _themeStreamController.add(theme); + _themeController.add(theme); _currentTheme = theme; return I; @@ -164,7 +164,7 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme setDarkTheme(ThemeData theme) { _darkTheme.value = theme; - _themeStreamController.add(theme); + _themeController.add(theme); _currentTheme = theme; return I; @@ -181,7 +181,7 @@ class ArcaneReactiveTheme extends ArcaneService { /// ``` ArcaneReactiveTheme setLightTheme(ThemeData theme) { _lightTheme.value = theme; - _themeStreamController.add(theme); + _themeController.add(theme); _currentTheme = theme; return I; @@ -197,13 +197,26 @@ class ArcaneReactiveTheme extends ArcaneService { _lightTheme.value = ThemeData.light(); _followingSystemTheme = false; _updateTheme(ThemeMode.light); - _themeStreamController.add(_lightTheme.value); + _themeController.add(_lightTheme.value); _currentTheme = _lightTheme.value; } + @override + void dispose() { + unawaited(_systemStreamController?.close()); + unawaited(_themeModeStreamController?.close()); + unawaited(_themeStreamController?.close()); + + _systemStreamController = null; + _themeModeStreamController = null; + _themeStreamController = null; + + super.dispose(); + } + /// Updates the current theme mode and broadcasts the change. void _updateTheme(ThemeMode themeMode) { _currentThemeMode = themeMode; - _themeModeStreamController.add(themeMode); + _themeModeController.add(themeMode); } } diff --git a/pubspec.yaml b/pubspec.yaml index 2d6219d..7c1e784 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: dev_dependencies: arcane_analysis: any - build_runner: ^2.4.1 flutter_test: sdk: flutter - mockito: ^5.4.5 + mocktail: ^1.0.5 diff --git a/test/arcane_test.dart b/test/arcane_test.dart index a1c30ac..c580993 100644 --- a/test/arcane_test.dart +++ b/test/arcane_test.dart @@ -1,8 +1,5 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter_test/flutter_test.dart"; -import "package:mockito/mockito.dart"; - -class MockLoggingInterface extends Mock implements LoggingInterface {} void main() { setUpAll(() { diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index e6d88b9..a74086e 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -1,38 +1,36 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; -import "package:mockito/annotations.dart"; -import "package:mockito/mockito.dart"; +import "package:mocktail/mocktail.dart"; -import "authentication_service_test.mocks.dart"; +class MockArcaneAuthInterface extends Mock implements ArcaneAuthInterface {} -@GenerateMocks([ - ArcaneAuthInterface, - ArcaneEnvironmentProvider, -]) void main() { - late ArcaneAuthInterface mockInterface; + late ArcaneAuthInterface authInterface; group("ArcaneAuthenticationService", () { setUp(() async { - // Initialize mocks - mockInterface = MockArcaneAuthInterface(); + authInterface = MockArcaneAuthInterface(); // Initialize the service await ArcaneAuthenticationService.I.reset(); - // Set up default mock behaviors - when(mockInterface.login(input: anyNamed("input"))).thenAnswer( - (_) async => const Result.ok(null), - ); - when(mockInterface.logout()).thenAnswer( - (_) async => const Result.ok(null), - ); - when(mockInterface.init()).thenAnswer( - (_) async {}, - ); + when(() => authInterface.init()).thenAnswer((_) async {}); + + when( + () => authInterface.login>( + input: any(named: "input"), + onLoggedIn: any(named: "onLoggedIn"), + ), + ).thenAnswer((_) async => const Result.ok(null)); + + when( + () => authInterface.logout( + onLoggedOut: any(named: "onLoggedOut"), + ), + ).thenAnswer((_) async => const Result.ok(null)); - await ArcaneAuthenticationService.I.registerInterface(mockInterface); + await ArcaneAuthenticationService.I.registerInterface(authInterface); }); testWidgets("login with success", (WidgetTester tester) async { @@ -59,9 +57,12 @@ void main() { }); testWidgets("login with failure", (WidgetTester tester) async { - // Reset the mock behavior for this specific test - when(mockInterface.login(input: anyNamed("input"))) - .thenAnswer((_) async => const Result.error("error")); + when( + () => authInterface.login>( + input: any(named: "input"), + onLoggedIn: any(named: "onLoggedIn"), + ), + ).thenAnswer((_) async => const Result.error("error")); final result = await ArcaneAuthenticationService.I .login(input: {"username": "test"}); @@ -140,5 +141,42 @@ void main() { equals(Environment.normal), ); }); + + test("statusChanges emits authentication updates", () async { + final statusEvent = expectLater( + ArcaneAuthenticationService.I.statusChanges, + emits(AuthenticationStatus.authenticated), + ); + + ArcaneAuthenticationService.I.setAuthenticated(); + await statusEvent; + }); + + test("signedInChanges emits signed-in updates", () async { + final signedInEvent = expectLater( + ArcaneAuthenticationService.I.signedInChanges, + emits(true), + ); + + ArcaneAuthenticationService.I.setAuthenticated(); + await signedInEvent; + }); + + test("statusChanges works after listener cancellation", () async { + final firstSubscription = + ArcaneAuthenticationService.I.statusChanges.listen((_) {}); + await firstSubscription.cancel(); + + // Ensure a deterministic baseline before asserting the next stream event. + ArcaneAuthenticationService.I.setUnauthenticated(); + + final secondEvent = expectLater( + ArcaneAuthenticationService.I.statusChanges, + emits(AuthenticationStatus.authenticated), + ); + + ArcaneAuthenticationService.I.setAuthenticated(); + await secondEvent; + }); }); } diff --git a/test/services/authentication/authentication_service_test.mocks.dart b/test/services/authentication/authentication_service_test.mocks.dart deleted file mode 100644 index f225281..0000000 --- a/test/services/authentication/authentication_service_test.mocks.dart +++ /dev/null @@ -1,275 +0,0 @@ -// Mocks generated by Mockito 5.4.5 from annotations -// in arcane_framework/test/services/authentication/authentication_service_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; - -import 'package:arcane_framework/arcane_framework.dart' as _i2; -import 'package:flutter/foundation.dart' as _i4; -import 'package:flutter/widgets.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeResult_0 extends _i1.SmartFake implements _i2.Result { - _FakeResult_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeWidget_1 extends _i1.SmartFake implements _i3.Widget { - _FakeWidget_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); - - @override - String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => - super.toString(); -} - -class _FakeState_2 extends _i1.SmartFake - implements _i3.State { - _FakeState_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); - - @override - String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => - super.toString(); -} - -class _FakeStatefulElement_3 extends _i1.SmartFake - implements _i3.StatefulElement { - _FakeStatefulElement_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); - - @override - String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => - super.toString(); -} - -class _FakeDiagnosticsNode_4 extends _i1.SmartFake - implements _i3.DiagnosticsNode { - _FakeDiagnosticsNode_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); - - @override - String toString({ - _i4.TextTreeConfiguration? parentConfiguration, - _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info, - }) => super.toString(); -} - -/// A class which mocks [ArcaneAuthInterface]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockArcaneAuthInterface extends _i1.Mock - implements _i2.ArcaneAuthInterface { - MockArcaneAuthInterface() { - _i1.throwOnMissingStub(this); - } - - @override - _i5.Future get isSignedIn => - (super.noSuchMethod( - Invocation.getter(#isSignedIn), - returnValue: _i5.Future.value(false), - ) - as _i5.Future); - - @override - _i5.Future init() => - (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future<_i2.Result> logout() => - (super.noSuchMethod( - Invocation.method(#logout, []), - returnValue: _i5.Future<_i2.Result>.value( - _FakeResult_0(this, Invocation.method(#logout, [])), - ), - ) - as _i5.Future<_i2.Result>); - - @override - _i5.Future<_i2.Result> login({ - T? input, - _i5.Future Function()? onLoggedIn, - }) => - (super.noSuchMethod( - Invocation.method(#login, [], { - #input: input, - #onLoggedIn: onLoggedIn, - }), - returnValue: _i5.Future<_i2.Result>.value( - _FakeResult_0( - this, - Invocation.method(#login, [], { - #input: input, - #onLoggedIn: onLoggedIn, - }), - ), - ), - ) - as _i5.Future<_i2.Result>); -} - -/// A class which mocks [ArcaneEnvironmentProvider]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockArcaneEnvironmentProvider extends _i1.Mock - implements _i2.ArcaneEnvironmentProvider { - MockArcaneEnvironmentProvider() { - _i1.throwOnMissingStub(this); - } - - @override - _i3.Widget get child => - (super.noSuchMethod( - Invocation.getter(#child), - returnValue: _FakeWidget_1(this, Invocation.getter(#child)), - ) - as _i3.Widget); - - @override - _i2.Environment get environment => - (super.noSuchMethod( - Invocation.getter(#environment), - returnValue: _i2.Environment.debug, - ) - as _i2.Environment); - - @override - _i3.State<_i2.ArcaneEnvironmentProvider> createState() => - (super.noSuchMethod( - Invocation.method(#createState, []), - returnValue: _FakeState_2<_i2.ArcaneEnvironmentProvider>( - this, - Invocation.method(#createState, []), - ), - ) - as _i3.State<_i2.ArcaneEnvironmentProvider>); - - @override - _i3.StatefulElement createElement() => - (super.noSuchMethod( - Invocation.method(#createElement, []), - returnValue: _FakeStatefulElement_3( - this, - Invocation.method(#createElement, []), - ), - ) - as _i3.StatefulElement); - - @override - String toStringShort() => - (super.noSuchMethod( - Invocation.method(#toStringShort, []), - returnValue: _i6.dummyValue( - this, - Invocation.method(#toStringShort, []), - ), - ) - as String); - - @override - void debugFillProperties(_i4.DiagnosticPropertiesBuilder? properties) => - super.noSuchMethod( - Invocation.method(#debugFillProperties, [properties]), - returnValueForMissingStub: null, - ); - - @override - String toStringShallow({ - String? joiner = ', ', - _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, - }) => - (super.noSuchMethod( - Invocation.method(#toStringShallow, [], { - #joiner: joiner, - #minLevel: minLevel, - }), - returnValue: _i6.dummyValue( - this, - Invocation.method(#toStringShallow, [], { - #joiner: joiner, - #minLevel: minLevel, - }), - ), - ) - as String); - - @override - String toStringDeep({ - String? prefixLineOne = '', - String? prefixOtherLines, - _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, - int? wrapWidth = 65, - }) => - (super.noSuchMethod( - Invocation.method(#toStringDeep, [], { - #prefixLineOne: prefixLineOne, - #prefixOtherLines: prefixOtherLines, - #minLevel: minLevel, - #wrapWidth: wrapWidth, - }), - returnValue: _i6.dummyValue( - this, - Invocation.method(#toStringDeep, [], { - #prefixLineOne: prefixLineOne, - #prefixOtherLines: prefixOtherLines, - #minLevel: minLevel, - #wrapWidth: wrapWidth, - }), - ), - ) - as String); - - @override - _i3.DiagnosticsNode toDiagnosticsNode({ - String? name, - _i4.DiagnosticsTreeStyle? style, - }) => - (super.noSuchMethod( - Invocation.method(#toDiagnosticsNode, [], { - #name: name, - #style: style, - }), - returnValue: _FakeDiagnosticsNode_4( - this, - Invocation.method(#toDiagnosticsNode, [], { - #name: name, - #style: style, - }), - ), - ) - as _i3.DiagnosticsNode); - - @override - List<_i3.DiagnosticsNode> debugDescribeChildren() => - (super.noSuchMethod( - Invocation.method(#debugDescribeChildren, []), - returnValue: <_i3.DiagnosticsNode>[], - ) - as List<_i3.DiagnosticsNode>); - - @override - String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => - super.toString(); -} diff --git a/test/services/feature_flags/feature_flags_service_test.dart b/test/services/feature_flags/feature_flags_service_test.dart index ae0b268..88d60ba 100644 --- a/test/services/feature_flags/feature_flags_service_test.dart +++ b/test/services/feature_flags/feature_flags_service_test.dart @@ -7,6 +7,7 @@ void main() { setUp(() { featureFlags = ArcaneFeatureFlags.I; + Arcane.features.reset(); }); test("singleton instance is consistent", () { @@ -14,9 +15,6 @@ void main() { }); group("feature management", () { - setUp(() { - Arcane.features.reset(); - }); test("enableFeature adds feature to enabled list", () { featureFlags.enableFeature(MockFeature.test); expect(featureFlags.enabledFeatures, contains(MockFeature.test)); @@ -47,7 +45,7 @@ void main() { group("notifications", () { test("enableFeature notifies listeners", () { var notified = false; - featureFlags.addListener(() => notified = true); + featureFlags.notifier.addListener(() => notified = true); featureFlags.enableFeature(MockFeature.test); expect(notified, true); }); @@ -55,10 +53,52 @@ void main() { test("disableFeature notifies listeners", () { featureFlags.enableFeature(MockFeature.test); var notified = false; - featureFlags.addListener(() => notified = true); + featureFlags.notifier.addListener(() => notified = true); featureFlags.disableFeature(MockFeature.test); expect(notified, true); }); + + test("enabledFeaturesChanges emits updates", () async { + List? emitted; + final subscription = + featureFlags.enabledFeaturesChanges.listen((features) { + emitted = features; + }); + + featureFlags.enableFeature(MockFeature.test); + await Future.delayed(Duration.zero); + + expect(emitted, contains(MockFeature.test)); + + await subscription.cancel(); + }); + + test("enabledFeaturesChanges works after listener cancellation", + () async { + List? firstEmission; + final firstSubscription = + featureFlags.enabledFeaturesChanges.listen((features) { + firstEmission = features; + }); + + featureFlags.enableFeature(MockFeature.test); + await Future.delayed(Duration.zero); + expect(firstEmission, contains(MockFeature.test)); + + await firstSubscription.cancel(); + + List? secondEmission; + final secondSubscription = + featureFlags.enabledFeaturesChanges.listen((features) { + secondEmission = features; + }); + + featureFlags.disableFeature(MockFeature.test); + await Future.delayed(Duration.zero); + expect(secondEmission, isNot(contains(MockFeature.test))); + + await secondSubscription.cancel(); + }); }); }); } diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart index d1539ac..48361ae 100644 --- a/test/services/logging/logging_service_test.dart +++ b/test/services/logging/logging_service_test.dart @@ -96,6 +96,32 @@ void main() { }); group("ArcaneLogger", () { + group("stream lifecycle", () { + test("logStream remains usable after listener cancellation", () async { + String? firstMessage; + final firstSubscription = Arcane.logger.logStream.listen((message) { + firstMessage = message; + }); + + Arcane.log("first"); + await Future.delayed(Duration.zero); + expect(firstMessage, contains("first")); + + await firstSubscription.cancel(); + + String? secondMessage; + final secondSubscription = Arcane.logger.logStream.listen((message) { + secondMessage = message; + }); + + Arcane.log("second"); + await Future.delayed(Duration.zero); + expect(secondMessage, contains("second")); + + await secondSubscription.cancel(); + }); + }); + group("interface management", () { test("registerInterfaces adds interfaces correctly", () async { await Arcane.logger.registerInterface(myInterface); diff --git a/test/services/logging/logging_service_test.mocks.dart b/test/services/logging/logging_service_test.mocks.dart deleted file mode 100644 index 4e52291..0000000 --- a/test/services/logging/logging_service_test.mocks.dart +++ /dev/null @@ -1,69 +0,0 @@ -// Mocks generated by Mockito 5.4.5 from annotations -// in arcane_framework/test/services/logging/logging_service_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; - -import 'package:arcane_framework/src/services/logging/logging_service.dart' - as _i2; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -/// A class which mocks [LoggingInterface]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockLoggingInterface extends _i1.Mock implements _i2.LoggingInterface { - @override - bool get initialized => - (super.noSuchMethod( - Invocation.getter(#initialized), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); - - @override - _i3.Future<_i2.LoggingInterface?> init() => - (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i3.Future<_i2.LoggingInterface?>.value(), - returnValueForMissingStub: - _i3.Future<_i2.LoggingInterface?>.value(), - ) - as _i3.Future<_i2.LoggingInterface?>); - - @override - void log( - String? message, { - Map? metadata, - _i2.Level? level, - StackTrace? stackTrace, - Object? extra, - }) => super.noSuchMethod( - Invocation.method( - #log, - [message], - { - #metadata: metadata, - #level: level, - #stackTrace: stackTrace, - #extra: extra, - }, - ), - returnValueForMissingStub: null, - ); -} diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart index 68c6630..28f57ed 100644 --- a/test/services/reactive_theme/reactive_theme_service_test.dart +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -8,6 +8,7 @@ void main() { setUp(() { theme = ArcaneReactiveTheme.I; + Arcane.theme.reset(); }); test("singleton instance is consistent", () { @@ -27,11 +28,41 @@ void main() { expect(theme.currentThemeMode, equals(ThemeMode.light)); }); - test("switching theme notifies listeners", () { - var notified = false; - theme.addListener(() => notified = true); + test("switching theme notifies theme mode stream", () async { + ThemeMode? emittedMode; + final subscription = theme.themeModeChanges.listen((mode) { + emittedMode = mode; + }); + theme.switchTheme(); - expect(notified, true); + await Future.delayed(Duration.zero); + + expect(emittedMode, equals(theme.currentThemeMode)); + await subscription.cancel(); + }); + + test("theme mode stream works after listener cancellation", () async { + ThemeMode? firstEmission; + final firstSubscription = theme.themeModeChanges.listen((mode) { + firstEmission = mode; + }); + + theme.switchTheme(); + await Future.delayed(Duration.zero); + expect(firstEmission, ThemeMode.dark); + + await firstSubscription.cancel(); + + ThemeMode? secondEmission; + final secondSubscription = theme.themeModeChanges.listen((mode) { + secondEmission = mode; + }); + + theme.switchTheme(); + await Future.delayed(Duration.zero); + expect(secondEmission, ThemeMode.light); + + await secondSubscription.cancel(); }); }); @@ -52,10 +83,19 @@ void main() { expect(theme.light.primaryColor, equals(Colors.orange)); }); - test("theme updates notify listeners", () { + test("theme updates notify notifier and streams", () async { bool darkNotified = false; bool lightNotified = false; - ThemeMode currentTheme = ThemeMode.system; + ThemeMode? emittedMode; + ThemeData? emittedThemeData; + + final modeSubscription = theme.themeModeChanges.listen((mode) { + emittedMode = mode; + }); + + final dataSubscription = theme.themeDataChanges.listen((themeData) { + emittedThemeData = themeData; + }); theme.darkTheme.addListener(() { darkNotified = true; @@ -65,23 +105,33 @@ void main() { lightNotified = true; }); - theme.addListener(() { - currentTheme = theme.currentThemeMode; - }); - - expect(currentTheme, ThemeMode.system); + final darkTheme = ThemeData.dark().copyWith( + primaryColor: Colors.teal, + ); + final lightTheme = ThemeData.light().copyWith( + primaryColor: Colors.amber, + ); - theme.setDarkTheme(ThemeData.dark()); - theme.setLightTheme(ThemeData.light()); + theme.setDarkTheme(darkTheme); + theme.setLightTheme(lightTheme); + await Future.delayed(Duration.zero); expect(darkNotified, true); expect(lightNotified, true); + expect(emittedThemeData, isNotNull); theme.switchTheme(); - expect(currentTheme, ThemeMode.light); + await Future.delayed(Duration.zero); + expect(theme.currentThemeMode, ThemeMode.dark); + expect(emittedMode, ThemeMode.dark); theme.switchTheme(); - expect(currentTheme, ThemeMode.dark); + await Future.delayed(Duration.zero); + expect(theme.currentThemeMode, ThemeMode.light); + expect(emittedMode, ThemeMode.light); + + await modeSubscription.cancel(); + await dataSubscription.cancel(); }); }); From 83cd75c9e4569aeab5cfef1dcc49bf972b094cde Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 21 May 2026 18:47:56 +0200 Subject: [PATCH 34/58] Refactor ArcaneEnvironment and AuthenticationService for improved environment management and status handling Signed-off-by: Hans Kokx --- lib/src/providers/environment_provider.dart | 24 +++---- .../authentication/authentication_enums.dart | 53 ++++++++++----- .../authentication_service.dart | 40 +++--------- .../authentication_service_test.dart | 65 +++++++++++++++++++ 4 files changed, 121 insertions(+), 61 deletions(-) diff --git a/lib/src/providers/environment_provider.dart b/lib/src/providers/environment_provider.dart index 7a62d46..2622d25 100644 --- a/lib/src/providers/environment_provider.dart +++ b/lib/src/providers/environment_provider.dart @@ -3,8 +3,8 @@ import "package:flutter/widgets.dart"; /// An `InheritedWidget` that provides access to the application environment. /// -/// The `ArcaneEnvironment` widget holds the current environment (`debug` or `normal`) -/// and allows descendant widgets to access it. +/// The `ArcaneEnvironment` widget holds the current environment and allows +/// descendant widgets to access and mutate it. class ArcaneEnvironment extends InheritedWidget { /// The current application environment. final Environment environment; @@ -42,6 +42,9 @@ class ArcaneEnvironment extends InheritedWidget { return environment != oldWidget.environment; } + void setEnvironment(Environment environment) => + _switchEnvironment(environment); + void enableDebugMode() => _switchEnvironment(Environment.debug); void disableDebugMode() => _switchEnvironment(Environment.normal); } @@ -81,16 +84,19 @@ class _ArcaneEnvironmentProviderState extends State { /// Enables debug mode by setting the environment to `Environment.debug`. void enableDebugMode() { if (_environment == Environment.debug) return; - setState(() { - _environment = Environment.debug; - }); + setEnvironment(Environment.debug); } /// Disables debug mode by setting the environment to `Environment.normal`. void disableDebugMode() { if (_environment == Environment.normal) return; + setEnvironment(Environment.normal); + } + + void setEnvironment(Environment environment) { + if (_environment == environment) return; setState(() { - _environment = Environment.normal; + _environment = environment; }); } @@ -98,11 +104,7 @@ class _ArcaneEnvironmentProviderState extends State { Widget build(BuildContext context) { return ArcaneEnvironment( environment: _environment, - switchEnvironment: (Environment environment) { - setState(() { - _environment = environment; - }); - }, + switchEnvironment: setEnvironment, child: widget.child, ); } diff --git a/lib/src/services/authentication/authentication_enums.dart b/lib/src/services/authentication/authentication_enums.dart index bca4e31..a4bc64e 100644 --- a/lib/src/services/authentication/authentication_enums.dart +++ b/lib/src/services/authentication/authentication_enums.dart @@ -24,10 +24,9 @@ enum SignUpStep { /// An enum representing the authentication status of a user. /// -/// This enum has three possible states: +/// This enum has two possible states: /// - `authenticated`: The user is authenticated. /// - `unauthenticated`: The user is not authenticated. -/// - `debug`: The application is in debug mode for testing. /// /// Example: /// ```dart @@ -41,13 +40,7 @@ enum AuthenticationStatus { authenticated, /// The user is not authenticated. - unauthenticated, - - /// The application is in debug mode, typically for testing or development purposes. - debug; - - /// Returns `true` if the current status is `debug`. - bool get isDebug => this == debug; + unauthenticated; /// Returns `true` if the current status is `authenticated`. bool get isAuthenticated => this == authenticated; @@ -56,15 +49,39 @@ enum AuthenticationStatus { bool get isUnauthenticated => this == unauthenticated; } -/// An enum representing the different application environments. +/// A value object representing the current application environment. /// -/// This enum has two possible values: -/// - `debug`: The application is in debug mode, typically for development and testing. -/// - `normal`: The application is running in a normal mode, for production or standard use. -enum Environment { - /// The debug environment for development and testing purposes. - debug, +/// Built-in values are available through [Environment.debug] and +/// [Environment.normal], but custom values can be created for app-specific +/// environments such as `staging`. +class Environment { + /// Creates an environment with a human-readable [name]. + const Environment(this.name); + + /// Built-in debug environment for development and testing purposes. + static const Environment debug = Environment("debug"); + + /// Built-in normal environment for production use. + static const Environment normal = Environment("normal"); + + /// Human-readable environment name. + final String name; + + /// Returns `true` when this environment is the built-in debug environment. + bool get isDebug => this == debug; + + /// Returns `true` when this environment is the built-in normal environment. + bool get isNormal => this == normal; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Environment && other.name == name; + } + + @override + int get hashCode => name.hashCode; - /// The normal environment for production use. - normal, + @override + String toString() => "Environment($name)"; } diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index 6cdca49..6ed6316 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -41,7 +41,6 @@ class ArcaneAuthenticationService extends ArcaneService { /// Available values: /// - `authenticated`: The user has successfully authenticated and is logged in. /// - `unauthenticated`: The user has not yet logged in. - /// - `debug`: Debug mode has been enabled, enabling development features. AuthenticationStatus get status => _notifier.value; static ArcaneAuthInterface? _authInterface; @@ -50,8 +49,8 @@ class ArcaneAuthenticationService extends ArcaneService { /// been registered. ArcaneAuthInterface? get authInterface => _authInterface; - /// A shortcut to `status != AuthenticationStatus.unauthenticated`. - bool get isAuthenticated => status != AuthenticationStatus.unauthenticated; + /// Returns `true` when the current status is authenticated. + bool get isAuthenticated => status == AuthenticationStatus.authenticated; final ValueNotifier _isSignedIn = ValueNotifier(false); @@ -79,8 +78,6 @@ class ArcaneAuthenticationService extends ArcaneService { Future get refreshToken async => await authInterface?.refreshToken ?? Future.value(""); - AuthenticationStatus? _previousModeWhenSettingDebug; - /// Removes any registered `ArcaneAuthInterface` and resets all values to /// default. Future reset() async { @@ -89,7 +86,6 @@ class ArcaneAuthenticationService extends ArcaneService { _isSignedIn.value = isAuthenticated; _statusController.add(_notifier.value); _signedInController.add(_isSignedIn.value); - _previousModeWhenSettingDebug = null; } /// Registers an `ArcaneAuthInterface` within the `ArcaneAuthenticationService`. @@ -102,9 +98,9 @@ class ArcaneAuthenticationService extends ArcaneService { await authInterface.init(); } - /// Sets `status` to `AuthenticationStatus.debug`. If `onDebugModeSet` has - /// been specified, the method will be triggered after the new status has been - /// set. + /// Enables the debug environment. + /// + /// This method does not mutate authentication status. Future setDebug( BuildContext context, { Future Function()? onDebugModeSet, @@ -116,26 +112,17 @@ class ArcaneAuthenticationService extends ArcaneService { if (previousEnvironment == Environment.debug) return; - _previousModeWhenSettingDebug = status; - arcaneEnvironment.enableDebugMode(); - final Environment currentEnvironment = arcaneEnvironment.environment; - - if (previousEnvironment == currentEnvironment) { - throw Exception("Unable to switch to debug mode."); - } - - _setStatus(AuthenticationStatus.debug); if (onDebugModeSet != null) await onDebugModeSet(); } catch (e) { rethrow; } } - /// Sets `status` to `AuthenticationStatus.normal`. If `onDebugModeUnset` has - /// been specified, the method will be triggered after the new status has been - /// set. + /// Enables the normal environment. + /// + /// This method does not mutate authentication status. Future setNormal( BuildContext context, { Future Function()? onDebugModeUnset, @@ -149,15 +136,6 @@ class ArcaneAuthenticationService extends ArcaneService { arcaneEnvironment.disableDebugMode(); - final Environment currentEnvironment = arcaneEnvironment.environment; - - if (previousEnvironment == currentEnvironment) { - throw Exception("Unable to switch to normal mode."); - } - - _setStatus( - _previousModeWhenSettingDebug ?? AuthenticationStatus.unauthenticated, - ); if (onDebugModeUnset != null) await onDebugModeUnset(); } catch (_) { throw Exception("No ArcaneEnvironment found in BuildContext"); @@ -211,8 +189,6 @@ class ArcaneAuthenticationService extends ArcaneService { setUnauthenticated(); } - _previousModeWhenSettingDebug = null; - return loggedOut; } diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index a74086e..a292fd1 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -102,6 +102,7 @@ void main() { await tester.pump(); ArcaneEnvironment.of(capturedContext).enableDebugMode(); await tester.pump(); + expect( ArcaneEnvironment.of(capturedContext).environment, equals(Environment.debug), @@ -142,6 +143,70 @@ void main() { ); }); + testWidgets( + "setDebug and setNormal do not mutate authentication status", + (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + await ArcaneAuthenticationService.I.login( + input: {"username": "test"}, + ); + + expect(ArcaneAuthenticationService.I.isAuthenticated, true); + + await ArcaneAuthenticationService.I.setDebug(capturedContext); + expect( + ArcaneAuthenticationService.I.status, + AuthenticationStatus.authenticated, + ); + + await ArcaneAuthenticationService.I.setNormal(capturedContext); + expect( + ArcaneAuthenticationService.I.status, + AuthenticationStatus.authenticated, + ); + }, + ); + + testWidgets("supports custom environment values", + (WidgetTester tester) async { + late BuildContext capturedContext; + + const Environment staging = Environment("staging"); + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + ArcaneEnvironment.of(capturedContext).setEnvironment(staging); + await tester.pump(); + + expect(ArcaneEnvironment.of(capturedContext).environment, staging); + expect(ArcaneEnvironment.of(capturedContext).environment.name, "staging"); + }); + test("statusChanges emits authentication updates", () async { final statusEvent = expectLater( ArcaneAuthenticationService.I.statusChanges, From 510b18a1c680d9296180ac6d20521d74fc1a64b8 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Thu, 21 May 2026 19:19:08 +0200 Subject: [PATCH 35/58] Add application environment management and tests for authentication status coherence Signed-off-by: Hans Kokx --- README.md | 32 ++++++++++++ example/lib/main.dart | 50 ++++++++++--------- .../authentication_service_test.dart | 25 ++++++++++ 3 files changed, 83 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 25a59d4..a5ab4d3 100644 --- a/README.md +++ b/README.md @@ -738,6 +738,38 @@ void dispose() { } ``` +### Application Environments + +Arcane environments are value-based and extensible. Two built-in values are +provided (`Environment.normal` and `Environment.debug`), and applications can define +their own environments (for example, `staging`). + +```dart +const Environment staging = Environment("staging"); + +class EnvironmentSwitcher extends StatelessWidget { + const EnvironmentSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); + + return ElevatedButton( + onPressed: () { + arcaneEnvironment.setEnvironment(staging); + }, + child: const Text("Use staging"), + ); + } +} +``` + +`enableDebugMode()` and `disableDebugMode()` are still available convenience +helpers that map to the built-in debug and normal environments. + +Authentication status is intentionally separate from environment. Switching +environments does not change `AuthenticationStatus`. + ### Dynamic Theming The Arcane Framework provides a simple interface for managing themes in your diff --git a/example/lib/main.dart b/example/lib/main.dart index b6a1180..c13a328 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -505,10 +505,18 @@ class ArcaneFeatureFlagsExample extends StatelessWidget { // when you may want to change the behavior of the application under // certain conditions. class ArcaneEnvironmentExample extends StatelessWidget { + static const Environment stagingEnvironment = Environment("staging"); + const ArcaneEnvironmentExample({ super.key, }); + Environment _nextEnvironment(Environment current) { + if (current == Environment.normal) return Environment.debug; + if (current == Environment.debug) return stagingEnvironment; + return Environment.normal; + } + @override Widget build(BuildContext context) { return Card( @@ -524,31 +532,25 @@ class ArcaneEnvironmentExample extends StatelessWidget { ), ElevatedButton( onPressed: () { - final Environment currentEnvironment = - ArcaneEnvironment.of(context).environment; - if (currentEnvironment == Environment.normal) { - ArcaneEnvironment.of(context).enableDebugMode(); - Arcane.log( - "Environment changed.", - metadata: { - "previous": - ArcaneEnvironment.of(context).environment.name, - "current": Environment.debug.name, - }, - ); - } else { - ArcaneEnvironment.of(context).disableDebugMode(); - Arcane.log( - "Environment changed.", - metadata: { - "previous": - ArcaneEnvironment.of(context).environment.name, - "current": Environment.normal.name, - }, - ); - } + final ArcaneEnvironment environment = ArcaneEnvironment.of( + context, + ); + final Environment previousEnvironment = environment.environment; + final Environment nextEnvironment = _nextEnvironment( + previousEnvironment, + ); + + environment.setEnvironment(nextEnvironment); + + Arcane.log( + "Environment changed.", + metadata: { + "previous": previousEnvironment.name, + "current": nextEnvironment.name, + }, + ); }, - child: const Text("Switch environment"), + child: const Text("Cycle environment"), ), Text( "Environment: ${ArcaneEnvironment.of(context).environment.name}", diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index a292fd1..9977632 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -243,5 +243,30 @@ void main() { ArcaneAuthenticationService.I.setAuthenticated(); await secondEvent; }); + + test("statusChanges and signedInChanges stay coherent", () async { + ArcaneAuthenticationService.I.setUnauthenticated(); + + final statusEvents = expectLater( + ArcaneAuthenticationService.I.statusChanges, + emitsInOrder( + [ + AuthenticationStatus.authenticated, + AuthenticationStatus.unauthenticated, + ], + ), + ); + + final signedInEvents = expectLater( + ArcaneAuthenticationService.I.signedInChanges, + emitsInOrder([true, false]), + ); + + ArcaneAuthenticationService.I.setAuthenticated(); + ArcaneAuthenticationService.I.setUnauthenticated(); + + await statusEvents; + await signedInEvents; + }); }); } From 15f76d159bc6620ef5c2f784d49bbfe4d6d42cf3 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 15:43:54 +0200 Subject: [PATCH 36/58] feat: Enhance service management with ArcaneApp provider composition This commit significantly refactors Arcane's service architecture. ArcaneApp now acts as a StatefulWidget, composing InheritedWidget- based providers for core services (features, environment, theme, auth). Service lookups (e.g., Arcane.features, context.service()) now prioritize provider-registered instances, falling back to singletons. Key changes include: - Renamed ArcaneFeatureFlags to ArcaneFeatureFlagService and ArcaneReactiveTheme to ArcaneThemeService (with typedefs). - Introduced ArcaneEnvironmentService and ArcaneEnvironmentProvider for centralized environment management. - Added BuildContext extensions (e.g., context.featureFlags) for reactive, convenient service access. - Removed .puro.json and its references, streamlining SDK setup. - Updated CHANGELOG.md to reflect a 2.0.0 release, detailing all breaking changes and new features. --- .gitignore | 4 +- .puro.json | 3 - .vscode/settings.json | 4 - CHANGELOG.md | 176 +++++++----------- README.md | 62 ++++-- example/README.md | 6 + example/lib/main.dart | 93 +++++---- lib/arcane_framework.dart | 18 +- lib/src/arcane.dart | 75 +++++--- lib/src/arcane_app.dart | 73 ++++++-- .../service/arcane_service.dart | 0 .../service/service_provider.dart | 7 +- .../service/service_provider_extensions.dart | 12 +- .../authentication/authentication_enums.dart | 37 ---- .../authentication_service.dart | 34 ++-- .../environment/environment_interface.dart | 36 ++++ .../environment}/environment_provider.dart | 35 +++- .../environment/environment_service.dart | 65 +++++++ .../feature_flags_context_extensions.dart | 38 ++++ .../feature_flags_extensions.dart | 6 +- .../feature_flags/feature_flags_provider.dart | 140 ++++++++++++++ .../feature_flags/feature_flags_service.dart | 52 ++++-- .../arcane_theme.dart | 2 +- .../theme_extensions.dart} | 13 +- .../theme_service.dart} | 84 ++++++--- .../theme_switcher.dart} | 64 +++---- test/arcane_test.dart | 6 +- test/providers/service_provider_test.dart | 60 +++++- .../authentication_service_test.dart | 25 +++ .../feature_flags_provider_test.dart | 141 ++++++++++++++ .../feature_flags_service_test.dart | 8 +- 31 files changed, 982 insertions(+), 397 deletions(-) delete mode 100644 .puro.json rename lib/src/{providers => }/service/arcane_service.dart (100%) rename lib/src/{providers => }/service/service_provider.dart (92%) rename lib/src/{providers => }/service/service_provider_extensions.dart (80%) create mode 100644 lib/src/services/environment/environment_interface.dart rename lib/src/{providers => services/environment}/environment_provider.dart (80%) create mode 100644 lib/src/services/environment/environment_service.dart create mode 100644 lib/src/services/feature_flags/feature_flags_context_extensions.dart create mode 100644 lib/src/services/feature_flags/feature_flags_provider.dart rename lib/src/services/{reactive_theme => theme}/arcane_theme.dart (100%) rename lib/src/services/{reactive_theme/reactive_theme_extensions.dart => theme/theme_extensions.dart} (64%) rename lib/src/services/{reactive_theme/reactive_theme_service.dart => theme/theme_service.dart} (69%) rename lib/src/services/{reactive_theme/reactive_theme_switcher.dart => theme/theme_switcher.dart} (52%) create mode 100644 test/services/feature_flags/feature_flags_provider_test.dart diff --git a/.gitignore b/.gitignore index 19fdd42..44ea6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ migrate_working_dir/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. @@ -51,5 +51,3 @@ app.*.map.json **/android/app/profile **/android/app/release -# Puro -!/.puro.json diff --git a/.puro.json b/.puro.json deleted file mode 100644 index 79958db..0000000 --- a/.puro.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "env": "stable" -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f3918d9..e69de29 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +0,0 @@ -{ - "dart.flutterSdkPath": "/Users/hans/.puro/envs/stable/flutter", - "dart.sdkPath": "/Users/hans/.puro/envs/stable/flutter/bin/cache/dart-sdk" -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a3dda..f5c5f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,43 +1,71 @@ ## Unreleased -### Authentication Service (ArcaneAuth) Updates +### Arcane Framework + +- [NEW] `ArcaneApp` now owns and publishes a live service registry for + provider-aware static lookups. +- [CHANGE] `Arcane.features`, `Arcane.auth`, `Arcane.theme`, and + `Arcane.environment` now prefer the live `ArcaneApp` registry instance when + available, then fall back to built-in singletons. + +### Environment Service + +- [NEW] Added `ArcaneEnvironmentService` as a singleton `ArcaneService` instance. +- [CHANGE] Changed `ArcaneEnvironment` is no longer a `Cubit` and is now an + `InheritedWidget`. +- [NEW] Added `Arcane.environment` shortcut for direct environment access. +- [NEW] Added environment service to `Arcane.services` built-in list. +- [CHANGE] `ArcaneEnvironmentProvider` is now a `StatefulWidget`. +- [NEW] `ArcaneEnvironmentProvider` now provides methods for + `enableDebugMode()`, `disableDebugMode()` and `setEnvironment()`. + +### Authentication Service - [NEW] Added `statusChanges` stream to observe `AuthenticationStatus` updates. - [NEW] Added `signedInChanges` stream to observe sign-in state changes. - [FIX] Added stream lifecycle cleanup in `dispose` with safe lazy recreation. -### Feature Flags Service (ArcaneFeatureFlags) Updates +### Feature Flag Service +- [CHANGE] Renamed service class `ArcaneFeatureFlags` to + `ArcaneFeatureFlagService`. +- [NEW] Added backward compatibility typedef: + `typedef ArcaneFeatureFlags = ArcaneFeatureFlagService`. - [NEW] Added `enabledFeaturesChanges` stream to observe enabled feature updates in realtime. - [FIX] Added stream lifecycle cleanup in `dispose` with safe lazy recreation. - -### Logging Service (ArcaneLogger) Updates - -- [FIX] `logStream` no longer closes when an individual listener cancels - (prevents stale stream state during widget lifecycle changes and hot reload - flows). -- [NEW] Added explicit `dispose` cleanup for logger stream resources. - -### Theme (ArcaneTheme) Updates - +- [NEW] Added `ArcaneFeatureFlagProvider` (`InheritedWidget`) and + `ArcaneFeatureFlagsProvider` (`StatefulWidget`) for first-class feature-flag + integration in the widget tree. +- [NEW] Added `BuildContext` convenience accessors for feature flags, including + `context.featureFlags`, `context.maybeFeatureFlags`, + `context.isFeatureEnabled(...)`, and `context.isFeatureDisabled(...)`. +- [NEW] `ArcaneApp` now includes `ArcaneFeatureFlagsProvider` by default, + enabling rebuilds for widgets that depend on + `ArcaneFeatureFlagProvider.of(context)`. +- [UPDATE] README now documents `ArcaneFeatureFlagProvider` and `ArcaneApp` + provider composition. +- [UPDATE] Example app now demonstrates feature toggling via + `ArcaneFeatureFlagProvider` to highlight scope-based rebuilds. + +### Theme Service + +- [CHANGE] Renamed `ArcaneReactiveTheme` to `ArcaneThemeService` for clearer + naming. +- [NEW] Added backward compatibility typedef: + `typedef ArcaneReactiveTheme = ArcaneThemeService`. +- [FIX] Theme initialization now respects `ThemeMode.system` and initializes + `ThemeData` using the effective brightness. +- [FIX] `ArcaneThemeSwitcher` now initializes theme state once via + `setInitialTheme(context)`. - [FIX] Reactive theme stream controllers now close only during service dispose, preventing stream shutdown when a single subscriber cancels. +- [UPDATE] README now documents `ArcaneThemeService` naming. -### Test Coverage Updates - -- [NEW] Added regression tests for stream listener cancellation and - re-subscription in logging, theme, feature flags, and authentication. - -### Documentation and Example Updates - -- [UPDATE] README now documents stream APIs for authentication, feature flags, - logging, and reactive theme, including subscription lifecycle patterns. -- [UPDATE] Example README now includes run instructions and references for - stream lifecycle usage in the demo app. - -### Logging Service (Upcoming Contract Changes) +### Arcane Logger +- [NEW] Added `logStream` for realtime log subscriptions. +- [NEW] Added explicit `dispose` cleanup for logger stream resources. - [BREAKING] `LoggingInterface` no longer includes built-in singleton-style initialization state. - [NEW] Added optional lifecycle capability via `LoggingInitializable` and @@ -46,10 +74,17 @@ - [CHANGE] `initializeInterfaces()` now initializes only interfaces that implement `LoggingInitializable`; other interfaces are skipped. -#### Migration (LoggingInterface Contract) +#### Migration Steps (LoggingInterface) -- For simple loggers (debug console style), remove `initialized`/`init` - boilerplate. +1. Remove `initialized` and `init` from interfaces that do not require startup + work. +2. If an interface requires startup/lifecycle management, add + `LoggingInitializationMixin` (or implement `LoggingInitializable`) and move + setup logic into `init()`. +3. Update `log(...)` implementations to guard behavior with `initialized` only + for interfaces that opted into initialization. +4. Run tests to verify interface registration and logging behavior still match + expectations. Before: @@ -96,91 +131,6 @@ class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { - If desired, adopt `feature` for destination-aware filtering in interceptors. -## 2.0.0 - -### Arcane - -- [FIX] The `Arcane` class is now `abstract` - -### ArcaneEnvironment - -- [CHANGE] The dependency on `flutter_bloc` has been removed. -- [CHANGE] The feature has been completely rewritten as an inherited widget, rather than using a `Cubit`. -- [NEW] The `ArcaneEnvironment` widget now includes the `maybeOf(context)` and `of(context)` service locators. -- [NEW] An `ArcaneEnvironmentProvider` widget has been added. This is used by `ArcaneApp` but can also be used independently when not using the `ArcaneApp` widget. -- [BREAKING] The locator for `ArcaneEnvironment` has been changed from `context.read()` to `ArcaneEnvironment.of(context)` -- [BREAKING] Reading the current environment has been changed from `context.read().state` to `ArcaneEnvironment.of(context).environment`; - -### ArcaneServiceProvider - -- [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 `removeService` 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] Added `ArcaneService.ofType(context)` and `ArcaneService.requiredOfType(context)` locators, returning a nullable and non-nullable instance of a given service, respectively. -- [BREAKING] Renamed `serviceInstances` to `registeredServices`. - -### Authentication Service (ArcaneAuth) - -- [FIX] Switching between `Environment.normal` and `Environment.debug` now correctly notifies subscribers -- [BREAKING] Switching between environments now remembers the previous authentication status (e.g., switching to debug mode and then back to normal mode will now remember whether you were authenticated or unauthenticated in normal mode when you switched to debug mode.) - -### Feature Flags Service (ArcaneFeatureFlags) - -- [NEW] A `reset` method has been added, which will remove all enabled features and de-initialize the service. - -### Logging Service (ArcaneLogger) - -- [NEW] A `logStream` has been added. This will stream all log messages that are sent to `ArcaneLogger`. These messages are not processed by any registered `LoggingInterface`. -- [BREAKING] Invoking the `log` method no longer throws an exception if `ArcaneLogger` has not been initialized. Log messages will always be sent to the `logStream` and will only be sent to the registered `LoggingInterface`s if the `init` method has ben invoked. -- [FIX] Automatic file and line number detection has been improved, both in terms of performance and in reliability. -- [NEW] In addition to the existing `registerInterfaces` method, a new `registerInterface` method has been added. -- [NEW] The following methods have been added: `unregisterInterface`, `unregisterInterfaces`, and `unregisterAllInterfaces`. -- [NEW] Added a `reset` method that clears all registered interfaces, clears all persistent metadata, and de-initializes `ArcaneLogger` -- [BREAKING] Added a `skipAutodetection` option (defaults to `false`) when invoking the `log` method. When set to `true`, automatic file and line number detection, as well as automatic module and method detection will not be performed (the module and method can still be added as properties). Skipping autodetection may help to increase performance, as a `StackTrace` is no longer generated and parsed. This property will need to be added to existing `LoggingInterface` implementations. - -### Theme (ArcaneTheme) - -- [NEW] Added `themeMode` extension to `BuildContext` to get the current `ThemeMode` (e.g., light/dark) -- [BREAKING] Completely rewrote `ArcaneReactiveTheme` -- [NEW] Added the `ArcaneThemeSwitcher` widget - -#### ArcaneReactiveTheme - -- [NEW] The `isFollowingSystemTheme` getter has been added. -- [NEW] The `themeModeChanges` getter will stream events when the `ThemeMode` changes (e.g., light/dark) -- [NEW] The `themeDataChanges` getter will stream events when the current `ThemeData` changes -- [NEW] The `systemThemeMode` getter will return the OS-level brightness (e.g., light/dark) -- [BREAKING] The `currentMode` getter was renamed to `currentThemeMode` -- [NEW] The `currentTheme` getter was added to retrieve the current `ThemeData`. -- [BREAKING] The `systemTheme` getter was replaced by the `systemThemeMode` getter -- [NEW] A `currentModeOf(context)` getter was added. Using this value will trigger a rebuild when the mode changes. -- [CHANGE] The `switchTheme` method now (optionally) takes in a `ThemeMode` parameter. If it is omitted, the new mode will be automatically determined. -- [FIX] The `followSystemTheme` method will now correctly trigger widget rebuilds under the correct circumstances. -- [FIX] Invoking the `setDarkTheme` and `setLightTheme` methods will trigger widget rebuilds under the correct circumstances. -- [BREAKING] In order to enable following the system brightness changes, the `Arcane.theme.followSystemTheme(context)`/`ArcaneReactiveTheme.I.followSystemTheme(context)` method will need to be invoked once. -- [NEW] When manually switching from following the system theme to a specific theme (e.g., `switchTheme()`), the system theme will no longer be followed. To follow the system theme once again, the `followSystemTheme(context)` method should be invoked. - -#### ArcaneThemeSwitcher - -- [NEW] This new widget will, when added to the widget tree, trigger rebuilds when the theme mode or theme style is updated via `ArcaneTheme`/`ArcaneReactiveTheme`. -- [NEW] This widget has been added to `ArcaneApp`. - -### Testing - -- [NEW] Tests have been written for much of the framework. - -### Example - -- [FIX] The example has been completely reworked. It now includes examples of all features that Arcane has to offer. - -### Misc - -- [FIX] Dartdoc comments have been added throughout the framework where they were previously missing. - ## 1.2.5 - Improved automatic metadata detection in `ArcaneLogger` diff --git a/README.md b/README.md index a5ab4d3..f56a2b1 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,14 @@ and service management. - **Service Management**: Centralized access to multiple services (logging, authentication, theming, etc.). - **Feature Flags**: Dynamically enable or disable features using - `ArcaneFeatureFlags`. + `ArcaneFeatureFlagService`. - **Logging**: Easily log messages with metadata, stack traces, and different log levels via `ArcaneLogger`. - **Authentication**: Built-in support for handling user authentication workflows. - **Dynamic Theming**: Switch between light and dark themes with - `ArcaneReactiveTheme`. + `ArcaneThemeService` (with `ArcaneReactiveTheme` kept as a deprecated + compatibility typedef). - **Realtime Streams**: In addition to `ValueNotifier`s, core services expose broadcast streams for reactive consumers. @@ -51,8 +52,9 @@ To use Arcane Framework in your Dart or Flutter project, follow these steps: flutter pub add arcane_framework ``` - 2. (optional) Wrap your `MaterialApp` or `CupertinoApp` with the `ArcaneApp` - Widget: + 2. (optional) Wrap your `MaterialApp` or `CupertinoApp` with `ArcaneApp`. + `ArcaneApp` wires up Arcane's built-in app-level providers for services, + feature flags, environment, and theme updates: ```dart import 'package:arcane_framework/arcane_framework.dart'; @@ -93,9 +95,11 @@ services: can instead use this widget directly. - The `service` and `requiredService` extensions on `BuildContext`: nullable and non-nullable getters used to locate a given `ArcaneService` via - `BuildContext`. **Note**: Use of these extensions requires that an - `ArcaneServiceProvider` widget is in your widget tree, either by adding it - directly or by using the `ArcaneApp` widget. + `BuildContext`. **Note**: For app-defined services, these lookups require an + `ArcaneServiceProvider` in the widget tree. For Arcane built-in singleton + services (such as `Arcane.auth`, `Arcane.features`, `Arcane.theme`, and + `Arcane.environment`), lookups fall back to built-ins even when no provider + is available. #### Defining an example `ArcaneService` @@ -164,6 +168,10 @@ Unregistering an already registered `ArcaneService` is as simple as: ArcaneServiceProvider.of(context).removeService() ``` +When both a provider-registered service and an Arcane built-in singleton match +the same requested type, provider-registered services take precedence for +`context.service()` and `context.requiredService()`. + #### Locating an `ArcaneService` There are numerous ways to locate a registered `ArcaneService`. Feel free to use @@ -230,7 +238,7 @@ utilized. One is limited only by their imagination! ### Feature Flags -You can easily manage feature flags using the `ArcaneFeatureFlags` built-in +You can easily manage feature flags using the `ArcaneFeatureFlagService` built-in service. Feature flags are useful for enabling or disabling different parts of your application under different circumstances. For example, you may want to enable a new feature only once it has finished development and testing, while @@ -268,7 +276,11 @@ void main() { if (feature.enabledAtStartup) Arcane.features.enableFeature(feature); } - runApp(const ArcaneApp()); + runApp( + ArcaneApp( + child: MainApp(), + ), + ); } ``` @@ -302,6 +314,9 @@ flag service: final List enabledFeatures = Arcane.features.enabledFeatures; ``` +`enabledFeatures` is a snapshot read. Reading it does not subscribe to updates +and does not trigger widget rebuilds. + It is also possible to add a listener to watch for changes in the enabled features. @@ -332,6 +347,27 @@ void dispose() { } ``` +When using `ArcaneApp`, you can also depend on feature flags via +`context.featureFlags`. This resolves to the nearest +`ArcaneFeatureFlagProvider` and widgets that read it rebuild automatically when +feature flags change. + +```dart +class FeatureGate extends StatelessWidget { + const FeatureGate({super.key}); + + @override + Widget build(BuildContext context) { + final flags = context.featureFlags; + if (flags.isDisabled(Feature.awesomeFeature)) { + return const SizedBox.shrink(); + } + + return const Text("Awesome feature enabled"); + } +} +``` + Note that it is possible to register multiple different `Enum` types in the feature flag service, should one have a need to do so. @@ -752,11 +788,11 @@ class EnvironmentSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); + final ArcaneEnvironmentService environment = Arcane.environment; return ElevatedButton( onPressed: () { - arcaneEnvironment.setEnvironment(staging); + environment.setEnvironment(staging); }, child: const Text("Use staging"), ); @@ -767,6 +803,10 @@ class EnvironmentSwitcher extends StatelessWidget { `enableDebugMode()` and `disableDebugMode()` are still available convenience helpers that map to the built-in debug and normal environments. +`ArcaneEnvironment` and `ArcaneEnvironmentProvider` are still available for +backward compatibility, but are deprecated in favor of +`Arcane.environment`/`ArcaneEnvironmentService`. + Authentication status is intentionally separate from environment. Switching environments does not change `AuthenticationStatus`. diff --git a/example/README.md b/example/README.md index e12ef93..56d05ea 100644 --- a/example/README.md +++ b/example/README.md @@ -37,6 +37,12 @@ Key patterns shown in the app include: - Subscribing to `Arcane.logger.logStream` in `initState` - Canceling subscriptions in `dispose` - Rebuilding UI from stream and notifier changes +- Rebuilding UI from `ArcaneFeatureFlagProvider.of(context)` dependencies +- Using `context.featureFlags` convenience access in widgets + +`ArcaneApp` composes built-in providers/switchers for Arcane services, +feature flags, environment, and theme updates, and this example demonstrates +all of them working together. Use this app as a reference for combining Arcane streams and `ValueNotifier` listeners in the same codebase. diff --git a/example/lib/main.dart b/example/lib/main.dart index c13a328..2f0e7b1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -62,8 +62,8 @@ Future main() async { ); runApp( - // The `ArcaneApp` widget is optional but provides the `ArcaneEnvironmentProvider`, - // `ArcaneServiceProvider`, and `ArcaneThemeSwitcher` widgets. + // The `ArcaneApp` widget is optional but provides Arcane's built-in + // service, feature flag, environment, and theme integration widgets. const ArcaneApp( child: MainApp(), ), @@ -444,9 +444,9 @@ class ArcaneThemeExample extends StatelessWidget { // Arcane's feature flag system is extremely simple and flexible to use. // By registering _any_ enum (or even multiple enums!), features can be // toggled on and off at any point. The feature flag system even offers -// a notifier, so you can listen to changes as they happen. Fetch your -// remote config and use it to dynamically enable and disable features -// with ease! +// a notifier, stream, and app-level scope so you can react to changes as +// they happen. Fetch your remote config and use it to dynamically enable +// and disable features with ease! class ArcaneFeatureFlagsExample extends StatelessWidget { const ArcaneFeatureFlagsExample({ super.key, @@ -454,47 +454,44 @@ class ArcaneFeatureFlagsExample extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: Arcane.features.notifier, - builder: (context, enabledFeatures, _) { - return Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Feature Flags", - style: Theme.of(context).textTheme.headlineSmall, - ), - Expanded( - child: ListView.builder( - itemCount: Feature.values.length, - itemBuilder: (context, i) { - final Feature feature = Feature.values[i]; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(feature.name), - Switch( - value: feature.enabled, - onChanged: (_) { - feature.enabled - ? feature.disable() - : feature.enable(); - }, - ), - ], - ); - }, - ), - ), - ], + final ArcaneFeatureFlagProvider flags = context.featureFlags; + + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Feature Flags", + style: Theme.of(context).textTheme.headlineSmall, ), - ), - ); - }, + Expanded( + child: ListView.builder( + itemCount: Feature.values.length, + itemBuilder: (context, i) { + final Feature feature = Feature.values[i]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(feature.name), + Switch( + value: flags.isEnabled(feature), + onChanged: (_) { + flags.isEnabled(feature) + ? flags.disableFeature(feature) + : flags.enableFeature(feature); + }, + ), + ], + ); + }, + ), + ), + ], + ), + ), ); } } @@ -532,9 +529,7 @@ class ArcaneEnvironmentExample extends StatelessWidget { ), ElevatedButton( onPressed: () { - final ArcaneEnvironment environment = ArcaneEnvironment.of( - context, - ); + final ArcaneEnvironmentService environment = Arcane.environment; final Environment previousEnvironment = environment.environment; final Environment nextEnvironment = _nextEnvironment( previousEnvironment, @@ -553,7 +548,7 @@ class ArcaneEnvironmentExample extends StatelessWidget { child: const Text("Cycle environment"), ), Text( - "Environment: ${ArcaneEnvironment.of(context).environment.name}", + "Environment: ${Arcane.environment.environment.name}", textAlign: TextAlign.center, ), ], diff --git a/lib/arcane_framework.dart b/lib/arcane_framework.dart index 6ca32ec..bc63c01 100644 --- a/lib/arcane_framework.dart +++ b/lib/arcane_framework.dart @@ -11,10 +11,10 @@ /// - **Service Management**: Centralized access to critical services like /// logging, feature flags, and theming. /// - **Feature Flags**: Dynamically enable or disable features using -/// `ArcaneFeatureFlags`. +/// `ArcaneFeatureFlagService`. /// - **Logging**: Flexible logging with different severity levels /// (`debug`, `info`, `error`, etc.). -/// - **Theming**: Easy light/dark mode switching with `ArcaneReactiveTheme`. +/// - **Theming**: Easy light/dark mode switching with `ArcaneThemeService`. /// - **Authentication**: Manage user login, sign up, and token-based /// authentication. /// @@ -39,11 +39,17 @@ library; export "package:arcane_framework/src/arcane.dart"; export "package:arcane_framework/src/arcane_app.dart"; -export "package:arcane_framework/src/providers/environment_provider.dart"; -export "package:arcane_framework/src/providers/service/arcane_service.dart"; +export "package:arcane_framework/src/service/arcane_service.dart"; export "package:arcane_framework/src/services/authentication/authentication_service.dart"; +export "package:arcane_framework/src/services/environment/environment_interface.dart"; +export "package:arcane_framework/src/services/environment/environment_provider.dart"; +export "package:arcane_framework/src/services/environment/environment_service.dart"; +export "package:arcane_framework/src/services/feature_flags/feature_flags_context_extensions.dart"; +export "package:arcane_framework/src/services/feature_flags/feature_flags_provider.dart"; export "package:arcane_framework/src/services/feature_flags/feature_flags_service.dart"; export "package:arcane_framework/src/services/logging/logging_service.dart"; -export "package:arcane_framework/src/services/reactive_theme/reactive_theme_service.dart"; -export "package:arcane_framework/src/services/reactive_theme/reactive_theme_switcher.dart"; +export "package:arcane_framework/src/services/theme/arcane_theme.dart"; +export "package:arcane_framework/src/services/theme/theme_extensions.dart"; +export "package:arcane_framework/src/services/theme/theme_service.dart"; +export "package:arcane_framework/src/services/theme/theme_switcher.dart"; export "package:result_monad/result_monad.dart"; diff --git a/lib/src/arcane.dart b/lib/src/arcane.dart index 3ebe1a0..ea5c3e5 100644 --- a/lib/src/arcane.dart +++ b/lib/src/arcane.dart @@ -1,4 +1,11 @@ -import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/foundation.dart"; + +import "service/arcane_service.dart"; +import "services/authentication/authentication_service.dart"; +import "services/environment/environment_service.dart"; +import "services/feature_flags/feature_flags_service.dart"; +import "services/logging/logging_service.dart"; +import "services/theme/theme_service.dart"; /// A singleton class that acts as the central hub for various services in the /// Arcane framework. @@ -7,37 +14,61 @@ import "package:arcane_framework/arcane_framework.dart"; /// authentication, theming, secure storage, and ID management. It also offers a /// convenient method for logging messages using the integrated logger. abstract class Arcane { + // Internal registry for service instances, set by ArcaneApp if present. + static ValueNotifier>? registry; + + // Called by ArcaneApp to register the live service registry. + /// Called by ArcaneApp to register the live service registry. + static void setRegistry(ValueNotifier> r) { + registry = r; + } + + // Called by ArcaneApp to clear the registry when disposed. + /// Called by ArcaneApp to clear the registry when disposed. + static void clearRegistry() { + registry = null; + } + + // The built-in singleton services (used as fallback if no ArcaneApp is present). + static List get builtInServices => [ + ArcaneFeatureFlagService.I, + ArcaneAuthenticationService.I, + ArcaneThemeService.I, + ArcaneEnvironmentService.I, + ]; + /// Provides access to the singleton instance of the logger service. /// /// The `ArcaneLogger` is used for logging messages throughout the app. + /// Logger is not a service and is always the singleton. static ArcaneLogger get logger => ArcaneLogger.I; - /// Provides access to the singleton instance of the feature flags service. - /// - /// `ArcaneFeatureFlags` manages feature toggles, allowing you to enable or - /// disable features dynamically. - static ArcaneFeatureFlags get features => ArcaneFeatureFlags.I; + /// Provides access to the feature flags service instance registered in ArcaneApp, or the singleton if not present. + static ArcaneFeatureFlagService get features => + services.whereType().firstOrNull ?? + ArcaneFeatureFlagService.I; - /// Provides access to the singleton instance of the authentication service. - /// - /// `ArcaneAuthenticationService` manages user authentication, login, and - /// signup processes. - static ArcaneAuthenticationService get auth => ArcaneAuthenticationService.I; + /// Provides access to the authentication service instance registered in ArcaneApp, or the singleton if not present. + static ArcaneAuthenticationService get auth => + services.whereType().firstOrNull ?? + ArcaneAuthenticationService.I; - /// Provides access to the singleton instance of the theme management service. - /// - /// `ArcaneReactiveTheme` allows switching between light and dark themes and - /// customizing them. - static ArcaneReactiveTheme get theme => ArcaneReactiveTheme.I; + /// Provides access to the theme management service instance registered in ArcaneApp, or the singleton if not present. + /// Returns ArcaneThemeService, but is also assignable to ArcaneReactiveTheme for backward compatibility. + static ArcaneThemeService get theme => + services.whereType().firstOrNull ?? + ArcaneThemeService.I; + + /// Provides access to the environment service instance registered in ArcaneApp, or the singleton if not present. + static ArcaneEnvironmentService get environment => + services.whereType().firstOrNull ?? + ArcaneEnvironmentService.I; /// Returns a list of all services available in the Arcane framework. /// - /// This list includes the feature flags, authentication, theme, and ID services. - static List get services => [ - features, - auth, - theme, - ]; + /// This list includes the feature flags, authentication, theme, and environment services. + /// If ArcaneApp is present, this reflects the live registry; otherwise, falls back to built-in singletons. + static List get services => registry?.value ?? builtInServices; /// Logs a message using the integrated logger. /// diff --git a/lib/src/arcane_app.dart b/lib/src/arcane_app.dart index 12f6460..9a50eee 100644 --- a/lib/src/arcane_app.dart +++ b/lib/src/arcane_app.dart @@ -1,6 +1,11 @@ -import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_framework/src/service/arcane_service.dart"; import "package:flutter/material.dart"; +import "arcane.dart"; +import "services/environment/environment_provider.dart"; +import "services/feature_flags/feature_flags_provider.dart"; +import "services/theme/theme_switcher.dart"; + /// A root widget for an Arcane-powered application. /// /// `ArcaneApp` serves as the entry point for an application using the Arcane @@ -14,41 +19,71 @@ import "package:flutter/material.dart"; /// Example usage: /// ```dart /// ArcaneApp( -/// services: [ArcaneAuthenticationService(), ArcaneFeatureFlags()], +/// services: [MyArcaneService()], /// child: MyApp(), /// ); /// ``` -class ArcaneApp extends StatelessWidget { +class ArcaneApp extends StatefulWidget { /// A list of Arcane services that will be made available to the application. - /// - /// These services will be provided to the widget tree using - /// `ArcaneServiceProvider`. - /// If no services are specified, an empty list is used by default. final List services; /// The root widget of the application. - /// - /// This widget will be wrapped by the service and environment providers. final Widget child; - /// Creates an `ArcaneApp` with the specified [child] widget and optional - /// [services]. - /// - /// The [child] is required, while the [services] list is optional. By - /// default, the [services] list is empty. + /// Creates an `ArcaneApp` with the specified [child] widget and optional [services]. const ArcaneApp({ required this.child, this.services = const [], super.key, }); + @override + State createState() => _ArcaneAppState(); +} + +class _ArcaneAppState extends State { + late final ValueNotifier> _serviceNotifier; + + List _computeMergedServices() { + final List merged = + List.from(widget.services); + final Set existingTypes = + merged.map((service) => service.runtimeType).toSet(); + + // Use Arcane._builtInServices directly to avoid registry recursion during init. + for (final ArcaneService builtIn in Arcane.builtInServices) { + if (existingTypes.contains(builtIn.runtimeType)) continue; + merged.add(builtIn); + existingTypes.add(builtIn.runtimeType); + } + + return merged; + } + + @override + void initState() { + super.initState(); + _serviceNotifier = + ValueNotifier>(_computeMergedServices()); + Arcane.setRegistry(_serviceNotifier); + } + + @override + void dispose() { + Arcane.clearRegistry(); + _serviceNotifier.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ArcaneEnvironmentProvider( - child: ArcaneServiceProvider( - serviceInstances: services, - child: ArcaneThemeSwitcher( - child: child, + return ArcaneServiceProvider( + serviceNotifier: _serviceNotifier, + child: ArcaneFeatureFlagsProvider( + child: ArcaneEnvironmentProvider( + child: ArcaneThemeSwitcher( + child: widget.child, + ), ), ), ); diff --git a/lib/src/providers/service/arcane_service.dart b/lib/src/service/arcane_service.dart similarity index 100% rename from lib/src/providers/service/arcane_service.dart rename to lib/src/service/arcane_service.dart diff --git a/lib/src/providers/service/service_provider.dart b/lib/src/service/service_provider.dart similarity index 92% rename from lib/src/providers/service/service_provider.dart rename to lib/src/service/service_provider.dart index de6d8d2..0bfa0ed 100644 --- a/lib/src/providers/service/service_provider.dart +++ b/lib/src/service/service_provider.dart @@ -25,13 +25,16 @@ class ArcaneServiceProvider /// 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. + /// If [serviceNotifier] is provided, it will be used as the backing notifier for the provider. + /// Otherwise, a new notifier will be created from [serviceInstances]. ArcaneServiceProvider({ required super.child, List serviceInstances = const [], + ValueNotifier>? serviceNotifier, super.key, }) : super( - notifier: ValueNotifier>(serviceInstances), + notifier: serviceNotifier ?? + ValueNotifier>(serviceInstances), ); /// Retrieves the nearest `ArcaneServiceProvider` in the widget tree. diff --git a/lib/src/providers/service/service_provider_extensions.dart b/lib/src/service/service_provider_extensions.dart similarity index 80% rename from lib/src/providers/service/service_provider_extensions.dart rename to lib/src/service/service_provider_extensions.dart index e71647d..1466aea 100644 --- a/lib/src/providers/service/service_provider_extensions.dart +++ b/lib/src/service/service_provider_extensions.dart @@ -20,12 +20,12 @@ extension ServiceProviderExtension on BuildContext { /// final myService = context.service(); /// ``` T? service() { - // First check built-in services - final builtInService = Arcane.services.whereType().firstOrNull; - if (builtInService != null) return builtInService; + // First check provider-registered services so app-specific overrides win. + final providerService = ArcaneServiceProvider.serviceOfType(this); + if (providerService != null) return providerService; - // Then check provider - return ArcaneServiceProvider.serviceOfType(this); + // Fall back to built-in services. + return Arcane.services.whereType().firstOrNull; } /// Finds and returns the `ArcaneService` instance of type `T` that has been registered @@ -46,6 +46,6 @@ extension ServiceProviderExtension on BuildContext { /// Legacy method to maintain backward compatibility. /// /// Prefer using `service()` instead. - @Deprecated("Use service() instead") + @Deprecated("Deprecated in 2.0.0. Use service() instead") T? serviceOfType() => service(); } diff --git a/lib/src/services/authentication/authentication_enums.dart b/lib/src/services/authentication/authentication_enums.dart index a4bc64e..a0dd540 100644 --- a/lib/src/services/authentication/authentication_enums.dart +++ b/lib/src/services/authentication/authentication_enums.dart @@ -48,40 +48,3 @@ enum AuthenticationStatus { /// Returns `true` if the current status is `unauthenticated`. bool get isUnauthenticated => this == unauthenticated; } - -/// A value object representing the current application environment. -/// -/// Built-in values are available through [Environment.debug] and -/// [Environment.normal], but custom values can be created for app-specific -/// environments such as `staging`. -class Environment { - /// Creates an environment with a human-readable [name]. - const Environment(this.name); - - /// Built-in debug environment for development and testing purposes. - static const Environment debug = Environment("debug"); - - /// Built-in normal environment for production use. - static const Environment normal = Environment("normal"); - - /// Human-readable environment name. - final String name; - - /// Returns `true` when this environment is the built-in debug environment. - bool get isDebug => this == debug; - - /// Returns `true` when this environment is the built-in normal environment. - bool get isNormal => this == normal; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is Environment && other.name == name; - } - - @override - int get hashCode => name.hashCode; - - @override - String toString() => "Environment($name)"; -} diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index 6ed6316..a8f53d5 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -36,7 +36,11 @@ class ArcaneAuthenticationService extends ArcaneService { /// Stream of authentication status updates. Stream get statusChanges => I._statusController.stream; - /// Returns the current `AuthenticationStatus`. + /// Returns the current `AuthenticationStatus` as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [notifier] (for `ValueListenableBuilder`) or + /// [statusChanges] (for streams) when you need reactive updates. /// /// Available values: /// - `authenticated`: The user has successfully authenticated and is logged in. @@ -105,19 +109,13 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeSet, }) async { - try { - final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); - - final Environment previousEnvironment = arcaneEnvironment.environment; + final Environment previousEnvironment = Arcane.environment.environment; - if (previousEnvironment == Environment.debug) return; + if (previousEnvironment == Environment.debug) return; - arcaneEnvironment.enableDebugMode(); + Arcane.environment.enableDebugMode(); - if (onDebugModeSet != null) await onDebugModeSet(); - } catch (e) { - rethrow; - } + if (onDebugModeSet != null) await onDebugModeSet(); } /// Enables the normal environment. @@ -127,19 +125,13 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeUnset, }) async { - try { - final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); + final Environment previousEnvironment = Arcane.environment.environment; - final Environment previousEnvironment = arcaneEnvironment.environment; + if (previousEnvironment == Environment.normal) return; - if (previousEnvironment == Environment.normal) return; + Arcane.environment.disableDebugMode(); - arcaneEnvironment.disableDebugMode(); - - if (onDebugModeUnset != null) await onDebugModeUnset(); - } catch (_) { - throw Exception("No ArcaneEnvironment found in BuildContext"); - } + if (onDebugModeUnset != null) await onDebugModeUnset(); } /// Sets `status` to `AuthenticationStatus.authenticated`. diff --git a/lib/src/services/environment/environment_interface.dart b/lib/src/services/environment/environment_interface.dart new file mode 100644 index 0000000..e6387c3 --- /dev/null +++ b/lib/src/services/environment/environment_interface.dart @@ -0,0 +1,36 @@ +/// A value object representing the current application environment. +/// +/// Built-in values are available through [Environment.debug] and +/// [Environment.normal], but custom values can be created for app-specific +/// environments such as `staging`. +class Environment { + /// Creates an environment with a human-readable [name]. + const Environment(this.name); + + /// Built-in debug environment for development and testing purposes. + static const Environment debug = Environment("debug"); + + /// Built-in normal environment for production use. + static const Environment normal = Environment("normal"); + + /// Human-readable environment name. + final String name; + + /// Returns `true` when this environment is the built-in debug environment. + bool get isDebug => this == debug; + + /// Returns `true` when this environment is the built-in normal environment. + bool get isNormal => this == normal; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Environment && other.name == name; + } + + @override + int get hashCode => name.hashCode; + + @override + String toString() => "Environment($name)"; +} diff --git a/lib/src/providers/environment_provider.dart b/lib/src/services/environment/environment_provider.dart similarity index 80% rename from lib/src/providers/environment_provider.dart rename to lib/src/services/environment/environment_provider.dart index 2622d25..2ad1942 100644 --- a/lib/src/providers/environment_provider.dart +++ b/lib/src/services/environment/environment_provider.dart @@ -1,6 +1,8 @@ -import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_framework/src/arcane.dart"; import "package:flutter/widgets.dart"; +import "environment_interface.dart"; + /// An `InheritedWidget` that provides access to the application environment. /// /// The `ArcaneEnvironment` widget holds the current environment and allows @@ -75,10 +77,34 @@ class ArcaneEnvironmentProvider extends StatefulWidget { class _ArcaneEnvironmentProviderState extends State { late Environment _environment; + void _handleEnvironmentChange() { + if (!mounted) return; + + final nextEnvironment = Arcane.environment.environment; + if (nextEnvironment == _environment) return; + + setState(() { + _environment = nextEnvironment; + }); + } + @override void initState() { super.initState(); - _environment = widget.environment; + _environment = Arcane.environment.environment; + + if (_environment != widget.environment) { + Arcane.environment.setEnvironment(widget.environment); + _environment = Arcane.environment.environment; + } + + Arcane.environment.notifier.addListener(_handleEnvironmentChange); + } + + @override + void dispose() { + Arcane.environment.notifier.removeListener(_handleEnvironmentChange); + super.dispose(); } /// Enables debug mode by setting the environment to `Environment.debug`. @@ -94,10 +120,7 @@ class _ArcaneEnvironmentProviderState extends State { } void setEnvironment(Environment environment) { - if (_environment == environment) return; - setState(() { - _environment = environment; - }); + Arcane.environment.setEnvironment(environment); } @override diff --git a/lib/src/services/environment/environment_service.dart b/lib/src/services/environment/environment_service.dart new file mode 100644 index 0000000..4cc2c97 --- /dev/null +++ b/lib/src/services/environment/environment_service.dart @@ -0,0 +1,65 @@ +import "dart:async"; + +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/widgets.dart"; + +/// A singleton service that stores and broadcasts the current application +/// environment. +class ArcaneEnvironmentService extends ArcaneService { + ArcaneEnvironmentService._internal(); + + static final ArcaneEnvironmentService _instance = + ArcaneEnvironmentService._internal(); + + /// Provides access to the singleton instance. + static ArcaneEnvironmentService get I => _instance; + + final ValueNotifier _notifier = + ValueNotifier(Environment.normal); + + /// A notifier that emits updates when [environment] changes. + ValueNotifier get notifier => _notifier; + + StreamController? _environmentStreamController; + + StreamController get _environmentController { + _environmentStreamController ??= StreamController.broadcast(); + return _environmentStreamController!; + } + + /// Stream of environment updates. + Stream get environmentChanges => I._environmentController.stream; + + /// The current application environment as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [notifier] (for `ValueListenableBuilder`) or + /// [environmentChanges] (for streams) when you need reactive updates. + Environment get environment => _notifier.value; + + /// Sets the environment when the incoming value is different. + void setEnvironment(Environment environment) { + if (_notifier.value == environment) return; + _notifier.value = environment; + _environmentController.add(_notifier.value); + } + + /// Switches the app to [Environment.debug]. + void enableDebugMode() => setEnvironment(Environment.debug); + + /// Switches the app to [Environment.normal]. + void disableDebugMode() => setEnvironment(Environment.normal); + + /// Restores defaults and emits the current state. + void reset() { + _notifier.value = Environment.normal; + _environmentController.add(_notifier.value); + } + + @override + void dispose() { + unawaited(_environmentStreamController?.close()); + _environmentStreamController = null; + super.dispose(); + } +} diff --git a/lib/src/services/feature_flags/feature_flags_context_extensions.dart b/lib/src/services/feature_flags/feature_flags_context_extensions.dart new file mode 100644 index 0000000..0b45251 --- /dev/null +++ b/lib/src/services/feature_flags/feature_flags_context_extensions.dart @@ -0,0 +1,38 @@ +import "package:arcane_framework/src/arcane.dart"; +import "package:flutter/widgets.dart"; + +import "feature_flags_provider.dart"; + +/// Convenience accessors for feature flags from `BuildContext`. +extension ArcaneFeatureFlagsContext on BuildContext { + /// Returns the nearest [ArcaneFeatureFlagProvider]. + /// + /// This creates an inherited dependency, so widgets using this getter in + /// `build` rebuild when enabled features change. + ArcaneFeatureFlagProvider get featureFlags => + ArcaneFeatureFlagProvider.of(this); + + /// Returns the nearest [ArcaneFeatureFlagProvider], if one exists. + ArcaneFeatureFlagProvider? get maybeFeatureFlags => + ArcaneFeatureFlagProvider.maybeOf(this); + + /// Returns `true` when [feature] is enabled. + /// + /// If no [ArcaneFeatureFlagProvider] is available in the tree, this falls + /// back + /// to [Arcane.features] snapshot state. + bool isFeatureEnabled(Enum feature) { + return maybeFeatureFlags?.isEnabled(feature) ?? + Arcane.features.isEnabled(feature); + } + + /// Returns `true` when [feature] is disabled. + /// + /// If no [ArcaneFeatureFlagProvider] is available in the tree, this falls + /// back + /// to [Arcane.features] snapshot state. + bool isFeatureDisabled(Enum feature) { + return maybeFeatureFlags?.isDisabled(feature) ?? + Arcane.features.isDisabled(feature); + } +} diff --git a/lib/src/services/feature_flags/feature_flags_extensions.dart b/lib/src/services/feature_flags/feature_flags_extensions.dart index aac456e..9b39243 100644 --- a/lib/src/services/feature_flags/feature_flags_extensions.dart +++ b/lib/src/services/feature_flags/feature_flags_extensions.dart @@ -3,7 +3,7 @@ part of "feature_flags_service.dart"; /// An extension on `Enum` to manage feature toggles. /// /// This extension provides a convenient way to enable, disable, and check the status -/// of feature flags associated with enum values. It interacts with the `ArcaneFeatureFlags` +/// of feature flags associated with enum values. It interacts with the `ArcaneFeatureFlagService` /// system to manage these feature flags at runtime. extension FeatureToggles on Enum { /// Returns `true` if the feature represented by this enum is currently enabled. @@ -31,7 +31,7 @@ extension FeatureToggles on Enum { /// Enables the feature represented by this enum. /// /// If the feature is already enabled, this method has no effect. It interacts with - /// the `ArcaneFeatureFlags` system to enable the feature. + /// the `ArcaneFeatureFlagService` system to enable the feature. /// /// Example: /// ```dart @@ -42,7 +42,7 @@ extension FeatureToggles on Enum { /// Disables the feature represented by this enum. /// /// If the feature is already disabled, this method has no effect. It interacts with - /// the `ArcaneFeatureFlags` system to disable the feature. + /// the `ArcaneFeatureFlagService` system to disable the feature. /// /// Example: /// ```dart diff --git a/lib/src/services/feature_flags/feature_flags_provider.dart b/lib/src/services/feature_flags/feature_flags_provider.dart new file mode 100644 index 0000000..dce4e5f --- /dev/null +++ b/lib/src/services/feature_flags/feature_flags_provider.dart @@ -0,0 +1,140 @@ +import "package:arcane_framework/src/arcane.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/widgets.dart"; + +/// An `InheritedWidget` that provides access to enabled feature flags. +/// +/// Descendant widgets that call [of] or [maybeOf] will rebuild when the +/// enabled feature set changes. +class ArcaneFeatureFlagProvider extends InheritedWidget { + /// The currently enabled feature flags. + final List enabledFeatures; + + final ValueChanged _enableFeature; + final ValueChanged _disableFeature; + + /// Creates an `ArcaneFeatureFlagProvider` widget. + const ArcaneFeatureFlagProvider({ + required this.enabledFeatures, + required void Function(Enum) enableFeature, + required void Function(Enum) disableFeature, + required super.child, + super.key, + }) : _enableFeature = enableFeature, + _disableFeature = disableFeature; + + /// Retrieves the nearest `ArcaneFeatureFlagProvider` from the widget tree. + /// + /// Returns `null` if no `ArcaneFeatureFlagProvider` ancestor is found. + static ArcaneFeatureFlagProvider? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + /// Retrieves the nearest `ArcaneFeatureFlagProvider` from the widget tree. + /// + /// Throws a `StateError` if no `ArcaneFeatureFlagProvider` ancestor is found. + static ArcaneFeatureFlagProvider of(BuildContext context) { + final ArcaneFeatureFlagProvider? result = maybeOf(context); + if (result == null) { + throw StateError("No ArcaneFeatureFlagProvider found in context"); + } + return result; + } + + /// Returns whether [feature] is currently enabled. + bool isEnabled(Enum feature) => enabledFeatures.contains(feature); + + /// Returns whether [feature] is currently disabled. + bool isDisabled(Enum feature) => !isEnabled(feature); + + /// Enables [feature]. + void enableFeature(Enum feature) => _enableFeature(feature); + + /// Disables [feature]. + void disableFeature(Enum feature) => _disableFeature(feature); + + /// A `ValueListenable` that can be used for reactive feature flag updates. + ValueListenable> get notifier => Arcane.features.notifier; + + /// A stream of enabled feature flag updates. + Stream> get enabledFeaturesChanges => + Arcane.features.enabledFeaturesChanges; + + @override + bool updateShouldNotify(ArcaneFeatureFlagProvider oldWidget) { + return !listEquals(enabledFeatures, oldWidget.enabledFeatures); + } +} + +@Deprecated( + "Deprecated in 2.0.0. " + "ArcaneFeatureFlagsScope has been renamed to ArcaneFeatureFlagProvider. " + "Please use ArcaneFeatureFlagProvider instead.", +) +typedef ArcaneFeatureFlagsScope = ArcaneFeatureFlagProvider; + +/// A `StatefulWidget` that keeps [ArcaneFeatureFlagProvider] in sync with +/// [ArcaneFeatureFlagService] and rebuilds descendants when flags change. +class ArcaneFeatureFlagsProvider extends StatefulWidget { + /// The child widget that will have access to feature flags. + final Widget child; + + /// Creates an `ArcaneFeatureFlagsProvider`. + const ArcaneFeatureFlagsProvider({ + required this.child, + super.key, + }); + + @override + State createState() => + _ArcaneFeatureFlagsProviderState(); +} + +class _ArcaneFeatureFlagsProviderState + extends State { + late List _enabledFeatures; + + void _handleFeatureFlagsChange() { + if (!mounted) return; + + final List nextEnabled = + List.from(Arcane.features.notifier.value); + if (listEquals(nextEnabled, _enabledFeatures)) return; + + setState(() { + _enabledFeatures = nextEnabled; + }); + } + + @override + void initState() { + super.initState(); + _enabledFeatures = List.from(Arcane.features.notifier.value); + Arcane.features.notifier.addListener(_handleFeatureFlagsChange); + } + + @override + void dispose() { + Arcane.features.notifier.removeListener(_handleFeatureFlagsChange); + super.dispose(); + } + + void enableFeature(Enum feature) { + Arcane.features.enableFeature(feature); + } + + void disableFeature(Enum feature) { + Arcane.features.disableFeature(feature); + } + + @override + Widget build(BuildContext context) { + return ArcaneFeatureFlagProvider( + enabledFeatures: List.unmodifiable(_enabledFeatures), + enableFeature: enableFeature, + disableFeature: disableFeature, + child: widget.child, + ); + } +} diff --git a/lib/src/services/feature_flags/feature_flags_service.dart b/lib/src/services/feature_flags/feature_flags_service.dart index f33bc0b..817729c 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -5,29 +5,41 @@ import "package:flutter/foundation.dart"; part "feature_flags_extensions.dart"; +@Deprecated( + "Deprecated in 2.0.0. " + "ArcaneFeatureFlags has been renamed to ArcaneFeatureFlagService for clarity. " + "Please use ArcaneFeatureFlagService instead.", +) +typedef ArcaneFeatureFlags = ArcaneFeatureFlagService; + /// A singleton class that manages feature flags in the Arcane architecture. /// -/// `ArcaneFeatureFlags` allows features to be dynamically enabled or disabled +/// `ArcaneFeatureFlagService` allows features to be dynamically enabled or disabled /// at runtime. This can be useful for controlling access to experimental or /// conditional functionality without requiring an application restart. /// /// Example usage: /// ```dart -/// ArcaneFeatureFlags.I.enableFeature(MyFeature.example); -/// if (ArcaneFeatureFlags.I.isEnabled(MyFeature.example)) { +/// ArcaneFeatureFlagService.I.enableFeature(MyFeature.example); +/// if (ArcaneFeatureFlagService.I.isEnabled(MyFeature.example)) { /// // Execute feature-specific logic /// } /// ``` -class ArcaneFeatureFlags extends ArcaneService { - ArcaneFeatureFlags._internal(); +class ArcaneFeatureFlagService extends ArcaneService { + ArcaneFeatureFlagService._internal(); - /// The singleton instance of `ArcaneFeatureFlags`. - static final ArcaneFeatureFlags _instance = ArcaneFeatureFlags._internal(); + /// The singleton instance of `ArcaneFeatureFlagService`. + static final ArcaneFeatureFlagService _instance = + ArcaneFeatureFlagService._internal(); - /// Provides access to the singleton instance of `ArcaneFeatureFlags`. - static ArcaneFeatureFlags get I => _instance; + /// Provides access to the singleton instance of `ArcaneFeatureFlagService`. + static ArcaneFeatureFlagService get I => _instance; - /// A list of enabled features. + /// A list of enabled features as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [notifier] (for `ValueListenableBuilder`) or + /// [enabledFeaturesChanges] (for streams) when you need reactive updates. /// /// Each feature is represented as an `Enum`. The list holds the features that are /// currently enabled. @@ -77,9 +89,9 @@ class ArcaneFeatureFlags extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneFeatureFlags.I.enableFeature(MyFeature.newFeature); + /// ArcaneFeatureFlagService.I.enableFeature(MyFeature.newFeature); /// ``` - ArcaneFeatureFlags enableFeature(Enum feature) { + ArcaneFeatureFlagService enableFeature(Enum feature) { if (!I._initialized) _init(); if (_enabledFeatures.contains(feature)) return I; @@ -89,10 +101,10 @@ class ArcaneFeatureFlags extends ArcaneService { if (Arcane.logger.initialized) { Arcane.logger.log( - "Feature enabled: ${feature.name}", - level: Level.debug, + "Feature enabled: $feature", + level: Level.info, metadata: { - feature.name: "✅", + feature.toString(): "✅", }, ); } @@ -107,9 +119,9 @@ class ArcaneFeatureFlags extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneFeatureFlags.I.disableFeature(MyFeature.oldFeature); + /// ArcaneFeatureFlagService.I.disableFeature(MyFeature.oldFeature); /// ``` - ArcaneFeatureFlags disableFeature(Enum feature) { + ArcaneFeatureFlagService disableFeature(Enum feature) { if (!I._initialized) _init(); if (!_enabledFeatures.contains(feature)) return I; @@ -118,10 +130,10 @@ class ArcaneFeatureFlags extends ArcaneService { if (Arcane.logger.initialized) { Arcane.logger.log( - "Feature disabled: ${feature.name}", - level: Level.debug, + "Feature disabled: $feature", + level: Level.info, metadata: { - feature.name: "❌", + feature.toString(): "❌", }, ); } diff --git a/lib/src/services/reactive_theme/arcane_theme.dart b/lib/src/services/theme/arcane_theme.dart similarity index 100% rename from lib/src/services/reactive_theme/arcane_theme.dart rename to lib/src/services/theme/arcane_theme.dart index d91ea3d..c90ee2a 100644 --- a/lib/src/services/reactive_theme/arcane_theme.dart +++ b/lib/src/services/theme/arcane_theme.dart @@ -7,10 +7,10 @@ class ArcaneTheme extends InheritedWidget { const ArcaneTheme({ required super.child, + super.key, this.themeMode = ThemeMode.light, this.followSystem = false, this.theme, - super.key, }); static ArcaneTheme? of(BuildContext context) { diff --git a/lib/src/services/reactive_theme/reactive_theme_extensions.dart b/lib/src/services/theme/theme_extensions.dart similarity index 64% rename from lib/src/services/reactive_theme/reactive_theme_extensions.dart rename to lib/src/services/theme/theme_extensions.dart index edb18f6..5d3a211 100644 --- a/lib/src/services/reactive_theme/reactive_theme_extensions.dart +++ b/lib/src/services/theme/theme_extensions.dart @@ -1,4 +1,7 @@ -part of "reactive_theme_service.dart"; +import "package:flutter/material.dart"; + +import "arcane_theme.dart"; +import "theme_service.dart"; /// An extension on `BuildContext` to check the current system dark mode setting. /// @@ -19,3 +22,11 @@ extension DarkMode on BuildContext { return brightness == Brightness.dark; } } + +extension ArcaneThemeContext on BuildContext { + /// Get the current theme mode from the nearest ArcaneThemeInherited widget + ThemeMode get themeMode { + return ArcaneTheme.of(this)?.themeMode ?? + ArcaneReactiveTheme.I.currentThemeMode; + } +} diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/theme/theme_service.dart similarity index 69% rename from lib/src/services/reactive_theme/reactive_theme_service.dart rename to lib/src/services/theme/theme_service.dart index 0c28cc3..e8ee32c 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/theme/theme_service.dart @@ -1,22 +1,29 @@ import "dart:async"; -import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_framework/src/service/arcane_service.dart"; import "package:flutter/material.dart"; -part "reactive_theme_extensions.dart"; +import "theme_extensions.dart"; + +@Deprecated( + "Deprecated in 2.0.0. " + "ArcaneReactiveTheme has been renamed to ArcaneThemeService for clarity. " + "Please use ArcaneThemeService instead.", +) +typedef ArcaneReactiveTheme = ArcaneThemeService; /// A singleton service that manages theme switching and customization for the application. /// -/// `ArcaneReactiveTheme` allows switching between light and dark themes and provides +/// `ArcaneThemeService` allows switching between light and dark themes and provides /// methods to customize the themes. The current theme mode can be accessed, and the /// theme can be switched at runtime. /// /// System theme changes are detected by the `ArcaneApp` widget, which ensures /// theme updates happen automatically when the device theme changes. -class ArcaneReactiveTheme extends ArcaneService { - ArcaneReactiveTheme._internal(); - static final ArcaneReactiveTheme _instance = ArcaneReactiveTheme._internal(); - static ArcaneReactiveTheme get I => _instance; +class ArcaneThemeService extends ArcaneService { + ArcaneThemeService._internal(); + static final ArcaneThemeService _instance = ArcaneThemeService._internal(); + static ArcaneThemeService get I => _instance; // ************************************************************************ // // * MARK: System theme @@ -44,13 +51,18 @@ class ArcaneReactiveTheme extends ArcaneService { // ************************************************************************ // // * MARK: ThemeMode // ************************************************************************ // - /// Returns the current `ThemeMode` being used by `ArcaneReactiveTheme`. + /// Returns the current `ThemeMode` being used by `ArcaneThemeService`. /// Will automatically update when the theme changes. ThemeMode currentModeOf(BuildContext context) => context.themeMode; - /// The currently active theme mode (light or dark). + /// The currently active theme mode (light, dark, or system) as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [themeModeChanges] when you need reactive updates. + /// + /// If `ThemeMode.system`, the effective theme is determined by the platform brightness. ThemeMode get currentThemeMode => _currentThemeMode; - ThemeMode _currentThemeMode = ThemeMode.light; + ThemeMode _currentThemeMode = ThemeMode.system; /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. Stream get themeModeChanges => I._themeModeController.stream; @@ -65,7 +77,10 @@ class ArcaneReactiveTheme extends ArcaneService { // ************************************************************************ // // * MARK: ThemeData // ************************************************************************ // - /// The currently active theme style. + /// The currently active theme style as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [themeDataChanges] when you need reactive updates. ThemeData get currentTheme => _currentTheme; ThemeData _currentTheme = ThemeData(); @@ -82,14 +97,20 @@ class ArcaneReactiveTheme extends ArcaneService { // ************************************************************************ // // * MARK: Light/Dark theme // ************************************************************************ // - /// Returns the current dark theme `ThemeData`. + /// Returns the current dark theme `ThemeData` as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [darkTheme] when you need reactive updates. ThemeData get dark => _darkTheme.value; /// ValueNotifier for the dark theme that can be observed for changes. ValueNotifier get darkTheme => I._darkTheme; final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); - /// Returns the current light theme `ThemeData`. + /// Returns the current light theme `ThemeData` as a snapshot value. + /// + /// Reading this getter does not subscribe to changes and does not trigger + /// widget rebuilds. Use [lightTheme] when you need reactive updates. ThemeData get light => _lightTheme.value; /// ValueNotifier for the light theme that can be observed for changes. @@ -106,13 +127,13 @@ class ArcaneReactiveTheme extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneReactiveTheme.I.switchTheme(); + /// ArcaneThemeService.I.switchTheme(); /// // or - /// ArcaneReactiveTheme.I.switchTheme(themeMode: ThemeMode.dark); + /// ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.dark); /// // or /// Arcane.theme.switchTheme(themeMode: ThemeMode.light); /// ``` - ArcaneReactiveTheme switchTheme({ThemeMode? themeMode}) { + ArcaneThemeService switchTheme({ThemeMode? themeMode}) { _followingSystemTheme = false; if (themeMode != null) { @@ -134,11 +155,11 @@ class ArcaneReactiveTheme extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneReactiveTheme.I.followSystemTheme(context); + /// ArcaneThemeService.I.followSystemTheme(context); /// // or /// Arcane.theme.followSystemTheme(context); /// ``` - ArcaneReactiveTheme followSystemTheme(BuildContext context) { + ArcaneThemeService followSystemTheme(BuildContext context) { _followingSystemTheme = true; _currentSystemThemeMode = @@ -160,9 +181,9 @@ class ArcaneReactiveTheme extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneReactiveTheme.I.setDarkTheme(customDarkTheme); + /// ArcaneThemeService.I.setDarkTheme(customDarkTheme); /// ``` - ArcaneReactiveTheme setDarkTheme(ThemeData theme) { + ArcaneThemeService setDarkTheme(ThemeData theme) { _darkTheme.value = theme; _themeController.add(theme); _currentTheme = theme; @@ -177,9 +198,9 @@ class ArcaneReactiveTheme extends ArcaneService { /// /// Example: /// ```dart - /// ArcaneReactiveTheme.I.setLightTheme(customLightTheme); + /// ArcaneThemeService.I.setLightTheme(customLightTheme); /// ``` - ArcaneReactiveTheme setLightTheme(ThemeData theme) { + ArcaneThemeService setLightTheme(ThemeData theme) { _lightTheme.value = theme; _themeController.add(theme); _currentTheme = theme; @@ -187,6 +208,25 @@ class ArcaneReactiveTheme extends ArcaneService { return I; } + /// Should be called on first build to ensure the initial theme matches the platform brightness. + void setInitialTheme(BuildContext context) { + // Only update if the theme is still the default (not user-provided) + if (_currentTheme != ThemeData.light() && + _currentTheme != ThemeData.dark()) { + return; + } + switch (_currentThemeMode) { + case ThemeMode.system: + final isDark = + MediaQuery.platformBrightnessOf(context) == Brightness.dark; + _currentTheme = isDark ? ThemeData.dark() : ThemeData.light(); + case ThemeMode.dark: + _currentTheme = ThemeData.dark(); + case ThemeMode.light: + _currentTheme = ThemeData.light(); + } + } + /// Resets the theme service to its default state. /// /// This resets both light and dark themes to their default values and diff --git a/lib/src/services/reactive_theme/reactive_theme_switcher.dart b/lib/src/services/theme/theme_switcher.dart similarity index 52% rename from lib/src/services/reactive_theme/reactive_theme_switcher.dart rename to lib/src/services/theme/theme_switcher.dart index 568c391..b633d49 100644 --- a/lib/src/services/reactive_theme/reactive_theme_switcher.dart +++ b/lib/src/services/theme/theme_switcher.dart @@ -1,8 +1,10 @@ import "dart:async"; -import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/material.dart"; +import "arcane_theme.dart"; +import "theme_service.dart"; + class ArcaneThemeSwitcher extends StatefulWidget { final Widget child; @@ -17,19 +19,21 @@ class ArcaneThemeSwitcher extends StatefulWidget { class _ArcaneThemeSwitcherState extends State with WidgetsBindingObserver { + bool _initialized = false; late final StreamSubscription _themeModeSubscription; late final StreamSubscription _themeSubscription; @override void initState() { super.initState(); + // Register as an observer to detect system theme changes WidgetsBinding.instance.addObserver(this); - _themeModeSubscription = ArcaneReactiveTheme.I.themeModeChanges.listen((_) { + _themeModeSubscription = ArcaneThemeService.I.themeModeChanges.listen((_) { setState(() {}); }); - _themeSubscription = ArcaneReactiveTheme.I.themeDataChanges.listen((_) { + _themeSubscription = ArcaneThemeService.I.themeDataChanges.listen((_) { setState(() {}); }); } @@ -38,17 +42,27 @@ class _ArcaneThemeSwitcherState extends State void dispose() { _themeModeSubscription.cancel(); _themeSubscription.cancel(); + // Clean up the observer when the widget is disposed WidgetsBinding.instance.removeObserver(this); super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + ArcaneThemeService.I.setInitialTheme(context); + _initialized = true; + } + } + @override Widget build(BuildContext context) { - return _ArcaneTheme( - themeMode: ArcaneReactiveTheme.I.currentThemeMode, - followSystem: ArcaneReactiveTheme.I.isFollowingSystemTheme, - theme: ArcaneReactiveTheme.I.currentTheme, + return ArcaneTheme( + themeMode: ArcaneThemeService.I.currentThemeMode, + followSystem: ArcaneThemeService.I.isFollowingSystemTheme, + theme: ArcaneThemeService.I.currentTheme, child: widget.child, ); } @@ -59,44 +73,12 @@ class _ArcaneThemeSwitcherState extends State // and use it to check the system theme if (mounted) { // Use the current context from the key to check system theme - if (ArcaneReactiveTheme.I.isFollowingSystemTheme) { + if (ArcaneThemeService.I.isFollowingSystemTheme) { WidgetsBinding.instance.addPostFrameCallback((_) { - ArcaneReactiveTheme.I.followSystemTheme(context); + ArcaneThemeService.I.followSystemTheme(context); }); } } super.didChangePlatformBrightness(); } } - -class _ArcaneTheme extends InheritedWidget { - final ThemeMode themeMode; - final bool followSystem; - final ThemeData? theme; - - const _ArcaneTheme({ - required super.child, - this.themeMode = ThemeMode.light, - this.followSystem = false, - this.theme, - }); - - static _ArcaneTheme? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType<_ArcaneTheme>(); - } - - @override - bool updateShouldNotify(_ArcaneTheme oldWidget) { - return themeMode != oldWidget.themeMode || - followSystem != oldWidget.followSystem || - theme != oldWidget.theme; - } -} - -extension ArcaneThemeContext on BuildContext { - /// Get the current theme mode from the nearest ArcaneThemeInherited widget - ThemeMode get themeMode { - return _ArcaneTheme.of(this)?.themeMode ?? - ArcaneReactiveTheme.I.currentThemeMode; - } -} diff --git a/test/arcane_test.dart b/test/arcane_test.dart index c580993..975b641 100644 --- a/test/arcane_test.dart +++ b/test/arcane_test.dart @@ -3,17 +3,19 @@ import "package:flutter_test/flutter_test.dart"; void main() { setUpAll(() { - ArcaneFeatureFlags.I.reset(); + ArcaneFeatureFlagService.I.reset(); ArcaneAuthenticationService.I.reset(); ArcaneReactiveTheme.I.reset(); + ArcaneEnvironmentService.I.reset(); }); group("Arcane", () { test("services getter returns all core services", () { final services = Arcane.services; - expect(services, contains(isA())); + expect(services, contains(isA())); expect(services, contains(isA())); expect(services, contains(isA())); + expect(services, contains(isA())); }); }); } diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index fa10d76..6fea9f3 100644 --- a/test/providers/service_provider_test.dart +++ b/test/providers/service_provider_test.dart @@ -20,7 +20,47 @@ void main() { child: Builder( builder: (context) { final provider = ArcaneServiceProvider.of(context); - expect(provider.registeredServices, equals(testServices)); + expect(provider.registeredServices, containsAll(testServices)); + expect( + provider.registeredServices + .whereType(), + isNotEmpty, + ); + expect( + provider.registeredServices + .whereType(), + isNotEmpty, + ); + expect( + provider.registeredServices.whereType(), + isNotEmpty, + ); + expect( + provider.registeredServices + .whereType(), + isNotEmpty, + ); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("does not duplicate built-ins when explicitly provided", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: [Arcane.environment], + child: Builder( + builder: (context) { + final provider = ArcaneServiceProvider.of(context); + final environmentServices = provider.registeredServices + .whereType() + .toList(); + + expect(environmentServices.length, 1); + expect(environmentServices.single, same(Arcane.environment)); return const SizedBox(); }, ), @@ -95,6 +135,24 @@ void main() { ); }); + testWidgets("service prefers provider services over built-in fallbacks", + (tester) async { + final providerService = MockArcaneService(); + + await tester.pumpWidget( + ArcaneApp( + services: [providerService], + child: Builder( + builder: (context) { + final service = context.service(); + expect(service, same(providerService)); + return const SizedBox(); + }, + ), + ), + ); + }); + testWidgets( "requiredService extension returns correct service and throws when not found", (tester) async { diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index 9977632..7af45a6 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -14,6 +14,7 @@ void main() { // Initialize the service await ArcaneAuthenticationService.I.reset(); + Arcane.environment.reset(); when(() => authInterface.init()).thenAnswer((_) async {}); @@ -109,6 +110,30 @@ void main() { ); }); + testWidgets( + "setDebug and setNormal use Arcane.environment without provider ancestry", + (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ); + + await ArcaneAuthenticationService.I.setDebug(capturedContext); + expect(Arcane.environment.environment, Environment.debug); + + await ArcaneAuthenticationService.I.setNormal(capturedContext); + expect(Arcane.environment.environment, Environment.normal); + }, + ); + testWidgets("setNormal disables debug mode", (WidgetTester tester) async { late BuildContext capturedContext; diff --git a/test/services/feature_flags/feature_flags_provider_test.dart b/test/services/feature_flags/feature_flags_provider_test.dart new file mode 100644 index 0000000..a5f8a0f --- /dev/null +++ b/test/services/feature_flags/feature_flags_provider_test.dart @@ -0,0 +1,141 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +enum TestFeature { + alpha, + beta, +} + +void main() { + setUp(() { + Arcane.features.reset(); + }); + + testWidgets("ArcaneApp provides ArcaneFeatureFlagProvider", (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ArcaneApp( + child: Builder( + builder: (context) { + expect(ArcaneFeatureFlagProvider.maybeOf(context), isNotNull); + return const SizedBox(); + }, + ), + ), + ), + ); + }); + + testWidgets("feature flag updates trigger rebuilds for dependent widgets", + (tester) async { + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneApp( + child: Builder( + builder: (context) { + final scope = context.featureFlags; + buildCount++; + final bool enabled = scope.isEnabled(TestFeature.alpha); + return Text(enabled ? "enabled" : "disabled"); + }, + ), + ), + ), + ); + + expect(find.text("disabled"), findsOneWidget); + expect(buildCount, 1); + + Arcane.features.enableFeature(TestFeature.alpha); + await tester.pump(); + + expect(find.text("enabled"), findsOneWidget); + expect(buildCount, 2); + + Arcane.features.disableFeature(TestFeature.alpha); + await tester.pump(); + + expect(find.text("disabled"), findsOneWidget); + expect(buildCount, 3); + }); + + testWidgets("scope helper methods can enable and disable features", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ArcaneApp( + child: Builder( + builder: (context) { + final scope = context.featureFlags; + return Column( + children: [ + Text( + scope.isEnabled(TestFeature.beta) ? "on" : "off", + ), + TextButton( + onPressed: () => scope.enableFeature(TestFeature.beta), + child: const Text("enable"), + ), + TextButton( + onPressed: () => scope.disableFeature(TestFeature.beta), + child: const Text("disable"), + ), + ], + ); + }, + ), + ), + ), + ); + + expect(find.text("off"), findsOneWidget); + + await tester.tap(find.text("enable")); + await tester.pump(); + expect(find.text("on"), findsOneWidget); + + await tester.tap(find.text("disable")); + await tester.pump(); + expect(find.text("off"), findsOneWidget); + }); + + testWidgets("scope exposes notifier and stream for reactive consumers", + (tester) async { + late ArcaneFeatureFlagProvider scope; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneApp( + child: Builder( + builder: (context) { + scope = context.featureFlags; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(identical(scope.notifier, Arcane.features.notifier), true); + expect(scope.enabledFeaturesChanges, isA>>()); + }); + + testWidgets("context fallback helpers work without ArcaneFeatureFlagProvider", + (tester) async { + Arcane.features.enableFeature(TestFeature.alpha); + + await tester.pumpWidget( + Builder( + builder: (context) { + expect(context.maybeFeatureFlags, isNull); + expect(context.isFeatureEnabled(TestFeature.alpha), isTrue); + expect(context.isFeatureDisabled(TestFeature.beta), isTrue); + return const SizedBox(); + }, + ), + ); + }); +} diff --git a/test/services/feature_flags/feature_flags_service_test.dart b/test/services/feature_flags/feature_flags_service_test.dart index 88d60ba..c713f66 100644 --- a/test/services/feature_flags/feature_flags_service_test.dart +++ b/test/services/feature_flags/feature_flags_service_test.dart @@ -2,16 +2,16 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter_test/flutter_test.dart"; void main() { - group("ArcaneFeatureFlags", () { - late ArcaneFeatureFlags featureFlags; + group("ArcaneFeatureFlagService", () { + late ArcaneFeatureFlagService featureFlags; setUp(() { - featureFlags = ArcaneFeatureFlags.I; + featureFlags = ArcaneFeatureFlagService.I; Arcane.features.reset(); }); test("singleton instance is consistent", () { - expect(identical(ArcaneFeatureFlags.I, featureFlags), true); + expect(identical(ArcaneFeatureFlagService.I, featureFlags), true); }); group("feature management", () { From 5dd9c762e9b793e71757e2fdc1847e5dcb80dd01 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 15:46:56 +0200 Subject: [PATCH 37/58] chore(example): Add ignore rules for example builds --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 44ea6fe..e2a4f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ app.*.map.json **/android/app/profile **/android/app/release +# Example builds +**/example/.metadata +**/example/web/ \ No newline at end of file From eca94fce34f0ed4bc561ba9e65cc39d44b2f2d0b Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:03:16 +0200 Subject: [PATCH 38/58] feat(logging): Introduce log interceptors and origin control Introduces the LogInterceptor class, enabling pre-processing, modification, or suppression of log events before they reach registered interfaces. The LogInterceptorContext provides contextual information such as the originating LoggingInterface. Adds a `skipAutodetection` parameter to Arcane.log, allowing control over the automatic detection of log origin (module, method, file/line). Also makes the LogEvent class extendable to support custom event types and updates existing interceptor callback signatures for consistency. --- CHANGELOG.md | 6 ++ example/lib/main.dart | 14 +++- lib/src/services/logging/log_event.dart | 2 +- lib/src/services/logging/log_interceptor.dart | 61 +++++++++++++++-- .../logging/log_interceptor_test.dart | 65 +++++++++++++++++++ .../logging/logging_service_test.dart | 18 ++--- 6 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 test/services/logging/log_interceptor_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c5f0a..aa02a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,12 @@ - [NEW] Added optional `feature` tag to `LoggingInterface` via constructor. - [CHANGE] `initializeInterfaces()` now initializes only interfaces that implement `LoggingInitializable`; other interfaces are skipped. +- [NEW] Added a `skipAutodetection` parameter to `Arcane.log` (defaults to + `false`) that, when enabled, skips detection of the `module`, `method`, and + file/line number where logs originated from. +- [NEW] Added the `LogInterceptor` class which can (optionally) be added to + `ArcaneLogger` to pre-process log messages before they are sent to the + registered `ArcaneLoggingInterface`(s). #### Migration Steps (LoggingInterface) diff --git a/example/lib/main.dart b/example/lib/main.dart index 2f0e7b1..8125374 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,9 +6,21 @@ import "package:example/interfaces/debug_auth_interface.dart"; import "package:example/interfaces/debug_print_interface.dart"; import "package:example/services/favorite_color_service.dart"; import "package:example/theme/theme.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; +import "package:flutter/services.dart"; Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + if (kIsWeb) { + // Work around a Flutter web debug assertion where legacy raw key messages + // can arrive before key data and force an incompatible transit mode. + SystemChannels.keyEvent.setMessageHandler( + (_) async => {"handled": false}, + ); + } + final DebugPrint debugPrintInterface = DebugPrint(); final DebugAuthInterface debugAuthInterface = DebugAuthInterface(); @@ -21,7 +33,7 @@ Future main() async { await Arcane.logger.registerInterface( debugPrintInterface, interceptors: [ - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { if (context.interface is DebugPrint && Feature.logging.disabled) { return null; } diff --git a/lib/src/services/logging/log_event.dart b/lib/src/services/logging/log_event.dart index aabd2c5..a375329 100644 --- a/lib/src/services/logging/log_event.dart +++ b/lib/src/services/logging/log_event.dart @@ -1,6 +1,6 @@ part of "logging_service.dart"; -final class LogEvent { +class LogEvent { static const Object _sentinel = Object(); const LogEvent({ diff --git a/lib/src/services/logging/log_interceptor.dart b/lib/src/services/logging/log_interceptor.dart index 60930a8..3f291d3 100644 --- a/lib/src/services/logging/log_interceptor.dart +++ b/lib/src/services/logging/log_interceptor.dart @@ -1,25 +1,78 @@ part of "logging_service.dart"; +/// Provides contextual information for a [LogInterceptor] invocation. +/// +/// This context is passed to each log interceptor and can be used to provide +/// additional data or interfaces that may influence how log events are processed. +/// For example, it may contain a reference to the [LoggingInterface] that +/// originated the log event. +/// +/// Typically, you do not need to construct this class directly; it is created +/// and managed by the logging framework. +/// +/// See also: +/// - [LogInterceptor], which uses this context when intercepting log events. +/// - [LoggingInterface], which may be referenced by this context. + final class LogInterceptorContext { + /// Creates a new [LogInterceptorContext]. + /// + /// The [interface] parameter may be used to provide a reference to the + /// [LoggingInterface] that originated the log event, or may be null if not applicable. const LogInterceptorContext({ this.interface, }); + /// The [LoggingInterface] associated with this context, if any. + /// + /// This can be used by interceptors to access additional logging features or + /// metadata about the source of the log event. final LoggingInterface? interface; } +/// A function-like object that intercepts and optionally transforms log events. +/// +/// [LogInterceptor] allows you to observe, modify, or suppress log events as +/// they pass through the logging pipeline. You provide a callback that receives +/// each [LogEvent] and its [LogInterceptorContext], and returns either a new +/// (possibly modified) [LogEvent], or `null` to suppress the event. +/// +/// Example usage: +/// ```dart +/// final interceptor = LogInterceptor((event, {context}) { +/// // Filter out debug-level logs +/// if (event.level == LogLevel.debug) return null; +/// return event; +/// }); +/// ``` +/// +/// See also: +/// - [LogEvent], which represents a log entry. +/// - [LogInterceptorContext], which provides context for the interception. class LogInterceptor { + /// Creates a [LogInterceptor] with the given callback. + /// + /// The [_callback] function will be invoked for each log event, with the + /// event and its context. Return a [LogEvent] to continue processing, or + /// `null` to suppress the event. const LogInterceptor(this._callback); + /// The callback function that processes each log event. + /// + /// The function receives the [event] and its [context], and should return + /// either a (possibly modified) [LogEvent], or `null` to suppress the event. final LogEvent? Function( - LogEvent event, { - required LogInterceptorContext context, - }) _callback; + LogEvent event, + LogInterceptorContext context, + ) _callback; + /// Invokes the interceptor on the given [event] and [context]. + /// + /// Returns the (possibly modified) [LogEvent], or `null` to suppress the event. LogEvent? call( LogEvent event, { required LogInterceptorContext context, }) { - return _callback(event, context: context); + return _callback(event, context); } } diff --git a/test/services/logging/log_interceptor_test.dart b/test/services/logging/log_interceptor_test.dart new file mode 100644 index 0000000..7d431ec --- /dev/null +++ b/test/services/logging/log_interceptor_test.dart @@ -0,0 +1,65 @@ +import "package:arcane_framework/src/services/logging/logging_service.dart"; +import "package:flutter_test/flutter_test.dart"; + +class DummyLogEvent extends LogEvent { + DummyLogEvent({required super.level, required super.message}); +} + +class DummyLoggingInterface extends LoggingInterface { + const DummyLoggingInterface() : super(); + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) {} +} + +void main() { + group("LogInterceptor", () { + test("calls the callback and returns the event unchanged", () { + final interceptor = LogInterceptor((event, context) => event); + final event = DummyLogEvent(level: Level.info, message: "test"); + const context = LogInterceptorContext(); + final result = interceptor(event, context: context); + expect(result, equals(event)); + }); + + test("can modify the event", () { + final interceptor = LogInterceptor((event, context) { + return DummyLogEvent(level: Level.warning, message: event.message); + }); + final event = DummyLogEvent(level: Level.info, message: "test"); + const context = LogInterceptorContext(); + final result = interceptor(event, context: context); + expect(result, isA()); + expect(result!.level, Level.warning); + expect(result.message, "test"); + }); + + test("can suppress the event by returning null", () { + final interceptor = LogInterceptor((event, context) => null); + final event = DummyLogEvent(level: Level.info, message: "test"); + const context = LogInterceptorContext(); + final result = interceptor(event, context: context); + expect(result, isNull); + }); + + test("receives the correct context", () { + const dummyInterface = DummyLoggingInterface(); + LogInterceptorContext? receivedContext; + final interceptor = LogInterceptor((event, context) { + receivedContext = context; + return event; + }); + final event = DummyLogEvent(level: Level.info, message: "test"); + const context = LogInterceptorContext(interface: dummyInterface); + interceptor(event, context: context); + expect(receivedContext, isNotNull); + expect(receivedContext!.interface, dummyInterface); + }); + }); +} diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart index 48361ae..62f663e 100644 --- a/test/services/logging/logging_service_test.dart +++ b/test/services/logging/logging_service_test.dart @@ -89,7 +89,7 @@ void main() { Arcane.logger.reset(); myInterface = TestLoggingInterface("primary"); prefixInterceptor = LogInterceptor( - (event, {required context}) { + (event, context) { return event.copyWith(message: "[global] ${event.message}"); }, ); @@ -208,7 +208,7 @@ void main() { test("interface interceptors can be registered at runtime", () async { final LogInterceptor dropForPrimary = LogInterceptor( - (event, {required LogInterceptorContext context}) { + (event, context) { if ((context.interface as TestLoggingInterface).name == "primary") { return null; } @@ -315,11 +315,11 @@ void main() { test("global interceptors run in registration order", () async { Arcane.logger.registerInterceptors([ - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { expect(context.interface, same(myInterface)); return event.copyWith(message: "${event.message}:first"); }), - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { expect(context.interface, same(myInterface)); return event.copyWith(message: "${event.message}:second"); }), @@ -333,7 +333,7 @@ void main() { test("global interceptors can drop events for all interfaces", () async { await Arcane.logger.registerInterface(myInterface); Arcane.logger.registerInterceptor( - LogInterceptor((event, {required context}) => null), + LogInterceptor((event, context) => null), ); Arcane.log(logMessage); @@ -359,7 +359,7 @@ void main() { final TestLoggingInterface secondaryInterface = TestLoggingInterface("secondary"); final LogInterceptor allowPrimaryOnly = LogInterceptor( - (event, {required LogInterceptorContext context}) { + (event, context) { final String name = (context.interface as TestLoggingInterface).name; return name == "primary" ? event : null; @@ -382,7 +382,7 @@ void main() { TestLoggingInterface("secondary"); Arcane.logger.registerInterceptor( - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { final TestLoggingInterface currentInterface = context.interface! as TestLoggingInterface; return event.copyWith( @@ -412,7 +412,7 @@ void main() { TestLoggingInterface("secondary"); Arcane.logger.registerInterceptor( - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { event.metadata?["mutatedBy"] = (context.interface as TestLoggingInterface).name; return event; @@ -444,7 +444,7 @@ void main() { await Arcane.logger.registerInterface( myInterface, interceptors: [ - LogInterceptor((event, {required context}) => null), + LogInterceptor((event, context) => null), ], ); From 3a6fd44fb34bfe94a2f725aca471979204ae5040 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:06:24 +0200 Subject: [PATCH 39/58] fix(theme): Update theme mode check to use currentModeOf method Signed-off-by: Hans Kokx --- example/lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 8125374..019ee73 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -315,7 +315,7 @@ class ArcaneThemeExample extends StatelessWidget { Column( children: [ Switch( - value: Arcane.theme.currentThemeMode == ThemeMode.dark, + value: Arcane.theme.currentModeOf(context) == ThemeMode.dark, thumbIcon: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return const Icon(Icons.dark_mode); From a91c5d7ecf2e1b40c4b47d1f67950a1951992198 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:17:40 +0200 Subject: [PATCH 40/58] fix(theme): Improve theme mode detection and toggling `context.isDarkMode` now reflects the app's effective theme brightness, not the raw platform brightness. This ensures `isDarkMode` accurately reports the currently rendered theme. `switchTheme()` now intelligently toggles from the *effective* theme when in `ThemeMode.system` by considering the current app brightness. `followSystemTheme()` is updated to always read the platform brightness directly, decoupling its behavior from the app's overridden theme settings. --- CHANGELOG.md | 6 ++++ example/lib/main.dart | 2 +- lib/src/services/theme/theme_extensions.dart | 14 ++++---- lib/src/services/theme/theme_service.dart | 17 +++++++-- .../reactive_theme_service_test.dart | 36 +++++++++++++++++++ 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa02a0a..545f152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,12 @@ `ThemeData` using the effective brightness. - [FIX] `ArcaneThemeSwitcher` now initializes theme state once via `setInitialTheme(context)`. +- [FIX] `switchTheme()` now toggles from the effective theme when current mode + is `ThemeMode.system` (system dark -> light, system light -> dark). +- [CHANGE] `context.isDarkMode` now reflects effective app theme brightness + (`Theme.of(context).brightness`) instead of raw platform brightness. +- [FIX] `followSystemTheme()` now reads platform brightness directly to avoid + coupling system-follow behavior to app theme overrides. - [FIX] Reactive theme stream controllers now close only during service dispose, preventing stream shutdown when a single subscriber cancels. - [UPDATE] README now documents `ArcaneThemeService` naming. diff --git a/example/lib/main.dart b/example/lib/main.dart index 019ee73..ff477ac 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -315,7 +315,7 @@ class ArcaneThemeExample extends StatelessWidget { Column( children: [ Switch( - value: Arcane.theme.currentModeOf(context) == ThemeMode.dark, + value: context.isDarkMode, thumbIcon: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return const Icon(Icons.dark_mode); diff --git a/lib/src/services/theme/theme_extensions.dart b/lib/src/services/theme/theme_extensions.dart index 5d3a211..575bd2b 100644 --- a/lib/src/services/theme/theme_extensions.dart +++ b/lib/src/services/theme/theme_extensions.dart @@ -3,22 +3,24 @@ import "package:flutter/material.dart"; import "arcane_theme.dart"; import "theme_service.dart"; -/// An extension on `BuildContext` to check the current system dark mode setting. +/// An extension on `BuildContext` to check the current effective dark mode. /// -/// This extension provides a convenient way to check whether the device is in dark mode. +/// This extension provides a convenient way to check whether the active +/// `ThemeData` is dark in the current context. extension DarkMode on BuildContext { - /// Returns `true` if the system is currently set to dark mode. + /// Returns `true` if the current effective theme is dark. /// - /// This uses `MediaQuery.of(this).platformBrightness` to check the system's brightness setting. + /// This uses `Theme.of(this).brightness`, so it reflects the app's active + /// rendered theme rather than raw platform brightness. /// /// Example: /// ```dart /// if (context.isDarkMode) { - /// // The system is in dark mode. + /// // The active app theme is dark. /// } /// ``` bool get isDarkMode { - final brightness = MediaQuery.platformBrightnessOf(this); + final brightness = Theme.of(this).brightness; return brightness == Brightness.dark; } } diff --git a/lib/src/services/theme/theme_service.dart b/lib/src/services/theme/theme_service.dart index e8ee32c..acfb66a 100644 --- a/lib/src/services/theme/theme_service.dart +++ b/lib/src/services/theme/theme_service.dart @@ -139,8 +139,9 @@ class ArcaneThemeService extends ArcaneService { if (themeMode != null) { _updateTheme(themeMode); } else { + final ThemeMode effectiveMode = _effectiveThemeMode; _updateTheme( - currentThemeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark, + effectiveMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark, ); } @@ -163,7 +164,9 @@ class ArcaneThemeService extends ArcaneService { _followingSystemTheme = true; _currentSystemThemeMode = - context.isDarkMode ? ThemeMode.dark : ThemeMode.light; + MediaQuery.platformBrightnessOf(context) == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; _systemController.add(_currentSystemThemeMode); _updateTheme(_currentSystemThemeMode); @@ -259,4 +262,14 @@ class ArcaneThemeService extends ArcaneService { _currentThemeMode = themeMode; _themeModeController.add(themeMode); } + + ThemeMode get _effectiveThemeMode { + if (_currentThemeMode != ThemeMode.system) { + return _currentThemeMode; + } + + return _currentTheme.brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; + } } diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart index 28f57ed..43485d3 100644 --- a/test/services/reactive_theme/reactive_theme_service_test.dart +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -28,6 +28,42 @@ void main() { expect(theme.currentThemeMode, equals(ThemeMode.light)); }); + testWidgets( + "switchTheme toggles from effective system mode, not always to dark", + (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: ArcaneApp( + child: SizedBox(), + ), + ), + ); + + final BuildContext darkContext = tester.element(find.byType(SizedBox)); + theme.switchTheme(themeMode: ThemeMode.system); + theme.setInitialTheme(darkContext); + theme.switchTheme(); + + expect(theme.currentThemeMode, equals(ThemeMode.light)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.light), + child: ArcaneApp( + child: SizedBox(), + ), + ), + ); + + final BuildContext lightContext = tester.element(find.byType(SizedBox)); + theme.switchTheme(themeMode: ThemeMode.system); + theme.setInitialTheme(lightContext); + theme.switchTheme(); + + expect(theme.currentThemeMode, equals(ThemeMode.dark)); + }); + test("switching theme notifies theme mode stream", () async { ThemeMode? emittedMode; final subscription = theme.themeModeChanges.listen((mode) { From 1dd92b6ac69d505d612c9bbe5e891be8a9ea62ae Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:38:42 +0200 Subject: [PATCH 41/58] feat(example): Improve theme and service integration demo Updates the example application to better demonstrate service and theme integration. The `FavoriteColorService` now actively sets the app's color scheme and can initialize its state from the current theme. Environment display is now reactive, and the UI responds dynamically to service registration and removal. Initial themes are now explicitly seeded. --- example/lib/main.dart | 280 ++++++++---------- .../lib/services/favorite_color_service.dart | 64 +++- example/lib/theme/theme.dart | 13 +- 3 files changed, 186 insertions(+), 171 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index ff477ac..5aa6382 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -376,70 +376,6 @@ class ArcaneThemeExample extends StatelessWidget { ), ], ), - SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8, - children: [ - const Text("Color"), - Expanded( - child: StreamBuilder( - stream: Arcane.theme.themeDataChanges, - builder: (context, themeData) => ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - if (context.themeMode == ThemeMode.dark) { - Arcane.theme.setDarkTheme( - ThemeData( - brightness: Brightness.dark, - colorSchemeSeed: colors[index], - ), - ); - } else if (context.themeMode == ThemeMode.light) { - Arcane.theme.setLightTheme( - ThemeData( - brightness: Brightness.light, - colorSchemeSeed: colors[index], - ), - ); - } - - Arcane.log( - "Setting ${Arcane.theme.currentThemeMode.name} theme color to ${colors[index].name}", - ); - }, - child: StreamBuilder( - stream: Arcane.theme.themeModeChanges, - builder: (context, themeMode) { - return Container( - key: - Key("${colors[index]}-${themeMode.data}"), - decoration: BoxDecoration( - color: colors[index], - border: themeData.data?.colorScheme.primary - .name == - colors[index].name - ? Border.all(width: 2) - : null, - ), - width: 20, - height: 20, - ); - }, - ), - ); - }, - ), - ), - ), - ], - ), - ), Text( "The current theme mode is ${Arcane.theme.currentModeOf(context).name} and " "is ${Arcane.theme.isFollowingSystemTheme ? "" : "not "}" @@ -559,9 +495,14 @@ class ArcaneEnvironmentExample extends StatelessWidget { }, child: const Text("Cycle environment"), ), - Text( - "Environment: ${Arcane.environment.environment.name}", - textAlign: TextAlign.center, + ValueListenableBuilder( + valueListenable: Arcane.environment.notifier, + builder: (context, environment, _) { + return Text( + "Environment: ${environment.name}", + textAlign: TextAlign.center, + ); + }, ), ], ), @@ -581,105 +522,122 @@ class ArcaneServicesExample extends StatelessWidget { @override Widget build(BuildContext context) { - final FavoriteColorService? service = - ArcaneServiceProvider.serviceOfType(context); - final ValueNotifier notifier = - service?.notifier ?? ValueNotifier(null); - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, color, _) { - return Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Services", - style: Theme.of(context).textTheme.headlineSmall, - ), - Text( - color != null ? "Favorite color: ${color.name}" : "", - ), - ElevatedButton( - onPressed: () { - if (service == null) { - ArcaneServiceProvider.of(context).addService( - FavoriteColorService.I, - ); - - Arcane.log( - "Service registered.", - metadata: {"service": "FavoriteColorService"}, - ); - } else { - ArcaneServiceProvider.of(context) - .removeService(); - - Arcane.log( - "Service removed.", - metadata: {"service": "FavoriteColorService"}, - ); - } - }, - child: Text( - '${service == null ? 'Register' : 'Remove'} service', + final ArcaneServiceProvider serviceProvider = ArcaneServiceProvider.of( + context, + ); + + return ValueListenableBuilder>( + valueListenable: serviceProvider.notifier!, + builder: (context, _, __) { + final FavoriteColorService? service = + ArcaneServiceProvider.serviceOfType(context); + + Widget buildCard(MaterialColor? color) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Services", + style: Theme.of(context).textTheme.headlineSmall, ), - ), - SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8, - children: [ - const Text("Color"), - Expanded( - child: ListView.separated( - itemCount: colors.length, - scrollDirection: Axis.horizontal, - separatorBuilder: (_, __) => const SizedBox(width: 4), - itemBuilder: (context, index) { - return InkWell( - onTap: () { - if (service == null) { - Arcane.log( - "FavoriteColorService is not registered", - ); - return; - } - - service.setMyFavoriteColor(colors[index]); - Arcane.log( - "Set a color in FavoriteColorService", - metadata: { - "color": colors[index].name ?? "Unknown", - }, - ); - }, - child: Container( - decoration: BoxDecoration( - color: colors[index], - border: color?.name == colors[index].name - ? Border.all(width: 2) - : null, + Text( + color != null ? "Favorite color: ${color.name}" : "", + ), + ElevatedButton( + onPressed: () { + if (service == null) { + final FavoriteColorService nextService = + FavoriteColorService() + ..syncFromCurrentTheme(colors); + + serviceProvider.addService( + nextService, + ); + + Arcane.log( + "Service registered.", + metadata: {"service": "FavoriteColorService"}, + ); + } else { + serviceProvider.removeService(); + + Arcane.log( + "Service removed.", + metadata: {"service": "FavoriteColorService"}, + ); + } + }, + child: Text( + '${service == null ? 'Register' : 'Remove'} service', + ), + ), + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + const Text("Color"), + Expanded( + child: ListView.separated( + itemCount: colors.length, + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => + const SizedBox(width: 4), + itemBuilder: (context, index) { + return Opacity( + opacity: service == null ? 0.4 : 1, + child: InkWell( + onTap: service == null + ? null + : () { + service.setMyFavoriteColor( + colors[index], + ); + Arcane.log( + "Set a color in FavoriteColorService", + metadata: { + "color": colors[index].name ?? + "Unknown", + }, + ); + }, + child: Container( + decoration: BoxDecoration( + color: colors[index], + border: color == colors[index] + ? Border.all(width: 2) + : null, + ), + width: 20, + height: 20, + ), ), - width: 20, - height: 20, - ), - ); - }, + ); + }, + ), ), - ), - ], + ], + ), ), - ), - Text( - "Service is ${service != null ? "" : "not "}registered", - ), - ], + Text( + "Service is ${service != null ? "" : "not "}registered", + ), + ], + ), ), - ), + ); + } + + if (service == null) return buildCard(null); + + return ValueListenableBuilder( + valueListenable: service.notifier, + builder: (context, color, _) => buildCard(color), ); }, ); diff --git a/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart index ac838cc..56ac6de 100644 --- a/example/lib/services/favorite_color_service.dart +++ b/example/lib/services/favorite_color_service.dart @@ -2,12 +2,7 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/material.dart"; class FavoriteColorService extends ArcaneService { - static final FavoriteColorService _instance = - FavoriteColorService._internal(); - - static FavoriteColorService get I => _instance; - - FavoriteColorService._internal(); + FavoriteColorService(); MaterialColor? get myFavoriteColor => _notifier.value; @@ -17,9 +12,62 @@ class FavoriteColorService extends ArcaneService { ValueNotifier get notifier => _notifier; void setMyFavoriteColor(MaterialColor? newValue) { - if (_notifier.value != newValue) { - _notifier.value = newValue; + if (_notifier.value == newValue) return; + + _notifier.value = newValue; + + if (newValue == null) return; + + // Apply the seed to whichever theme is currently being rendered. + final bool isUsingDarkTheme = + Arcane.theme.currentTheme.brightness == Brightness.dark; + if (isUsingDarkTheme) { + Arcane.theme.setDarkTheme( + ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: newValue, + ), + ); + } else { + Arcane.theme.setLightTheme( + ThemeData( + brightness: Brightness.light, + colorSchemeSeed: newValue, + ), + ); + } + } + + void syncFromCurrentTheme(Iterable palette) { + final Iterator iterator = palette.iterator; + if (!iterator.moveNext()) { + _notifier.value = null; + return; } + + final Color target = Arcane.theme.currentTheme.colorScheme.primary; + MaterialColor closest = iterator.current; + double closestDistance = _colorDistanceSquared(closest, target); + + while (iterator.moveNext()) { + final MaterialColor candidate = iterator.current; + final double distance = _colorDistanceSquared(candidate, target); + if (distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + + if (_notifier.value != closest) { + _notifier.value = closest; + } + } + + double _colorDistanceSquared(Color a, Color b) { + final double dr = a.r - b.r; + final double dg = a.g - b.g; + final double db = a.b - b.b; + return (dr * dr) + (dg * dg) + (db * db); } } diff --git a/example/lib/theme/theme.dart b/example/lib/theme/theme.dart index 0491b2b..d9f05f3 100644 --- a/example/lib/theme/theme.dart +++ b/example/lib/theme/theme.dart @@ -1,4 +1,13 @@ import "package:flutter/material.dart"; -final ThemeData darkTheme = ThemeData.dark(); -final ThemeData lightTheme = ThemeData.light(); +const MaterialColor defaultSeedColor = Colors.blue; + +final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: defaultSeedColor, +); + +final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + colorSchemeSeed: defaultSeedColor, +); From c7767e5a0fc7aaa91768abc0450b9a5dee3bc780 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:43:22 +0200 Subject: [PATCH 42/58] feat(example): Add interactive persistent metadata toggle Previously, persistent metadata was added unconditionally. This change allows users to dynamically enable or disable the demo persistent metadata via a UI switch, better showcasing the API functionality. --- example/lib/main.dart | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5aa6382..46a9dcd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -43,11 +43,6 @@ Future main() async { ], ); - // Add some persistent metadata to be used in every future log message - Arcane.logger.addPersistentMetadata({ - "demo": "This message will be included in all log messages.", - }); - // Register the authentication interface await Arcane.auth.registerInterface(debugAuthInterface); @@ -141,6 +136,10 @@ class ArcaneLoggingExample extends StatefulWidget { } class _ArcaneLoggingExampleState extends State { + static const String _demoMetadataKey = "demo"; + static const String _demoMetadataValue = + "This message will be included in all log messages."; + // Set up a subscriber that we can use to listen to logs in realtime. // Note: this is completely optional and does _not_ impact whether logs are // sent to any registered logging interfaces. @@ -148,6 +147,7 @@ class _ArcaneLoggingExampleState extends State { // Used to collect the logs from the stream. final List latestLogs = []; + bool _persistentMetadataEnabled = false; @override void initState() { @@ -190,6 +190,32 @@ class _ArcaneLoggingExampleState extends State { "Logging", style: Theme.of(context).textTheme.headlineSmall, ), + Row( + spacing: 8, + children: [ + const Text("Include persistent demo metadata"), + Switch( + value: _persistentMetadataEnabled, + onChanged: Feature.logging.disabled + ? null + : (enabled) { + setState(() { + _persistentMetadataEnabled = enabled; + }); + + if (enabled) { + Arcane.logger.addPersistentMetadata({ + _demoMetadataKey: _demoMetadataValue, + }); + } else { + Arcane.logger.removePersistentMetadata( + _demoMetadataKey, + ); + } + }, + ), + ], + ), if (latestLogs.isEmpty) Text( "Log messages will appear here", From 50c07d407410a7a3ba05bb0ae394d4c41377d1a8 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:45:11 +0200 Subject: [PATCH 43/58] feat(theme): Add assignment-style theme setters Introduces convenience setters for dark and light themes, allowing `Arcane.theme.dark = ...` and `Arcane.theme.light = ...` as an alternative to `setDarkTheme` and `setLightTheme`. This improves developer ergonomics and conciseness. --- CHANGELOG.md | 3 +++ README.md | 6 ++++++ lib/src/services/theme/theme_service.dart | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545f152..601bc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,9 @@ (`Theme.of(context).brightness`) instead of raw platform brightness. - [FIX] `followSystemTheme()` now reads platform brightness directly to avoid coupling system-follow behavior to app theme overrides. +- [NEW] Added assignment-style theme setters: `Arcane.theme.dark = ...` and + `Arcane.theme.light = ...` (in addition to `setDarkTheme` / + `setLightTheme`). - [FIX] Reactive theme stream controllers now close only during service dispose, preventing stream shutdown when a single subscriber cancels. - [UPDATE] README now documents `ArcaneThemeService` naming. diff --git a/README.md b/README.md index f56a2b1..ae0d812 100644 --- a/README.md +++ b/README.md @@ -903,8 +903,14 @@ if (context.isDarkMode) { // Set a custom dark theme Arcane.theme.setDarkTheme(customDarkTheme); +// Equivalent assignment-style setter +Arcane.theme.dark = customDarkTheme; + // Set a custom light theme Arcane.theme.setLightTheme(customLightTheme); + +// Equivalent assignment-style setter +Arcane.theme.light = customLightTheme; ``` You can subscribe to theme streams to react to theme updates outside of widget diff --git a/lib/src/services/theme/theme_service.dart b/lib/src/services/theme/theme_service.dart index acfb66a..85ec778 100644 --- a/lib/src/services/theme/theme_service.dart +++ b/lib/src/services/theme/theme_service.dart @@ -103,6 +103,11 @@ class ArcaneThemeService extends ArcaneService { /// widget rebuilds. Use [darkTheme] when you need reactive updates. ThemeData get dark => _darkTheme.value; + /// Sets a custom dark `ThemeData`. + /// + /// This is a convenience setter that delegates to [setDarkTheme]. + set dark(ThemeData theme) => setDarkTheme(theme); + /// ValueNotifier for the dark theme that can be observed for changes. ValueNotifier get darkTheme => I._darkTheme; final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); @@ -113,6 +118,11 @@ class ArcaneThemeService extends ArcaneService { /// widget rebuilds. Use [lightTheme] when you need reactive updates. ThemeData get light => _lightTheme.value; + /// Sets a custom light `ThemeData`. + /// + /// This is a convenience setter that delegates to [setLightTheme]. + set light(ThemeData theme) => setLightTheme(theme); + /// ValueNotifier for the light theme that can be observed for changes. ValueNotifier get lightTheme => I._lightTheme; final ValueNotifier _lightTheme = ValueNotifier(ThemeData.light()); From bbef7be53b4de3ec6d7407bf27b753bad99a77be Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 16:45:21 +0200 Subject: [PATCH 44/58] docs(README): Add Application Environments section to features list Signed-off-by: Hans Kokx --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae0d812..197e4d5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ and service management. - [Feature Flags](#feature-flags) - [Logging](#logging) - [Authentication](#authentication) + - [Application Environments](#application-environments) - [Dynamic Theming](#dynamic-theming) - [Contributing](#contributing) From 101f21e19255303e629d90aa36fc89afbe771820 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 17:23:46 +0200 Subject: [PATCH 45/58] Adds `ArcaneApp.builder` to provide a provider-aware `BuildContext` for the application root. This deprecates the `ArcaneApp.child` parameter, improving developer experience for accessing Arcane's providers at build time. Updates the `ArcaneThemeSwitcher` to default to `followSystemTheme` when mounted under `ArcaneApp`, ensuring system theme behavior is enabled out of the box. Fixes `setDarkTheme` and `setLightTheme` to only update the rendered theme if their respective modes are currently active. This prevents unintended UI changes when updating the definition of an inactive theme. Updates README and examples to reflect the new `builder` API and clarified theme logic. Adds new tests for these changes. --- CHANGELOG.md | 13 +++- README.md | 53 +++++++++++++-- example/lib/main.dart | 55 ++++++---------- .../lib/services/favorite_color_service.dart | 31 ++++----- lib/src/arcane_app.dart | 66 ++++++++++++++++--- lib/src/services/theme/theme_service.dart | 16 +++-- lib/src/services/theme/theme_switcher.dart | 4 +- test/providers/service_provider_test.dart | 40 +++++++++++ .../theme_service_regression_test.dart | 50 ++++++++++++++ 9 files changed, 253 insertions(+), 75 deletions(-) create mode 100644 test/services/reactive_theme/theme_service_regression_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 601bc5b..2b49624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - [CHANGE] `Arcane.features`, `Arcane.auth`, `Arcane.theme`, and `Arcane.environment` now prefer the live `ArcaneApp` registry instance when available, then fall back to built-in singletons. +- [NEW] Added optional `ArcaneApp.builder` callback (TransitionBuilder style) + for capturing provider-aware build contexts from within `ArcaneApp`. +- [DEPRECATED] `ArcaneApp.child` is now deprecated in favor of + `ArcaneApp.builder` (legacy child usage remains supported during migration). +- [UPDATE] README and examples now show `ArcaneApp.builder` as the preferred + integration path, with migration guidance from `child`. ### Environment Service @@ -58,6 +64,9 @@ `ThemeData` using the effective brightness. - [FIX] `ArcaneThemeSwitcher` now initializes theme state once via `setInitialTheme(context)`. +- [FIX] `ArcaneThemeSwitcher` now defaults to `followSystemTheme(context)` when + mounted under `ArcaneApp`, so system-follow is enabled by default and system + brightness changes are handled framework-side (no app-level observer needed). - [FIX] `switchTheme()` now toggles from the effective theme when current mode is `ThemeMode.system` (system dark -> light, system light -> dark). - [CHANGE] `context.isDarkMode` now reflects effective app theme brightness @@ -69,7 +78,9 @@ `setLightTheme`). - [FIX] Reactive theme stream controllers now close only during service dispose, preventing stream shutdown when a single subscriber cancels. -- [UPDATE] README now documents `ArcaneThemeService` naming. +- [FIX] Setting a theme (e.g., dark) while in the opposite mode (e.g., light) no + longer changes the current brightness or rendered theme. Only the active + mode's theme updates the rendered appearance. ### Arcane Logger diff --git a/README.md b/README.md index 197e4d5..4743c48 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ and service management. - [Arcane Framework](#arcane-framework) - [Features](#features) - [Installation](#installation) + - [ArcaneApp Builder Migration (v1.x -\> v2.x)](#arcaneapp-builder-migration-v1x---v2x) - [Usage](#usage) - [Services](#services) - [Defining an example `ArcaneService`](#defining-an-example-arcaneservice) @@ -63,12 +64,50 @@ To use Arcane Framework in your Dart or Flutter project, follow these steps: void main() { runApp( ArcaneApp( - child: MainApp(), + builder: (context, _) => MainApp(), ), ); } ``` + `ArcaneApp.child` remains available for backward compatibility, but is + deprecated in favor of `ArcaneApp.builder`. + +### ArcaneApp Builder Migration (v1.x -> v2.x) + +Arcane now prefers `ArcaneApp.builder` over `ArcaneApp.child`. + +Why this is better: + +- Your app root is built with Arcane providers already in scope. +- You can access Arcane-backed context values immediately at app-root build + time. +- You no longer need an extra `Builder` wrapper just to capture provider-aware + context. + +When to use each API: + +| Situation | Recommended API | Why | +| ---------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | +| New app code | `ArcaneApp.builder` | Preferred, future-facing API. | +| Root widget needs Arcane-aware `BuildContext` during construction | `ArcaneApp.builder` | Context is captured inside Arcane's provider tree. | +| Existing app already uses `child` and you want minimal churn right now | `ArcaneApp.child` (deprecated) | Still supported for compatibility while you migrate. | +| You only need to pass through a static root widget | `ArcaneApp.builder` | Keeps usage consistent with migration target and avoids later refactors. | + +Migration example: + +```dart +// Before (deprecated) +ArcaneApp( + child: MainApp(), +) + +// After (preferred) +ArcaneApp( + builder: (context, _) => MainApp(), +) +``` + ## Usage The following sections provide more information about how to use the package's @@ -145,7 +184,7 @@ ArcaneApp( services: [ FavoriteColorService.I, ], - child: MainApp(), + builder: (context, _) => MainApp(), ), ``` @@ -156,7 +195,7 @@ already includes `ArcaneServiceProvider`) to be in your widget tree. ```dart // The service is not included at compile-time ArcaneApp( - child: MainApp(), + builder: (context, _) => MainApp(), ), // Add the service at runtime @@ -279,7 +318,7 @@ void main() { runApp( ArcaneApp( - child: MainApp(), + builder: (context, _) => MainApp(), ), ); } @@ -829,7 +868,7 @@ void main() { runApp( ArcaneApp( - child: MainApp(), + builder: (context, _) => MainApp(), ), ); } @@ -856,7 +895,7 @@ class _MainAppState extends State { @override Widget build(BuildContext context) { return ArcaneApp( - child: MaterialApp( + builder: (context, _) => MaterialApp( theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, themeMode: Arcane.theme.currentModeOf(context), @@ -876,7 +915,7 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return ArcaneApp( - child: MaterialApp( + builder: (context, _) => MaterialApp( theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, themeMode: Arcane.theme.currentModeOf(context), diff --git a/example/lib/main.dart b/example/lib/main.dart index 46a9dcd..10a4cbe 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -71,8 +71,8 @@ Future main() async { runApp( // The `ArcaneApp` widget is optional but provides Arcane's built-in // service, feature flag, environment, and theme integration widgets. - const ArcaneApp( - child: MainApp(), + ArcaneApp( + builder: (context, _) => const MainApp(), ), ); } @@ -83,13 +83,8 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - // Use the light and dark theme objects registered in Arcane. If either style is - // updated in Arcane, the changes will reflect here. This allows for on-the-fly - // customizations without requiring compile-time themes to be pre-defined. theme: Arcane.theme.light, darkTheme: Arcane.theme.dark, - // By fetching the current ThemeMode from Arcane, the app will automatically rebuild - // when the theme is switched, either manually or automatically. themeMode: Arcane.theme.currentModeOf(context), home: Scaffold( appBar: AppBar( @@ -313,7 +308,6 @@ class ArcaneAuthExample extends StatelessWidget { } // * Theme -// Arcane enables easy, dynamic theme switching. Themes can be switched // at any time between light mode and dark mode, or set to follow the // system theme. In addition, themes can be swapped out on-the-fly, // enabling dynamic customizations and remote theme fetching. @@ -349,15 +343,17 @@ class ArcaneThemeExample extends StatelessWidget { return const Icon(Icons.light_mode); }), onChanged: (_) { - final ThemeMode oldTheme = Arcane.theme.currentThemeMode; - Arcane.theme.switchTheme(); + // Always disable system mode and flip to the opposite of the effective mode + Arcane.theme.switchTheme( + themeMode: + context.isDarkMode ? ThemeMode.light : ThemeMode.dark, + ); Arcane.log( "Switching theme", metadata: { "followingSystemTheme": "${Arcane.theme.isFollowingSystemTheme}", "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, }, ); }, @@ -368,33 +364,24 @@ class ArcaneThemeExample extends StatelessWidget { Checkbox( value: Arcane.theme.isFollowingSystemTheme, onChanged: (value) { - final ThemeMode oldTheme = - Arcane.theme.currentThemeMode; if (value == true) { Arcane.theme.followSystemTheme(context); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); } else { - Arcane.theme.switchTheme( - themeMode: Arcane.theme.systemThemeMode, - ); - Arcane.log( - "Switching theme", - metadata: { - "followingSystemTheme": - "${Arcane.theme.isFollowingSystemTheme}", - "newMode": Arcane.theme.currentThemeMode.name, - "oldMode": oldTheme.name, - }, - ); + // When unchecking, set mode to the effective mode (not system) + final ThemeMode effective = + Theme.of(context).brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; + Arcane.theme.switchTheme(themeMode: effective); } + Arcane.log( + "Switching theme", + metadata: { + "followingSystemTheme": + "${Arcane.theme.isFollowingSystemTheme}", + "newMode": Arcane.theme.currentThemeMode.name, + }, + ); }, ), const Text("Follow system"), diff --git a/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart index 56ac6de..13e6f25 100644 --- a/example/lib/services/favorite_color_service.dart +++ b/example/lib/services/favorite_color_service.dart @@ -18,24 +18,19 @@ class FavoriteColorService extends ArcaneService { if (newValue == null) return; - // Apply the seed to whichever theme is currently being rendered. - final bool isUsingDarkTheme = - Arcane.theme.currentTheme.brightness == Brightness.dark; - if (isUsingDarkTheme) { - Arcane.theme.setDarkTheme( - ThemeData( - brightness: Brightness.dark, - colorSchemeSeed: newValue, - ), - ); - } else { - Arcane.theme.setLightTheme( - ThemeData( - brightness: Brightness.light, - colorSchemeSeed: newValue, - ), - ); - } + // Apply the seed to both themes so switching mode keeps the same color + // family. + Arcane.theme.setLightTheme( + ThemeData( + brightness: Brightness.light, + colorSchemeSeed: newValue, + ), + ); + + Arcane.theme.dark = ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: newValue, + ); } void syncFromCurrentTheme(Iterable palette) { diff --git a/lib/src/arcane_app.dart b/lib/src/arcane_app.dart index 9a50eee..2b92fb8 100644 --- a/lib/src/arcane_app.dart +++ b/lib/src/arcane_app.dart @@ -13,29 +13,73 @@ import "services/theme/theme_switcher.dart"; /// settings throughout the widget tree using the `ArcaneServiceProvider` and /// `ArcaneEnvironmentProvider`. /// -/// This widget wraps the provided [child] widget with the necessary providers -/// to make the Arcane services available to all descendant widgets. +/// This widget wraps your app root with Arcane's built-in providers so +/// descendant widgets can access services, environment, feature flags, and +/// theme updates. +/// +/// Preferred API: [builder] +/// +/// Use [builder] when your app root needs a provider-aware `BuildContext` +/// during construction. This is the recommended and future-facing API. +/// +/// Legacy API: [child] +/// +/// [child] is deprecated but still supported for compatibility while migrating +/// existing apps. +/// +/// Migration: +/// ```dart +/// // Before (deprecated) +/// ArcaneApp(child: MyApp()) +/// +/// // After (preferred) +/// ArcaneApp(builder: (context, _) => MyApp()) +/// ``` /// /// Example usage: /// ```dart /// ArcaneApp( /// services: [MyArcaneService()], -/// child: MyApp(), +/// builder: (context, _) => MyApp(), /// ); /// ``` class ArcaneApp extends StatefulWidget { /// A list of Arcane services that will be made available to the application. final List services; + /// Optional builder invoked inside Arcane's provider tree. + /// + /// This mirrors Flutter's `TransitionBuilder` pattern and allows consumers + /// to capture a provider-aware context without adding their own wrapper + /// widgets around [child]. + final TransitionBuilder? builder; + /// The root widget of the application. - final Widget child; + /// + /// Deprecated: prefer [builder] to construct your root widget with a + /// provider-aware BuildContext from inside `ArcaneApp`. + @Deprecated( + "Deprecated in 2.0.0. " + "Prefer ArcaneApp.builder so your app root is built with Arcane-provided context.", + ) + final Widget? child; - /// Creates an `ArcaneApp` with the specified [child] widget and optional [services]. + /// Creates an `ArcaneApp` with optional [child], [builder], and [services]. + /// + /// Either [child] or [builder] must be provided. const ArcaneApp({ - required this.child, + @Deprecated( + "Deprecated in 2.0.0. " + "Prefer ArcaneApp.builder so your app root is built with Arcane-provided context.", + ) + this.child, this.services = const [], + this.builder, super.key, - }); + }) : assert( + child != null || builder != null, + "ArcaneApp requires either a child or a builder.", + ); @override State createState() => _ArcaneAppState(); @@ -77,12 +121,18 @@ class _ArcaneAppState extends State { @override Widget build(BuildContext context) { + final Widget appChild = widget.builder != null + ? Builder( + builder: (context) => widget.builder!(context, widget.child), + ) + : widget.child!; + return ArcaneServiceProvider( serviceNotifier: _serviceNotifier, child: ArcaneFeatureFlagsProvider( child: ArcaneEnvironmentProvider( child: ArcaneThemeSwitcher( - child: widget.child, + child: appChild, ), ), ), diff --git a/lib/src/services/theme/theme_service.dart b/lib/src/services/theme/theme_service.dart index 85ec778..08ae417 100644 --- a/lib/src/services/theme/theme_service.dart +++ b/lib/src/services/theme/theme_service.dart @@ -198,9 +198,11 @@ class ArcaneThemeService extends ArcaneService { /// ``` ArcaneThemeService setDarkTheme(ThemeData theme) { _darkTheme.value = theme; - _themeController.add(theme); - _currentTheme = theme; - + // Only update the rendered theme if dark is the active mode. + if (_effectiveThemeMode == ThemeMode.dark) { + _themeController.add(theme); + _currentTheme = theme; + } return I; } @@ -215,9 +217,11 @@ class ArcaneThemeService extends ArcaneService { /// ``` ArcaneThemeService setLightTheme(ThemeData theme) { _lightTheme.value = theme; - _themeController.add(theme); - _currentTheme = theme; - + // Only update the rendered theme if light is the active mode. + if (_effectiveThemeMode == ThemeMode.light) { + _themeController.add(theme); + _currentTheme = theme; + } return I; } diff --git a/lib/src/services/theme/theme_switcher.dart b/lib/src/services/theme/theme_switcher.dart index b633d49..5825954 100644 --- a/lib/src/services/theme/theme_switcher.dart +++ b/lib/src/services/theme/theme_switcher.dart @@ -52,7 +52,9 @@ class _ArcaneThemeSwitcherState extends State void didChangeDependencies() { super.didChangeDependencies(); if (!_initialized) { - ArcaneThemeService.I.setInitialTheme(context); + // ArcaneApp defaults to following the platform theme until the user + // explicitly picks a manual light/dark mode. + ArcaneThemeService.I.followSystemTheme(context); _initialized = true; } } diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index 6fea9f3..a714bf9 100644 --- a/test/providers/service_provider_test.dart +++ b/test/providers/service_provider_test.dart @@ -68,6 +68,46 @@ void main() { ); }); + testWidgets("ArcaneApp builder receives provider-aware context", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + builder: (context, child) { + final provider = ArcaneServiceProvider.of(context); + expect(provider.registeredServices, containsAll(testServices)); + return child ?? const SizedBox(); + }, + child: const SizedBox(), + ), + ); + }); + + testWidgets("ArcaneApp supports builder-only usage", (tester) async { + var builderCalled = false; + + await tester.pumpWidget( + ArcaneApp( + services: testServices, + builder: (context, _) { + builderCalled = true; + final provider = ArcaneServiceProvider.of(context); + expect(provider.registeredServices, containsAll(testServices)); + return const SizedBox(); + }, + ), + ); + + expect(builderCalled, isTrue); + }); + + test("ArcaneApp asserts when both child and builder are missing", () { + expect( + () => ArcaneApp(), + throwsA(isA()), + ); + }); + testWidgets("static serviceOfType method returns correct service", (tester) async { await tester.pumpWidget( diff --git a/test/services/reactive_theme/theme_service_regression_test.dart b/test/services/reactive_theme/theme_service_regression_test.dart new file mode 100644 index 0000000..a44cea5 --- /dev/null +++ b/test/services/reactive_theme/theme_service_regression_test.dart @@ -0,0 +1,50 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneThemeService regression", () { + setUp(() { + Arcane.theme.reset(); + }); + + test("setDarkTheme does not update rendered theme if not in dark mode", () { + // Start in light mode + expect(Arcane.theme.currentThemeMode, ThemeMode.light); + final originalTheme = Arcane.theme.currentTheme; + final darkTheme = ThemeData.dark().copyWith(primaryColor: Colors.purple); + Arcane.theme.setDarkTheme(darkTheme); + // Should not update rendered theme + expect(Arcane.theme.currentTheme, originalTheme); + expect(Arcane.theme.dark.primaryColor, Colors.purple); + }); + + test("setLightTheme does not update rendered theme if not in light mode", + () { + Arcane.theme.switchTheme(themeMode: ThemeMode.dark); + expect(Arcane.theme.currentThemeMode, ThemeMode.dark); + final originalTheme = Arcane.theme.currentTheme; + final lightTheme = + ThemeData.light().copyWith(primaryColor: Colors.orange); + Arcane.theme.setLightTheme(lightTheme); + // Should not update rendered theme + expect(Arcane.theme.currentTheme, originalTheme); + expect(Arcane.theme.light.primaryColor, Colors.orange); + }); + + test("setDarkTheme updates rendered theme if in dark mode", () { + Arcane.theme.switchTheme(themeMode: ThemeMode.dark); + expect(Arcane.theme.currentThemeMode, ThemeMode.dark); + final darkTheme = ThemeData.dark().copyWith(primaryColor: Colors.green); + Arcane.theme.setDarkTheme(darkTheme); + expect(Arcane.theme.currentTheme, darkTheme); + }); + + test("setLightTheme updates rendered theme if in light mode", () { + expect(Arcane.theme.currentThemeMode, ThemeMode.light); + final lightTheme = ThemeData.light().copyWith(primaryColor: Colors.blue); + Arcane.theme.setLightTheme(lightTheme); + expect(Arcane.theme.currentTheme, lightTheme); + }); + }); +} From 0482918487a4dfdc9032512a7046ddd5ffc57521 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 17:26:35 +0200 Subject: [PATCH 46/58] docs(arcane_app): Clarify ArcaneApp API and provider access documentation Updates the documentation for the `ArcaneApp` constructor to explicitly detail the preferred `builder` API and the deprecated `child` parameter, including migration guidance. Also updates the list of provided services to include `ArcaneFeatureFlagsProvider`. --- lib/src/arcane_app.dart | 42 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/src/arcane_app.dart b/lib/src/arcane_app.dart index 2b92fb8..64e5e2b 100644 --- a/lib/src/arcane_app.dart +++ b/lib/src/arcane_app.dart @@ -10,8 +10,8 @@ import "services/theme/theme_switcher.dart"; /// /// `ArcaneApp` serves as the entry point for an application using the Arcane /// framework. It provides access to the application's services and environment -/// settings throughout the widget tree using the `ArcaneServiceProvider` and -/// `ArcaneEnvironmentProvider`. +/// settings throughout the widget tree using the `ArcaneServiceProvider`, +/// `ArcaneEnvironmentProvider`, and `ArcaneFeatureFlagsProvider`. /// /// This widget wraps your app root with Arcane's built-in providers so /// descendant widgets can access services, environment, feature flags, and @@ -64,9 +64,43 @@ class ArcaneApp extends StatefulWidget { ) final Widget? child; - /// Creates an `ArcaneApp` with optional [child], [builder], and [services]. + /// A root widget for an Arcane-powered application. /// - /// Either [child] or [builder] must be provided. + /// `ArcaneApp` serves as the entry point for an application using the Arcane + /// framework. It provides access to the application's services and environment + /// settings throughout the widget tree using the `ArcaneServiceProvider`, + /// `ArcaneEnvironmentProvider`, and `ArcaneFeatureFlagsProvider`. + /// + /// This widget wraps your app root with Arcane's built-in providers so + /// descendant widgets can access services, environment, feature flags, and + /// theme updates. + /// + /// Preferred API: [builder] + /// + /// Use [builder] when your app root needs a provider-aware `BuildContext` + /// during construction. This is the recommended and future-facing API. + /// + /// Legacy API: [child] + /// + /// [child] is deprecated but still supported for compatibility while migrating + /// existing apps. + /// + /// Migration: + /// ```dart + /// // Before (deprecated) + /// ArcaneApp(child: MyApp()) + /// + /// // After (preferred) + /// ArcaneApp(builder: (context, _) => MyApp()) + /// ``` + /// + /// Example usage: + /// ```dart + /// ArcaneApp( + /// services: [MyArcaneService()], + /// builder: (context, _) => MyApp(), + /// ); + /// ``` const ArcaneApp({ @Deprecated( "Deprecated in 2.0.0. " From ef4c2816bbb4f5a578c000753027b980235dcbee Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:00:03 +0200 Subject: [PATCH 47/58] feat: Overhaul Arcane framework for 2.0.0 release notes Migrate Arcane to a static utility surface, removing the instantiable singleton constructor for simpler and more direct access to services. Introduce new reactive streams for environment, authentication, and theme services, enabling real-time observation of state changes. Enhance `ArcaneApp` integration with the new `builder` callback and `BuildContext` convenience accessors for services and feature flags. Update `ArcaneAuthInterface.logout` signature and revise `src` import paths, requiring consumers to update their implementations and imports. Deprecate `ArcaneApp.child` and `BuildContext.serviceOfType()` in favor of new, more idiomatic APIs. Remove direct `flutter_bloc` dependency and upgrade `result_monad` to `^4.0.0`. --- CHANGELOG.md | 67 +++++++++++++++++++++++++++++---- README.md | 102 ++++++++++++++++++++++++++++----------------------- 2 files changed, 116 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b49624..214439a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ - [CHANGE] `Arcane.features`, `Arcane.auth`, `Arcane.theme`, and `Arcane.environment` now prefer the live `ArcaneApp` registry instance when available, then fall back to built-in singletons. +- [BREAKING] `Arcane` is now a static utility surface (no instantiable + singleton constructor). +- [BREAKING] Several `package:arcane_framework/src/...` import paths changed + (for example, `src/providers/...` -> `src/service/...` and + `src/services/reactive_theme/...` -> `src/services/theme/...`). Consumers + importing from `src` directly must update import paths. - [NEW] Added optional `ArcaneApp.builder` callback (TransitionBuilder style) for capturing provider-aware build contexts from within `ArcaneApp`. - [DEPRECATED] `ArcaneApp.child` is now deprecated in favor of `ArcaneApp.builder` (legacy child usage remains supported during migration). -- [UPDATE] README and examples now show `ArcaneApp.builder` as the preferred - integration path, with migration guidance from `child`. +- [DEPRECATED] `BuildContext.serviceOfType()` is now deprecated in favor of + `BuildContext.service()`. ### Environment Service @@ -21,16 +27,54 @@ `InheritedWidget`. - [NEW] Added `Arcane.environment` shortcut for direct environment access. - [NEW] Added environment service to `Arcane.services` built-in list. -- [CHANGE] `ArcaneEnvironmentProvider` is now a `StatefulWidget`. +- [CHANGE] `ArcaneEnvironmentProvider` is now a `StatefulWidget` instead of a + `StatelessWidget` with a `BlocProvider`. - [NEW] `ArcaneEnvironmentProvider` now provides methods for `enableDebugMode()`, `disableDebugMode()` and `setEnvironment()`. +- [NEW] Added `environmentChanges` stream for realtime environment updates. ### Authentication Service +- [BREAKING] `ArcaneAuthInterface.logout` now accepts optional + `onLoggedOut` callback parameters. `ArcaneAuthInterface` implementers must + update logout signature to accept optional `onLoggedOut`. See the migration + steps for further details. - [NEW] Added `statusChanges` stream to observe `AuthenticationStatus` updates. - [NEW] Added `signedInChanges` stream to observe sign-in state changes. - [FIX] Added stream lifecycle cleanup in `dispose` with safe lazy recreation. +#### Migration Steps (ArcaneAuthInterface) + +1. Update `ArcaneAuthInterface` implementations to accept the new optional + `onLoggedOut` callback parameter in `logout(...)`. +2. If your implementation performs cleanup side effects on logout, invoke + `onLoggedOut` when provided. +3. Run tests to confirm your authentication adapter still satisfies your + login/logout flows. + +Before: + +```dart +@override +Future> logout() async { + // ... + return Result.ok(null); +} +``` + +After: + +```dart +@override +Future> logout({ + Future Function()? onLoggedOut, +}) async { + // ... + if (onLoggedOut != null) await onLoggedOut(); + return Result.ok(null); +} +``` + ### Feature Flag Service - [CHANGE] Renamed service class `ArcaneFeatureFlags` to @@ -43,6 +87,8 @@ - [NEW] Added `ArcaneFeatureFlagProvider` (`InheritedWidget`) and `ArcaneFeatureFlagsProvider` (`StatefulWidget`) for first-class feature-flag integration in the widget tree. +- [DEPRECATED] `ArcaneFeatureFlagsScope` has been renamed to + `ArcaneFeatureFlagProvider`. - [NEW] Added `BuildContext` convenience accessors for feature flags, including `context.featureFlags`, `context.maybeFeatureFlags`, `context.isFeatureEnabled(...)`, and `context.isFeatureDisabled(...)`. @@ -51,8 +97,6 @@ `ArcaneFeatureFlagProvider.of(context)`. - [UPDATE] README now documents `ArcaneFeatureFlagProvider` and `ArcaneApp` provider composition. -- [UPDATE] Example app now demonstrates feature toggling via - `ArcaneFeatureFlagProvider` to highlight scope-based rebuilds. ### Theme Service @@ -62,8 +106,8 @@ `typedef ArcaneReactiveTheme = ArcaneThemeService`. - [FIX] Theme initialization now respects `ThemeMode.system` and initializes `ThemeData` using the effective brightness. -- [FIX] `ArcaneThemeSwitcher` now initializes theme state once via - `setInitialTheme(context)`. +- [FIX] `ArcaneThemeSwitcher` now initializes system-follow behavior once on + first dependency resolution. - [FIX] `ArcaneThemeSwitcher` now defaults to `followSystemTheme(context)` when mounted under `ArcaneApp`, so system-follow is enabled by default and system brightness changes are handled framework-side (no app-level observer needed). @@ -81,6 +125,8 @@ - [FIX] Setting a theme (e.g., dark) while in the opposite mode (e.g., light) no longer changes the current brightness or rendered theme. Only the active mode's theme updates the rendered appearance. +- [NEW] Added `themeModeChanges` and `themeDataChanges` streams for realtime + theme updates. ### Arcane Logger @@ -157,6 +203,13 @@ class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { - If desired, adopt `feature` for destination-aware filtering in interceptors. +### Dependencies + +- [BREAKING] Upgraded `result_monad` from `^2.3.2` to `^4.0.0`. +- [CHANGE] Removed direct `flutter_bloc` dependency. +- [CHANGE] Updated `collection` from `^1.18.0` to `^1.19.0`. +- [CHANGE] Relaxed `arcane_helper_utils` constraint from `^1.4.7` to `any`. + ## 1.2.5 - Improved automatic metadata detection in `ArcaneLogger` diff --git a/README.md b/README.md index 4743c48..532d433 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The Arcane Framework is a powerful Dart package designed to provide a robust architecture for managing key application services such as logging, -authentication, secure storage, feature flags, theming, and more. This framework +authentication, feature flags, theming, and more. This framework is ideal for building scalable applications that require dynamic configuration and service management. @@ -38,9 +38,9 @@ and service management. log levels via `ArcaneLogger`. - **Authentication**: Built-in support for handling user authentication workflows. -- **Dynamic Theming**: Switch between light and dark themes with - `ArcaneThemeService` (with `ArcaneReactiveTheme` kept as a deprecated - compatibility typedef). +- **Dynamic Theming**: Switch between light and dark themes and update theme + definitions on-the-fly with `ArcaneThemeService`. +- **Extensible Service Definitions**: Implement your own `ArcaneService` services and leverage the inherent powers of Arcane. - **Realtime Streams**: In addition to `ValueNotifier`s, core services expose broadcast streams for reactive consumers. @@ -153,9 +153,8 @@ appropriate: ```dart class FavoriteColorService extends ArcaneService { - FavoriteColorService._internal(); - static final FavoriteColorService _instance = FavoriteColorService._internal(); - static FavoriteColorService get I => _instance; + + FavoriteColorService(); final ValueNotifier _notifier = ValueNotifier(null); ValueNotifier get notifier => _notifier; @@ -182,7 +181,7 @@ singleton instance) to the `services` list directly: ```dart ArcaneApp( services: [ - FavoriteColorService.I, + FavoriteColorService(), ], builder: (context, _) => MainApp(), ), @@ -199,7 +198,7 @@ ArcaneApp( ), // Add the service at runtime -ArcaneServiceProvider.of(context).addService(FavoriteColorService.I); +ArcaneServiceProvider.of(context).addService(FavoriteColorService()); ``` Unregistering an already registered `ArcaneService` is as simple as: @@ -408,6 +407,20 @@ class FeatureGate extends StatelessWidget { } ``` +Additional `BuildContext` helpers are also available: + +```dart +final ArcaneFeatureFlagProvider? maybeFlags = context.maybeFeatureFlags; + +if (context.isFeatureEnabled(Feature.awesomeFeature)) { + // Feature is enabled +} + +if (context.isFeatureDisabled(Feature.prettyOkFeature)) { + // Feature is disabled +} +``` + Note that it is possible to register multiple different `Enum` types in the feature flag service, should one have a need to do so. @@ -475,7 +488,7 @@ final DebugConsole debugConsole = DebugConsole(); await Arcane.logger.registerInterface( debugConsole, interceptors: [ - LogInterceptor((event, {required LogInterceptorContext context}) { + LogInterceptor((event, context) { if (context.interface is DebugConsole && event.level == Level.debug) { return null; } @@ -486,7 +499,7 @@ await Arcane.logger.registerInterface( ); Arcane.logger.registerInterceptor( - LogInterceptor((event, {required context}) { + LogInterceptor((event, context) { return event.copyWith( metadata: { ...?event.metadata, @@ -527,9 +540,17 @@ Arcane.log( level: Level.debug, module: "ModuleName", method: "MethodName", - metadata: {"key": "value", "attempt": 1}, + metadata: {"key": "value", "attempt": "1"}, stackTrace: StackTrace.current, ); + +// Optional: skip automatic module/method/file-line detection. +Arcane.log( + "Manual log routing", + module: "CustomModule", + method: "customMethod", + skipAutodetection: true, +); ``` You can also listen to `logStream` for realtime log events, and cancel and @@ -561,9 +582,9 @@ prefer, you can also define your own interceptor class by implementing ```dart final LogInterceptor redactSecrets = LogInterceptor(( - event, { - required LogInterceptorContext context, -}) { + event, + context, +) { final Object? token = event.metadata?["token"]; if (token == null) return event; @@ -655,11 +676,15 @@ class DebugAuthInterface ); @override - Future> logout() async { + Future> logout({ + Future Function()? onLoggedOut, + }) async { Arcane.log("Logging out"); _isSignedIn = false; + if (onLoggedOut != null) await onLoggedOut(); + return Result.ok(null); } @@ -743,7 +768,7 @@ class DebugAuthInterface // Register an interface to handle user authentication. -await Arcane.auth.registerInterface(AuthProviderInterface.I); +await Arcane.auth.registerInterface(DebugAuthInterface.I); ``` Once your interface has been created and registered, you can use it to perform a @@ -752,7 +777,7 @@ number of common authentication tasks: ```dart // Register an account using the ArcaneAuthAccountRegistration mixin final nextStep = await Arcane.auth.register( - input: ("email": "user@example.com", "password": "password123"), + input: (email: "user@example.com", password: "password123"), ); // Confirm a newly registered account using the ArcaneAuthAccountRegistration mixin @@ -779,12 +804,12 @@ final passwordResetFinished = await Arcane.auth.resetPassword( // Sign in with email and password final result = await Arcane.auth.login( - input: ("email": "user@example.com", "password": "password123") + input: (email: "user@example.com", password: "password123"), onLoggedIn: () => Arcane.log("User logged in"), ); // Sign out -await Arcane.auth.logout(); +await Arcane.auth.logOut(); ``` Authentication updates can also be consumed through streams: @@ -877,35 +902,22 @@ void main() { From here, you can either follow the system theme: ```dart -// Follow the system's theme mode -class MainApp extends StatefulWidget { +// ArcaneApp already enables system-follow behavior by default. +class MainApp extends StatelessWidget { const MainApp({super.key}); - @override - State createState() => _MainAppState(); -} - -class _MainAppState extends State { - @override - void didChangeDependencies() { - Arcane.theme.followSystemTheme(context); - super.didChangeDependencies(); - } - @override Widget build(BuildContext context) { - return ArcaneApp( - builder: (context, _) => MaterialApp( - theme: Arcane.theme.light, - darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.currentModeOf(context), - ), + return MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), ); } } ``` -or manually control the theme mode: +You can also manually control the theme mode: ```dart // Manually control the theme mode @@ -914,12 +926,10 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ArcaneApp( - builder: (context, _) => MaterialApp( - theme: Arcane.theme.light, - darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.currentModeOf(context), - ), + return MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), ); } } From 8bd4bfe7f3cead2f20d674319396bd2e91e9c8e4 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:10:45 +0200 Subject: [PATCH 48/58] refactor(environment): Rename environment access getter to current This change unifies how the current application environment is accessed across Arcane's services and providers. The `state` and `environment` getters have been replaced with a consistent `current` getter, simplifying API usage and aligning with new patterns. Includes corresponding updates in example and internal code, and documents the migration in the CHANGELOG. --- CHANGELOG.md | 18 ++++++++++++++++++ example/lib/main.dart | 2 +- .../authentication/authentication_service.dart | 4 ++-- .../environment/environment_provider.dart | 9 ++++++--- .../environment/environment_service.dart | 4 ++-- .../authentication_service_test.dart | 4 ++-- 6 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214439a..4aede5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,24 @@ `enableDebugMode()`, `disableDebugMode()` and `setEnvironment()`. - [NEW] Added `environmentChanges` stream for realtime environment updates. +#### Migration Steps (ArcaneEnvironment) + +1. The `state` getter has been removed from `ArcaneEnvironment`. If you previously accessed environment state via `Arcane.environment.state`, update your code to use the new API: + + - **Before:** + + ```dart + final env = Arcane.environment.state; + ``` + + - **After:** + + ```dart + final env = Arcane.environment.current; + ``` + +2. If you were using `Cubit`-style APIs, migrate to the new `InheritedWidget`/`ValueNotifier`-based approach. See the README for updated usage examples. + ### Authentication Service - [BREAKING] `ArcaneAuthInterface.logout` now accepts optional diff --git a/example/lib/main.dart b/example/lib/main.dart index 10a4cbe..d530792 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -491,7 +491,7 @@ class ArcaneEnvironmentExample extends StatelessWidget { ElevatedButton( onPressed: () { final ArcaneEnvironmentService environment = Arcane.environment; - final Environment previousEnvironment = environment.environment; + final Environment previousEnvironment = environment.current; final Environment nextEnvironment = _nextEnvironment( previousEnvironment, ); diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index a8f53d5..bb4080c 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -109,7 +109,7 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeSet, }) async { - final Environment previousEnvironment = Arcane.environment.environment; + final Environment previousEnvironment = Arcane.environment.current; if (previousEnvironment == Environment.debug) return; @@ -125,7 +125,7 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeUnset, }) async { - final Environment previousEnvironment = Arcane.environment.environment; + final Environment previousEnvironment = Arcane.environment.current; if (previousEnvironment == Environment.normal) return; diff --git a/lib/src/services/environment/environment_provider.dart b/lib/src/services/environment/environment_provider.dart index 2ad1942..b5a09da 100644 --- a/lib/src/services/environment/environment_provider.dart +++ b/lib/src/services/environment/environment_provider.dart @@ -8,6 +8,9 @@ import "environment_interface.dart"; /// The `ArcaneEnvironment` widget holds the current environment and allows /// descendant widgets to access and mutate it. class ArcaneEnvironment extends InheritedWidget { + /// Returns the current environment (alias for [environment]) for API consistency. + Environment get current => environment; + /// The current application environment. final Environment environment; @@ -80,7 +83,7 @@ class _ArcaneEnvironmentProviderState extends State { void _handleEnvironmentChange() { if (!mounted) return; - final nextEnvironment = Arcane.environment.environment; + final nextEnvironment = Arcane.environment.current; if (nextEnvironment == _environment) return; setState(() { @@ -91,11 +94,11 @@ class _ArcaneEnvironmentProviderState extends State { @override void initState() { super.initState(); - _environment = Arcane.environment.environment; + _environment = Arcane.environment.current; if (_environment != widget.environment) { Arcane.environment.setEnvironment(widget.environment); - _environment = Arcane.environment.environment; + _environment = Arcane.environment.current; } Arcane.environment.notifier.addListener(_handleEnvironmentChange); diff --git a/lib/src/services/environment/environment_service.dart b/lib/src/services/environment/environment_service.dart index 4cc2c97..f0e4243 100644 --- a/lib/src/services/environment/environment_service.dart +++ b/lib/src/services/environment/environment_service.dart @@ -17,7 +17,7 @@ class ArcaneEnvironmentService extends ArcaneService { final ValueNotifier _notifier = ValueNotifier(Environment.normal); - /// A notifier that emits updates when [environment] changes. + /// A notifier that emits updates when [current] changes. ValueNotifier get notifier => _notifier; StreamController? _environmentStreamController; @@ -35,7 +35,7 @@ class ArcaneEnvironmentService extends ArcaneService { /// Reading this getter does not subscribe to changes and does not trigger /// widget rebuilds. Use [notifier] (for `ValueListenableBuilder`) or /// [environmentChanges] (for streams) when you need reactive updates. - Environment get environment => _notifier.value; + Environment get current => _notifier.value; /// Sets the environment when the incoming value is different. void setEnvironment(Environment environment) { diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index 7af45a6..afa7015 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -127,10 +127,10 @@ void main() { ); await ArcaneAuthenticationService.I.setDebug(capturedContext); - expect(Arcane.environment.environment, Environment.debug); + expect(Arcane.environment.current, Environment.debug); await ArcaneAuthenticationService.I.setNormal(capturedContext); - expect(Arcane.environment.environment, Environment.normal); + expect(Arcane.environment.current, Environment.normal); }, ); From c777b145645baea9794f0f14dd3fd298c0942c3e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:12:32 +0200 Subject: [PATCH 49/58] chore: Relax result_monad dependency constraint The `result_monad` dependency was recently upgraded to `^4.0.0`. This update relaxes the constraint to `any` to provide greater flexibility and mitigate potential dependency conflicts for consumers of Arcane, allowing them to use other compatible versions. --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aede5d..9607b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -223,7 +223,7 @@ class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { ### Dependencies -- [BREAKING] Upgraded `result_monad` from `^2.3.2` to `^4.0.0`. +- [CHANGE] Relaxed `result_monad` constraint from `^2.3.2` to `any`. - [CHANGE] Removed direct `flutter_bloc` dependency. - [CHANGE] Updated `collection` from `^1.18.0` to `^1.19.0`. - [CHANGE] Relaxed `arcane_helper_utils` constraint from `^1.4.7` to `any`. diff --git a/pubspec.yaml b/pubspec.yaml index 7c1e784..a210660 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: collection: ^1.19.0 flutter: sdk: flutter - result_monad: ^4.0.0 + result_monad: any dev_dependencies: arcane_analysis: any From 0aba1f5951eef25f33cc3fa45e4617a98f78a95e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:22:53 +0200 Subject: [PATCH 50/58] refactor(logging): Replace LoggingInitializationMixin with LoggingInitialization and add LoggingFeature annotation support Signed-off-by: Hans Kokx --- CHANGELOG.md | 9 +-- README.md | 60 +++++++++++++++++-- .../services/logging/logging_interface.dart | 19 ++++-- .../logging/logging_service_test.dart | 7 +-- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9607b3a..5891a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,8 +153,9 @@ Future> logout({ - [BREAKING] `LoggingInterface` no longer includes built-in singleton-style initialization state. - [NEW] Added optional lifecycle capability via `LoggingInitializable` and - `LoggingInitializationMixin`. -- [NEW] Added optional `feature` tag to `LoggingInterface` via constructor. + `LoggingInitialization`. +- [NEW] Added optional `feature` tag support via `@LoggingFeature(...)` + annotation. - [CHANGE] `initializeInterfaces()` now initializes only interfaces that implement `LoggingInitializable`; other interfaces are skipped. - [NEW] Added a `skipAutodetection` parameter to `Arcane.log` (defaults to @@ -169,7 +170,7 @@ Future> logout({ 1. Remove `initialized` and `init` from interfaces that do not require startup work. 2. If an interface requires startup/lifecycle management, add - `LoggingInitializationMixin` (or implement `LoggingInitializable`) and move + `LoggingInitialization` (or implement `LoggingInitializable`) and move setup logic into `init()`. 3. Update `log(...)` implementations to guard behavior with `initialized` only for interfaces that opted into initialization. @@ -203,7 +204,7 @@ class DebugConsole extends LoggingInterface { - For SDK-backed loggers, opt into initialization with the mixin. ```dart -class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { +class ExternalLogger extends LoggingInterface with LoggingInitialization { @override Future init() async { if (initialized) return; diff --git a/README.md b/README.md index 532d433..61ba880 100644 --- a/README.md +++ b/README.md @@ -452,10 +452,10 @@ class DebugConsole extends LoggingInterface { ``` If your destination needs setup (SDK start, permission checks, etc.), opt into -the initialization lifecycle with `LoggingInitializationMixin`: +the initialization lifecycle with `LoggingInitialization`: ```dart -class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { +class ExternalLogger extends LoggingInterface with LoggingInitialization { @override Future init() async { if (initialized) return; @@ -478,6 +478,58 @@ class ExternalLogger extends LoggingInterface with LoggingInitializationMixin { } ``` +If you want to tag a destination, annotate the interface with +`@LoggingFeature(...)`: + +```dart +@LoggingFeature("Analytics") +class AnalyticsLogger extends LoggingInterface { + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + // Forward to analytics pipeline. + } +} + +Arcane.logger.registerInterceptor( + LogInterceptor((event, context) { + if (context.interface is AnalyticsLogger && event.level == Level.debug) { + return null; + } + + return event; + }), +); +``` + +You can use this tag as source-level documentation and keep destination routing +explicit in interceptors. + +```dart +@LoggingFeature("auth") +class AuthLogger extends LoggingInterface { + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + // Forward to auth destination. + } +} + +await Arcane.logger.registerInterface(AnalyticsLogger()); +await Arcane.logger.registerInterface(AuthLogger()); +``` + Next, register your logging interface with the Arcane logger service. You can attach interceptors when registering an interface, or add global interceptors later at runtime. @@ -510,7 +562,7 @@ Arcane.logger.registerInterceptor( ); // Optional: initialize only interfaces that implement LoggingInitializable -// (for example, SDK-backed loggers that mix in LoggingInitializationMixin). +// (for example, SDK-backed loggers that mix in LoggingInitialization). await Arcane.logger.initializeInterfaces(); ``` @@ -636,7 +688,7 @@ instances, so mutations made for one destination do not leak into another. **Important**: Initialization is now optional per interface. Call `initializeInterfaces()` when you have interfaces that opt into -`LoggingInitializable` (for example via `LoggingInitializationMixin`). Simple +`LoggingInitializable` (for example via `LoggingInitialization`). Simple destinations like a debug console can skip initialization entirely. ### Authentication diff --git a/lib/src/services/logging/logging_interface.dart b/lib/src/services/logging/logging_interface.dart index 065b57f..85386c4 100644 --- a/lib/src/services/logging/logging_interface.dart +++ b/lib/src/services/logging/logging_interface.dart @@ -5,11 +5,7 @@ part of "logging_service.dart"; /// Concrete implementations of this class should override the [log] method to provide /// platform-specific logging behavior. abstract class LoggingInterface { - const LoggingInterface([this.feature]); - - /// An optional tag that can be used to identify the destination this - /// interface represents (for example, "my-feature" or "auth"). - final String? feature; + const LoggingInterface(); /// This method is called by the `ArcaneLogger` when a log message is /// received. See `ArcaneLogger.log` for further details on how logging @@ -33,7 +29,7 @@ abstract interface class LoggingInitializable { } /// Default initialization behavior for interfaces that opt into lifecycle. -mixin LoggingInitializationMixin implements LoggingInitializable { +mixin LoggingInitialization implements LoggingInitializable { bool _initialized = false; @override @@ -44,3 +40,14 @@ mixin LoggingInitializationMixin implements LoggingInitializable { _initialized = true; } } + +/// Annotation used to tag a logging destination with a feature name. +/// +/// Example: +/// `@LoggingFeature("analytics")` +final class LoggingFeature { + const LoggingFeature(this.value); + + /// The feature name associated with this logging destination. + final String value; +} diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart index 62f663e..28e1ddf 100644 --- a/test/services/logging/logging_service_test.dart +++ b/test/services/logging/logging_service_test.dart @@ -1,9 +1,8 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter_test/flutter_test.dart"; -class TestLoggingInterface extends LoggingInterface - with LoggingInitializationMixin { - TestLoggingInterface(this.name, [super.feature]); +class TestLoggingInterface extends LoggingInterface with LoggingInitialization { + TestLoggingInterface(this.name); final String name; int initCallCount = 0; @@ -36,7 +35,7 @@ class TestLoggingInterface extends LoggingInterface } class TestPassiveLoggingInterface extends LoggingInterface { - TestPassiveLoggingInterface(this.name, [super.feature]); + TestPassiveLoggingInterface(this.name); final String name; final List events = []; From ce15557f35a427327c85dc6b11f9f8be7d8f52f9 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:33:00 +0200 Subject: [PATCH 51/58] docs(changelog): Add migration steps for ArcaneThemeService and update themeMode configuration examples Signed-off-by: Hans Kokx --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5891a5d..01b90ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,31 @@ Future> logout({ - [NEW] Added `themeModeChanges` and `themeDataChanges` streams for realtime theme updates. +#### Migration Steps (ArcaneThemeService) + +1. Replace legacy `ThemeMode` reads from `Arcane.theme.systemTheme.value` with + `Arcane.theme.currentModeOf(context)` when configuring app `themeMode`. + +Before: + +```dart +MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.systemTheme.value, +) +``` + +After: + +```dart +MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), +) +``` + ### Arcane Logger - [NEW] Added `logStream` for realtime log subscriptions. From 49e4c4a3d100733922838c6660cfa97e9e89183c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:36:49 +0200 Subject: [PATCH 52/58] refactor(workflow): Simplify analyze-and-test workflow by removing unused environment variables and steps Signed-off-by: Hans Kokx --- .github/workflows/analyze-and-unit-test.yaml | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/.github/workflows/analyze-and-unit-test.yaml b/.github/workflows/analyze-and-unit-test.yaml index 82f7f07..52da24f 100644 --- a/.github/workflows/analyze-and-unit-test.yaml +++ b/.github/workflows/analyze-and-unit-test.yaml @@ -2,9 +2,6 @@ name: Tests on: workflow_dispatch: pull_request: -env: - PURO_FLUTTER_VERSION: "" - FLUTTER_VERSION: "" jobs: test: name: Analyze and test @@ -12,26 +9,13 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Get Flutter version from .puro.json - id: puro_version - run: | - if [[ -f ".puro.json" ]]; then - flutter_version=$(jq -r '.env' .puro.json) - echo "PURO_FLUTTER_VERSION=$flutter_version" >> $GITHUB_ENV - else - echo "Warning: .puro.json not found, using default Flutter stable channel." - echo "PURO_FLUTTER_VERSION=stable" >> $GITHUB_ENV - fi - name: Setup Flutter uses: subosito/flutter-action@v2 with: channel: "stable" - 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 - run: flutter test \ No newline at end of file + run: flutter test From 1650c9e5595f30613bfc008ebff1f0eb204365a9 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:46:56 +0200 Subject: [PATCH 53/58] docs(changelog): Update Arcane.log metadata type and add migration steps for structured values Signed-off-by: Hans Kokx --- CHANGELOG.md | 35 ++++++++++++++ README.md | 46 +++++++++++++++++-- lib/src/arcane.dart | 6 ++- .../authentication_service.dart | 8 ++-- lib/src/services/logging/log_interceptor.dart | 2 +- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b90ad..3a7c357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,8 @@ MaterialApp( - [NEW] Added the `LogInterceptor` class which can (optionally) be added to `ArcaneLogger` to pre-process log messages before they are sent to the registered `ArcaneLoggingInterface`(s). +- [CHANGE] Updated `Arcane.log` metadata type from `Map?` to + `Map?` to support structured metadata values. #### Migration Steps (LoggingInterface) @@ -247,6 +249,39 @@ class ExternalLogger extends LoggingInterface with LoggingInitialization { - If desired, adopt `feature` for destination-aware filtering in interceptors. +#### Migration Steps (Arcane.log metadata) + +1. Update `Arcane.log(...)` call sites that stringify metadata values only to + satisfy the previous `Map` type. +2. Prefer passing native values (for example `int`, `bool`, `List`, or nested + `Map`) directly in `metadata` when useful. +3. If your logging destination expects only string metadata, convert + `Object?` values to strings at your logging boundary. + +Before: + +```dart +Arcane.log( + "Login attempt", + metadata: { + "attempt": attempt.toString(), + "rememberMe": rememberMe.toString(), + }, +); +``` + +After: + +```dart +Arcane.log( + "Login attempt", + metadata: { + "attempt": attempt, + "rememberMe": rememberMe, + }, +); +``` + ### Dependencies - [CHANGE] Relaxed `result_monad` constraint from `^2.3.2` to `any`. diff --git a/README.md b/README.md index 61ba880..138b06d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ and service management. - [Features](#features) - [Installation](#installation) - [ArcaneApp Builder Migration (v1.x -\> v2.x)](#arcaneapp-builder-migration-v1x---v2x) + - [Arcane.log Metadata Migration (v1.x -\> v2.x)](#arcanelog-metadata-migration-v1x---v2x) - [Usage](#usage) - [Services](#services) - [Defining an example `ArcaneService`](#defining-an-example-arcaneservice) @@ -108,6 +109,39 @@ ArcaneApp( ) ``` +### Arcane.log Metadata Migration (v1.x -> v2.x) + +`Arcane.log(...)` now accepts `metadata` as `Map?`. +This allows metadata values to be non-strings (for example `int`, `bool`, +lists, or nested maps). + +Migration example: + +```dart +// Before +Arcane.log( + "Login attempt", + metadata: { + "userId": userId.toString(), + "attempt": attempt.toString(), + "rememberMe": rememberMe.toString(), + }, +); + +// After +Arcane.log( + "Login attempt", + metadata: { + "userId": userId, + "attempt": attempt, + "rememberMe": rememberMe, + }, +); +``` + +If your logger destination serializes metadata, ensure it can handle +`Object?` values (or convert values to strings at that boundary). + ## Usage The following sections provide more information about how to use the package's @@ -130,8 +164,8 @@ services: - `ArcaneService`: The base class from which to extend your own services. This what Arcane uses to locate services. - `ArcaneServiceProvider`: A widget used to provide access to registered - `ArcaneService` instances. **Note**: This widget is already part of the* - `ArcaneApp`*widget, however if you are not using the `ArcaneApp` widget you + `ArcaneService` instances. **Note**: This widget is already part of the + _`ArcaneApp`_ widget, however if you are not using the `ArcaneApp` widget you can instead use this widget directly. - The `service` and `requiredService` extensions on `BuildContext`: nullable and non-nullable getters used to locate a given `ArcaneService` via @@ -254,7 +288,7 @@ service.notifier.addListener(() { }); ``` -We can also simply user a `ValueListenableBuilder`: +We can also simply use a `ValueListenableBuilder`: ```dart ValueListenableBuilder( @@ -592,7 +626,11 @@ Arcane.log( level: Level.debug, module: "ModuleName", method: "MethodName", - metadata: {"key": "value", "attempt": "1"}, + metadata: { + "key": "value", + "attempt": 1, + "retryable": true, + }, stackTrace: StackTrace.current, ); diff --git a/lib/src/arcane.dart b/lib/src/arcane.dart index ea5c3e5..91d27c2 100644 --- a/lib/src/arcane.dart +++ b/lib/src/arcane.dart @@ -87,7 +87,9 @@ abstract class Arcane { /// - [level]: The log level (e.g., `Level.debug`, `Level.error`), defaults to /// `Level.debug`. /// - [stackTrace]: Optional stack trace information. - /// - [metadata]: Optional additional metadata in key-value pairs. + /// - [metadata]: Optional additional metadata in key-value pairs + /// (`Map`), which supports structured values such as + /// numbers, booleans, nested maps, and lists. /// - [extra]: Optional data passed to the logger. /// - [skipAutodetection]: Bypass automatically determining the module, method, /// and file/line number of log messages. @@ -97,7 +99,7 @@ abstract class Arcane { String? method, Level level = Level.debug, StackTrace? stackTrace, - Map? metadata, + Map? metadata, Object? extra, bool skipAutodetection = false, }) { diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index bb4080c..2275665 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -75,12 +75,12 @@ class ArcaneAuthenticationService extends ArcaneService { /// provides one. This token is often used in the headers of HTTP requests /// to the backend API. Future get accessToken async => - await authInterface?.accessToken ?? Future.value(""); + authInterface?.accessToken ?? Future.value(""); /// Returns a JWT refresh token if the registered `ArcaneAuthInterface` /// provides one. Future get refreshToken async => - await authInterface?.refreshToken ?? Future.value(""); + authInterface?.refreshToken ?? Future.value(""); /// Removes any registered `ArcaneAuthInterface` and resets all values to /// default. @@ -171,7 +171,9 @@ class ArcaneAuthenticationService extends ArcaneService { return const Result.error("No ArcaneAuthInterface has been registered"); } - if (!isAuthenticated) const Result.error("User is not authenticated."); + if (!isAuthenticated) { + return const Result.error("User is not authenticated."); + } final Result loggedOut = await authInterface!.logout( onLoggedOut: onLoggedOut, diff --git a/lib/src/services/logging/log_interceptor.dart b/lib/src/services/logging/log_interceptor.dart index 3f291d3..40d80e8 100644 --- a/lib/src/services/logging/log_interceptor.dart +++ b/lib/src/services/logging/log_interceptor.dart @@ -39,7 +39,7 @@ final class LogInterceptorContext { /// /// Example usage: /// ```dart -/// final interceptor = LogInterceptor((event, {context}) { +/// final interceptor = LogInterceptor((event, context) { /// // Filter out debug-level logs /// if (event.level == LogLevel.debug) return null; /// return event; From 26abff3629b3face7552e2948f46d9327ee64e28 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 18:53:19 +0200 Subject: [PATCH 54/58] test: Add unit tests for authentication and feature flags functionality Signed-off-by: Hans Kokx --- .../authentication_enums_test.dart | 22 +++++++++ .../authentication_interface_test.dart | 45 +++++++++++++++++++ .../feature_flags_extensions_test.dart | 27 +++++++++++ .../services/theme/theme_extensions_test.dart | 33 ++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 test/services/authentication/authentication_enums_test.dart create mode 100644 test/services/authentication/authentication_interface_test.dart create mode 100644 test/services/feature_flags/feature_flags_extensions_test.dart create mode 100644 test/services/theme/theme_extensions_test.dart diff --git a/test/services/authentication/authentication_enums_test.dart b/test/services/authentication/authentication_enums_test.dart new file mode 100644 index 0000000..319fdf8 --- /dev/null +++ b/test/services/authentication/authentication_enums_test.dart @@ -0,0 +1,22 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:arcane_framework/src/services/authentication/authentication_service.dart"; + +void main() { + group("SignUpStep", () { + test("values are correct", () { + expect(SignUpStep.confirmSignUp.index, 0); + expect(SignUpStep.done.index, 1); + }); + }); + + group("AuthenticationStatus", () { + test("isAuthenticated returns true only for authenticated", () { + expect(AuthenticationStatus.authenticated.isAuthenticated, isTrue); + expect(AuthenticationStatus.unauthenticated.isAuthenticated, isFalse); + }); + test("isUnauthenticated returns true only for unauthenticated", () { + expect(AuthenticationStatus.authenticated.isUnauthenticated, isFalse); + expect(AuthenticationStatus.unauthenticated.isUnauthenticated, isTrue); + }); + }); +} diff --git a/test/services/authentication/authentication_interface_test.dart b/test/services/authentication/authentication_interface_test.dart new file mode 100644 index 0000000..22ca589 --- /dev/null +++ b/test/services/authentication/authentication_interface_test.dart @@ -0,0 +1,45 @@ +import "package:arcane_framework/src/services/authentication/authentication_service.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:result_monad/result_monad.dart"; + +class MockAuth implements ArcaneAuthInterface { + @override + Future> login( + {T? input, Future Function()? onLoggedIn}) async { + if (onLoggedIn != null) await onLoggedIn(); + return const Result.ok(null); + } + + @override + Future get isSignedIn => Future.value(true); + + @override + Future? get accessToken => Future.value("token"); + + @override + Future? get refreshToken => Future.value("refresh"); + + @override + Future init() async {} + + @override + Future> logout( + {Future Function()? onLoggedOut}) async { + if (onLoggedOut != null) await onLoggedOut(); + return const Result.ok(null); + } +} + +void main() { + test("MockAuth fulfills ArcaneAuthInterface contract", () async { + final auth = MockAuth(); + expect(await auth.isSignedIn, isTrue); + expect(await auth.accessToken, "token"); + expect(await auth.refreshToken, "refresh"); + var called = false; + await auth.logout(onLoggedOut: () async { + called = true; + }); + expect(called, isTrue); + }); +} diff --git a/test/services/feature_flags/feature_flags_extensions_test.dart b/test/services/feature_flags/feature_flags_extensions_test.dart new file mode 100644 index 0000000..4f99f1e --- /dev/null +++ b/test/services/feature_flags/feature_flags_extensions_test.dart @@ -0,0 +1,27 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; + +enum DummyFeature { foo, bar } + +void main() { + setUp(() { + Arcane.features.disableFeature(DummyFeature.foo); + Arcane.features.disableFeature(DummyFeature.bar); + }); + + test("enabled/disabled reflect Arcane.features state", () { + expect(DummyFeature.foo.enabled, isFalse); + Arcane.features.enableFeature(DummyFeature.foo); + expect(DummyFeature.foo.enabled, isTrue); + expect(DummyFeature.foo.disabled, isFalse); + Arcane.features.disableFeature(DummyFeature.foo); + expect(DummyFeature.foo.disabled, isTrue); + }); + + test("enable/disable call Arcane.features", () { + DummyFeature.bar.enable(); + expect(DummyFeature.bar.enabled, isTrue); + DummyFeature.bar.disable(); + expect(DummyFeature.bar.enabled, isFalse); + }); +} diff --git a/test/services/theme/theme_extensions_test.dart b/test/services/theme/theme_extensions_test.dart new file mode 100644 index 0000000..217ee11 --- /dev/null +++ b/test/services/theme/theme_extensions_test.dart @@ -0,0 +1,33 @@ +import "package:arcane_framework/src/services/theme/theme_extensions.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + testWidgets("isDarkMode returns true for dark theme", (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Builder( + builder: (context) { + expect(context.isDarkMode, isTrue); + return Container(); + }, + ), + ), + ); + }); + + testWidgets("isDarkMode returns false for light theme", (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (context) { + expect(context.isDarkMode, isFalse); + return Container(); + }, + ), + ), + ); + }); +} From b3e256e446b1f1ef5c5b2a636961d0e536f05049 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 19:02:27 +0200 Subject: [PATCH 55/58] feat(theme): track user-defined theme overrides in ArcaneThemeService Signed-off-by: Hans Kokx --- lib/src/services/theme/theme_service.dart | 11 +- .../authentication_service_test.dart | 217 ++++++++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) diff --git a/lib/src/services/theme/theme_service.dart b/lib/src/services/theme/theme_service.dart index 08ae417..61a5862 100644 --- a/lib/src/services/theme/theme_service.dart +++ b/lib/src/services/theme/theme_service.dart @@ -87,6 +87,9 @@ class ArcaneThemeService extends ArcaneService { /// Stream of `ThemeData` changes that can be listened to for reactive UI updates. Stream get themeDataChanges => I._themeController.stream; + /// Tracks whether a custom light/dark theme has been explicitly provided by the user. + bool _themeOverriddenByUser = false; + StreamController? _themeStreamController; StreamController get _themeController { @@ -197,6 +200,7 @@ class ArcaneThemeService extends ArcaneService { /// ArcaneThemeService.I.setDarkTheme(customDarkTheme); /// ``` ArcaneThemeService setDarkTheme(ThemeData theme) { + _themeOverriddenByUser = true; _darkTheme.value = theme; // Only update the rendered theme if dark is the active mode. if (_effectiveThemeMode == ThemeMode.dark) { @@ -216,6 +220,7 @@ class ArcaneThemeService extends ArcaneService { /// ArcaneThemeService.I.setLightTheme(customLightTheme); /// ``` ArcaneThemeService setLightTheme(ThemeData theme) { + _themeOverriddenByUser = true; _lightTheme.value = theme; // Only update the rendered theme if light is the active mode. if (_effectiveThemeMode == ThemeMode.light) { @@ -227,9 +232,8 @@ class ArcaneThemeService extends ArcaneService { /// Should be called on first build to ensure the initial theme matches the platform brightness. void setInitialTheme(BuildContext context) { - // Only update if the theme is still the default (not user-provided) - if (_currentTheme != ThemeData.light() && - _currentTheme != ThemeData.dark()) { + // Only update when no custom theme was explicitly provided by the user. + if (_themeOverriddenByUser) { return; } switch (_currentThemeMode) { @@ -252,6 +256,7 @@ class ArcaneThemeService extends ArcaneService { void reset() { _darkTheme.value = ThemeData.dark(); _lightTheme.value = ThemeData.light(); + _themeOverriddenByUser = false; _followingSystemTheme = false; _updateTheme(ThemeMode.light); _themeController.add(_lightTheme.value); diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index afa7015..bb32719 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -5,7 +5,224 @@ import "package:mocktail/mocktail.dart"; class MockArcaneAuthInterface extends Mock implements ArcaneAuthInterface {} +class MockAccountRegistration extends Mock + implements ArcaneAuthInterface, ArcaneAuthAccountRegistration {} + +class MockPasswordManagement extends Mock + implements ArcaneAuthInterface, ArcaneAuthPasswordManagement {} + void main() { + group("ArcaneAuthenticationService error and edge cases", () { + setUp(() async { + await ArcaneAuthenticationService.I.reset(); + Arcane.environment.reset(); + }); + + test("reset clears interface and notifiers", () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + await ArcaneAuthenticationService.I.reset(); + expect(ArcaneAuthenticationService.I.authInterface, isNull); + expect( + ArcaneAuthenticationService.I.status, + AuthenticationStatus.unauthenticated, + ); + expect(ArcaneAuthenticationService.I.isSignedIn.value, false); + }); + + test("registerInterface throws if already registered", () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + expect( + () async => ArcaneAuthenticationService.I.registerInterface(auth), + throwsException, + ); + }); + + test("login returns error if no interface registered", () async { + final result = await ArcaneAuthenticationService.I.login(input: {}); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test("logOut returns error if no interface registered", () async { + final result = await ArcaneAuthenticationService.I.logOut(); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test("logOut returns error if not authenticated", () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = await ArcaneAuthenticationService.I.logOut(); + expect(result.isFailure, true); + expect(result.error, contains("not authenticated")); + }); + + test("register returns error if no interface registered", () async { + final result = await ArcaneAuthenticationService.I.register(input: {}); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test("register returns error if interface does not support registration", + () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = await ArcaneAuthenticationService.I.register(input: {}); + expect(result.isFailure, true); + expect(result.error, contains("does not support account registration")); + }); + + test("register returns error if registration returns null", () async { + final auth = MockAccountRegistration(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + when(() => auth.register(input: any(named: "input"))) + .thenAnswer((_) async => const Result.error("returned a null value")); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = await ArcaneAuthenticationService.I.register(input: {}); + expect(result.isFailure, true); + expect(result.error, contains("returned a null value")); + }); + + test("confirmSignup returns error if no interface registered", () async { + final result = await ArcaneAuthenticationService.I + .confirmSignup(email: "a", confirmationCode: "b"); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test( + "confirmSignup returns error if interface does not support registration", + () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = await ArcaneAuthenticationService.I + .confirmSignup(email: "a", confirmationCode: "b"); + expect(result.isFailure, true); + expect(result.error, contains("does not support account registration")); + }); + + test("confirmSignup returns error if confirmSignup returns null", () async { + final auth = MockAccountRegistration(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + when( + () => auth.confirmSignup( + username: any(named: "username"), + confirmationCode: any(named: "confirmationCode"), + ), + ).thenAnswer((_) async => const Result.error("returned a null value")); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = await ArcaneAuthenticationService.I + .confirmSignup(email: "a", confirmationCode: "b"); + expect(result.isFailure, true); + expect(result.error, contains("returned a null value")); + }); + + test("resendVerificationCode returns error if no interface registered", + () async { + final result = + await ArcaneAuthenticationService.I.resendVerificationCode("a"); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test( + "resendVerificationCode returns error if interface does not support registration", + () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = + await ArcaneAuthenticationService.I.resendVerificationCode("a"); + expect(result.isFailure, true); + expect(result.error, contains("does not support account registration")); + }); + + test( + "resendVerificationCode returns error if resendVerificationCode returns null", + () async { + final auth = MockAccountRegistration(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + when(() => auth.resendVerificationCode(input: any(named: "input"))) + .thenReturn(null); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = + await ArcaneAuthenticationService.I.resendVerificationCode("a"); + expect(result.isFailure, true); + expect(result.error, contains("returned a null value")); + }); + + test("resetPassword returns error if no interface registered", () async { + final result = + await ArcaneAuthenticationService.I.resetPassword(email: "a"); + expect(result.isFailure, true); + expect(result.error, contains("No ArcaneAuthInterface")); + }); + + test( + "resetPassword returns error if interface does not support password management", + () async { + final auth = MockArcaneAuthInterface(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = + await ArcaneAuthenticationService.I.resetPassword(email: "a"); + expect(result.isFailure, true); + expect(result.error, contains("does not support password management")); + }); + + test("resetPassword returns error if resetPassword returns null", () async { + final auth = MockPasswordManagement(); + when(() => auth.init()).thenAnswer((_) async { + return null; + }); + when( + () => auth.resetPassword( + email: any(named: "email"), + newPassword: any(named: "newPassword"), + code: any(named: "code"), + ), + ).thenAnswer((_) async => const Result.error("returned a null value")); + await ArcaneAuthenticationService.I.registerInterface(auth); + final result = + await ArcaneAuthenticationService.I.resetPassword(email: "a"); + expect(result.isFailure, true); + expect(result.error, contains("returned a null value")); + }); + + test("dispose closes stream controllers and calls super", () async { + // Just call dispose to ensure no exceptions are thrown + ArcaneAuthenticationService.I.dispose(); + // No assertion needed; just ensure no crash + }); + }); + late ArcaneAuthInterface authInterface; group("ArcaneAuthenticationService", () { From 66a95f17477a6716cb20a6254c4e176eb7decdc1 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 19:03:25 +0200 Subject: [PATCH 56/58] refactor(service): update service access methods in ArcaneServiceProvider Signed-off-by: Hans Kokx --- lib/src/service/service_provider.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/src/service/service_provider.dart b/lib/src/service/service_provider.dart index 0bfa0ed..5b6d011 100644 --- a/lib/src/service/service_provider.dart +++ b/lib/src/service/service_provider.dart @@ -15,7 +15,8 @@ part of "arcane_service.dart"; /// ``` /// To access the provided services: /// ```dart -/// final myService = ArcaneServiceProvider.of(context); +/// final provider = ArcaneServiceProvider.of(context); +/// final myService = ArcaneServiceProvider.serviceOfType(context); /// ``` class ArcaneServiceProvider extends InheritedNotifier>> { @@ -57,9 +58,7 @@ class ArcaneServiceProvider /// ```dart /// final provider = ArcaneServiceProvider.of(context); /// ``` - static ArcaneServiceProvider of( - BuildContext context, - ) { + static ArcaneServiceProvider of(BuildContext context) { final provider = maybeOf(context); assert(provider != null, "No ArcaneServiceProvider found in context"); return provider!; @@ -71,7 +70,7 @@ class ArcaneServiceProvider /// /// Example: /// ```dart - /// final myService = ArcaneServiceProvider.of(context); + /// final myService = ArcaneServiceProvider.serviceOfType(context); /// ``` static T? serviceOfType(BuildContext context) { final provider = maybeOf(context); @@ -86,7 +85,7 @@ class ArcaneServiceProvider /// /// Example: /// ```dart - /// final myService = ArcaneServiceProvider.of(context); + /// final myService = ArcaneServiceProvider.requiredServiceOfType(context); /// ``` static T requiredServiceOfType( BuildContext context, From 0e758c40f2b387a24e5dff557d828e830d2cdece Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 19:05:32 +0200 Subject: [PATCH 57/58] refactor(tests): format login and logout method signatures in MockAuth for improved readability Signed-off-by: Hans Kokx --- .../authentication_enums_test.dart | 2 +- .../authentication_interface_test.dart | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/test/services/authentication/authentication_enums_test.dart b/test/services/authentication/authentication_enums_test.dart index 319fdf8..6eff24c 100644 --- a/test/services/authentication/authentication_enums_test.dart +++ b/test/services/authentication/authentication_enums_test.dart @@ -1,5 +1,5 @@ -import "package:flutter_test/flutter_test.dart"; import "package:arcane_framework/src/services/authentication/authentication_service.dart"; +import "package:flutter_test/flutter_test.dart"; void main() { group("SignUpStep", () { diff --git a/test/services/authentication/authentication_interface_test.dart b/test/services/authentication/authentication_interface_test.dart index 22ca589..46f8d43 100644 --- a/test/services/authentication/authentication_interface_test.dart +++ b/test/services/authentication/authentication_interface_test.dart @@ -4,8 +4,10 @@ import "package:result_monad/result_monad.dart"; class MockAuth implements ArcaneAuthInterface { @override - Future> login( - {T? input, Future Function()? onLoggedIn}) async { + Future> login({ + T? input, + Future Function()? onLoggedIn, + }) async { if (onLoggedIn != null) await onLoggedIn(); return const Result.ok(null); } @@ -23,8 +25,9 @@ class MockAuth implements ArcaneAuthInterface { Future init() async {} @override - Future> logout( - {Future Function()? onLoggedOut}) async { + Future> logout({ + Future Function()? onLoggedOut, + }) async { if (onLoggedOut != null) await onLoggedOut(); return const Result.ok(null); } @@ -37,9 +40,11 @@ void main() { expect(await auth.accessToken, "token"); expect(await auth.refreshToken, "refresh"); var called = false; - await auth.logout(onLoggedOut: () async { - called = true; - }); + await auth.logout( + onLoggedOut: () async { + called = true; + }, + ); expect(called, isTrue); }); } From 6c2f01d86af5b100c078001235c344b6f82e0d1c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 25 May 2026 19:07:37 +0200 Subject: [PATCH 58/58] feat(tests): add unit tests for ArcaneThemeService functionality Signed-off-by: Hans Kokx --- .gitignore | 1 + test/services/theme/theme_service_test.dart | 52 +++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 test/services/theme/theme_service_test.dart diff --git a/.gitignore b/.gitignore index e2a4f4e..ffed392 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .history .svn/ migrate_working_dir/ +coverage/ # IntelliJ related *.iml diff --git a/test/services/theme/theme_service_test.dart b/test/services/theme/theme_service_test.dart new file mode 100644 index 0000000..89b9a62 --- /dev/null +++ b/test/services/theme/theme_service_test.dart @@ -0,0 +1,52 @@ +import "package:arcane_framework/src/services/theme/theme_service.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneThemeService", () { + setUp(() { + ArcaneThemeService.I.reset(); + }); + + test("default state is light theme, not following system", () { + expect(ArcaneThemeService.I.isFollowingSystemTheme, isFalse); + expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light); + expect(ArcaneThemeService.I.currentTheme, isA()); + }); + + test("switchTheme toggles between light and dark", () { + ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.light); + expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light); + ArcaneThemeService.I.switchTheme(); + expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.dark); + ArcaneThemeService.I.switchTheme(); + expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light); + }); + + test("setDarkTheme and setLightTheme update themes", () { + final customDark = + ThemeData(primaryColor: Colors.red, brightness: Brightness.dark); + final customLight = + ThemeData(primaryColor: Colors.blue, brightness: Brightness.light); + ArcaneThemeService.I.setDarkTheme(customDark); + expect(ArcaneThemeService.I.dark, customDark); + ArcaneThemeService.I.setLightTheme(customLight); + expect(ArcaneThemeService.I.light, customLight); + }); + + test("reset restores defaults", () { + ArcaneThemeService.I.setDarkTheme( + ThemeData(primaryColor: Colors.red, brightness: Brightness.dark),); + ArcaneThemeService.I.setLightTheme( + ThemeData(primaryColor: Colors.blue, brightness: Brightness.light),); + ArcaneThemeService.I.reset(); + expect(ArcaneThemeService.I.dark, ThemeData.dark()); + expect(ArcaneThemeService.I.light, ThemeData.light()); + expect(ArcaneThemeService.I.isFollowingSystemTheme, isFalse); + }); + + test("dispose does not throw", () { + ArcaneThemeService.I.dispose(); + }); + }); +}