diff --git a/.github/workflows/analyze-and-unit-test.yaml b/.github/workflows/analyze-and-unit-test.yaml new file mode 100644 index 0000000..52da24f --- /dev/null +++ b/.github/workflows/analyze-and-unit-test.yaml @@ -0,0 +1,21 @@ +name: Tests +on: + workflow_dispatch: + pull_request: +jobs: + test: + name: Analyze and test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + - name: Install dependencies + run: flutter pub get + - name: Analyze + run: flutter analyze + - name: Test + run: flutter test diff --git a/.gitignore b/.gitignore index f841c92..4a189ec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ .history .svn/ migrate_working_dir/ +coverage/ +example/.metadata # IntelliJ related *.iml @@ -19,7 +21,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. @@ -50,3 +52,7 @@ app.*.map.json **/android/app/debug **/android/app/profile **/android/app/release + +# Example builds +**/example/.metadata +**/example/web/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ee38f..f55618e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,293 @@ +## Unreleased + +### 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. +- [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). +- [DEPRECATED] `BuildContext.serviceOfType()` is now deprecated in favor of + `BuildContext.service()`. + +### 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` 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. + +#### 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 + `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 + `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. +- [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(...)`. +- [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. + +### 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 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). +- [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. +- [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. +- [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. + +#### 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. +- [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 + `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 + `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). +- [CHANGE] Updated `Arcane.log` metadata type from `Map?` to + `Map?` to support structured metadata values. + +#### Migration Steps (LoggingInterface) + +1. Remove `initialized` and `init` from interfaces that do not require startup + work. +2. If an interface requires startup/lifecycle management, add + `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. +4. Run tests to verify interface registration and logging behavior still match + expectations. + +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 LoggingInitialization { + @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. + +#### 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] Updated `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`. + ## 1.2.7 - Updated dependencies to latest @@ -66,7 +356,7 @@ These methods have been moved to mixin classes. To continue using them, please update your ArcaneAuthInterface implementations. - To use `resendVerificationCode`, `register` and `confirmSignup`, use the new -`ArcaneAuthAccountRegistration` mixin. + `ArcaneAuthAccountRegistration` mixin. - To use `resetPassword`, use the new `ArcaneAuthPasswordManagement` mixin. ### Migration diff --git a/README.md b/README.md index 83ac62c..138b06d 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,324 @@ -# Arcane Framework: Agnostic Reusable Component Architecture for New Ecosystems +# Arcane Framework -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. +> _**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)](https://pub.dev/packages/arcane_analysis) +![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, 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) + - [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) + - [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) + - [Application Environments](#application-environments) + - [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 + `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 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. -## 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 + ``` - ```yaml - dependencies: - arcane_framework: - ``` + 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: - 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'; + + void main() { + runApp( + ArcaneApp( + builder: (context, _) => MainApp(), + ), + ); + } + ``` - ```dart - import 'package:arcane_framework/arcane_framework.dart'; + `ArcaneApp.child` remains available for backward compatibility, but is + deprecated in favor of `ArcaneApp.builder`. - void main() { - runApp( - ArcaneApp( - services: [ - MyArcaneService.I, - ], - child: MyApp(...), - ), - ); - } - ``` +### 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(), +) +``` + +### 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 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. 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, while optionally leveraging 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. +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. + +The following tools are provided by Arcane to assist with creating and using +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**: 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` + +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, leveraging a `ValueNotifier` to trigger rebuilds as +appropriate: ```dart class FavoriteColorService extends ArcaneService { - static final FavoriteColorService _instance = FavoriteColorService._internal(); - static FavoriteColorService get I => _instance; + FavoriteColorService(); - FavoriteColorService._internal(); + final ValueNotifier _notifier = ValueNotifier(null); + ValueNotifier get notifier => _notifier; Color? get myFavoriteColor => _notifier.value; - final ValueNotifier _notifier = ValueNotifier(null); - - ValueNotifier get notifier => _notifier; - - void setMyFavoriteColor(Color? newValue) { - if (_notifier.value != newValue) { - _notifier.value = newValue; + 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( services: [ - FavoriteColorService.I, + FavoriteColorService(), ], - child: MyApp(...), + builder: (context, _) => MainApp(), ), ``` -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 -FavoriteColorService.I.notifier.addListener(() { - final Color? color = FavoriteColorService.I.myFavoriteColor; - // Do something with the value +// The service is not included at compile-time +ArcaneApp( + builder: (context, _) => MainApp(), +), + +// Add the service at runtime +ArcaneServiceProvider.of(context).addService(FavoriteColorService()); +``` + +Unregistering an already registered `ArcaneService` is as simple as: + +```dart +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 +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 use 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. +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 +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: @@ -118,10 +337,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. @@ -129,11 +349,16 @@ Next, ensure that your features are enabled at startup by registering them withi if (feature.enabledAtStartup) Arcane.features.enableFeature(feature); } - runApp(const ArcaneApp()); + runApp( + ArcaneApp( + builder: (context, _) => MainApp(), + ), + ); } ``` -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 @@ -155,13 +380,18 @@ 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. +`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. ```dart Arcane.features.notifier.addListener(() { @@ -169,58 +399,219 @@ 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. +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(); +} +``` + +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"); + } +} +``` + +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. ### 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 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", ); } - - @override - Future init() async => I; } ``` -Next, register your logging interface with the Arcane logger service: +If your destination needs setup (SDK start, permission checks, etc.), opt into +the initialization lifecycle with `LoggingInitialization`: ```dart -// Register your logging interface(s) -await Arcane.logger.registerInterfaces([ - DebugConsole.I, -]); +class ExternalLogger extends LoggingInterface with LoggingInitialization { + @override + Future init() async { + if (initialized) return; -// Initialize registered logging interfaces -// NOTE: This step may be deferred until a user has consented to app tracking. + // 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. + } +} +``` + +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. + +```dart +final DebugConsole debugConsole = DebugConsole(); + +await Arcane.logger.registerInterface( + debugConsole, + interceptors: [ + LogInterceptor((event, context) { + if (context.interface is DebugConsole && event.level == Level.debug) { + return null; + } + + return event; + }), + ], +); + +Arcane.logger.registerInterceptor( + LogInterceptor((event, context) { + return event.copyWith( + metadata: { + ...?event.metadata, + "session": "startup", + }, + ); + }), +); + +// Optional: initialize only interfaces that implement LoggingInitializable +// (for example, SDK-backed loggers that mix in LoggingInitialization). await Arcane.logger.initializeInterfaces(); ``` -Finally, add any additional persistent metadata to your log messages (optional) and log a message: +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: ```dart // Add metadata to the logger @@ -235,20 +626,117 @@ Arcane.log( level: Level.debug, module: "ModuleName", method: "MethodName", - metadata: {"key": "value"}, + metadata: { + "key": "value", + "attempt": 1, + "retryable": true, + }, stackTrace: StackTrace.current, ); + +// Optional: skip automatic module/method/file-line detection. +Arcane.log( + "Manual log routing", + module: "CustomModule", + method: "customMethod", + skipAutodetection: true, +); ``` -Multiple logging interfaces can be registered simultaneously. +You can also listen to `logStream` for realtime log events, and cancel and +re-register subscribers as widget lifecycles change: -**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). +```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 +prefer, you can also define your own interceptor class by implementing +`LogInterceptor` instead of using the callback constructor. + +```dart +final LogInterceptor redactSecrets = LogInterceptor(( + event, + 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**: Initialization is now optional per interface. Call +`initializeInterfaces()` when you have interfaces that opt into +`LoggingInitializable` (for example via `LoggingInitialization`). Simple +destinations like a debug console can skip initialization entirely. ### 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"; @@ -278,11 +766,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); } @@ -366,15 +858,16 @@ 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 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 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 @@ -401,19 +894,85 @@ 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: + +```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(); +} +``` + +### 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 ArcaneEnvironmentService environment = Arcane.environment; + + return ElevatedButton( + onPressed: () { + environment.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. + +`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`. + ### 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() { @@ -424,7 +983,7 @@ void main() { runApp( ArcaneApp( - child: MainApp(), + builder: (context, _) => MainApp(), ), ); } @@ -433,47 +992,34 @@ 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 Widget build(BuildContext context) { - return ArcaneApp( - child: MaterialApp( - theme: Arcane.theme.light, - darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.systemTheme.value, - ), + return MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), ); } - - @override - void didChangeDependencies() { - Arcane.theme.followSystemTheme(context); - super.didChangeDependencies(); - } } ``` -or manually control the theme mode: +You can also 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, - ), + return MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), ); } } @@ -486,7 +1032,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; @@ -497,16 +1043,49 @@ 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 +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, 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/.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/README.md b/example/README.md index 1b7a4e3..269f5b5 100644 --- a/example/README.md +++ b/example/README.md @@ -1,3 +1,48 @@ -# 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 (with automatic initial sync when using `ArcaneApp`) +- 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 +- 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/config.dart b/example/lib/config.dart index 2868ef5..8aa998a 100644 --- a/example/lib/config.dart +++ b/example/lib/config.dart @@ -1,7 +1,21 @@ +import "package:flutter/material.dart"; + enum Feature { logging(true), + authentication(true), ; 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/interfaces/debug_auth_interface.dart b/example/lib/interfaces/debug_auth_interface.dart index 2c2f54e..7a5f878 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); @@ -25,24 +22,28 @@ class DebugAuthInterface ); @override - Future> logout() async { + Future> logout({ + Future Function()? onLoggedOut, + }) async { Arcane.log("Logging out"); _isSignedIn = false; - return Result.ok(null); + await onLoggedOut?.call(); + + 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; @@ -51,7 +52,9 @@ class DebugAuthInterface _isSignedIn = true; - return Result.ok(null); + await onLoggedIn?.call(); + + return const Result.ok(null); } @override @@ -59,15 +62,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; @@ -75,7 +78,7 @@ class DebugAuthInterface Arcane.log("Creating account for $email with password $password"); } - return Result.ok(SignUpStep.confirmSignUp); + return const Result.ok(SignUpStep.confirmSignUp); } @override @@ -86,7 +89,7 @@ class DebugAuthInterface Arcane.log( "Confirming registration for $username with code $confirmationCode", ); - return Result.ok(true); + return const Result.ok(true); } @override @@ -96,7 +99,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/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 565ba60..efd751e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,135 +1,660 @@ +import "dart:async"; + 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/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(); + + // 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( + debugPrintInterface, + interceptors: [ + LogInterceptor((event, context) { + if (context.interface is DebugPrint && Feature.logging.disabled) { + return null; + } - Arcane.logger.addPersistentMetadata({ - "session_id": IdService.I.sessionId.value, - }); + return event; + }), + ], + ); - await Arcane.auth.registerInterface(DebugAuthInterface.I); + // Register the authentication interface + await Arcane.auth.registerInterface(debugAuthInterface); + // 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(const MainApp()); + runApp( + // The `ArcaneApp` widget is optional but provides Arcane's built-in + // service, feature flag, environment, and theme integration widgets. + // It also performs initial platform theme synchronization automatically + // via ArcaneThemeSwitcher. + ArcaneApp( + builder: (context, _) => const MainApp(), + ), + ); } -class MainApp extends StatefulWidget { +class MainApp extends StatelessWidget { const MainApp({super.key}); - @override - State createState() => _MainAppState(); -} - -class _MainAppState extends State { @override Widget build(BuildContext context) { - return ArcaneApp( - services: [ - IdService.I, - ], - child: MaterialApp( - debugShowCheckedModeBanner: false, - theme: Arcane.theme.light, - darkTheme: Arcane.theme.dark, - themeMode: Arcane.theme.currentMode, - home: Scaffold( - appBar: AppBar( - title: const Text("Arcane Framework Example"), - actions: [ - IconButton( - icon: const Icon(Icons.contrast), - onPressed: () { - Arcane.theme.switchTheme(); - setState(() {}); - }, - ), - ], - ), - body: const HomeScreen(), + return MaterialApp( + theme: Arcane.theme.light, + darkTheme: Arcane.theme.dark, + themeMode: Arcane.theme.currentModeOf(context), + home: Scaffold( + appBar: AppBar( + title: const Text("Arcane Framework Example"), ), - ), - ); - } -} - -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - @override - Widget build(BuildContext context) { - final bool isSignedIn = Arcane.auth.isSignedIn.value; - return Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: Column( children: [ - Text( - "Authentication status: ${Arcane.auth.status.name}", + Expanded( + child: GridView.extent( + maxCrossAxisExtent: 300, + padding: const EdgeInsets.all(16), + children: const [ + ArcaneThemeExample(), + ArcaneAuthExample(), + ArcaneFeatureFlagsExample(), + ArcaneEnvironmentExample(), + ArcaneServicesExample(), + ], + ), ), - if (isSignedIn) - ElevatedButton( - child: const Text("Sign out"), - onPressed: () async { - await Arcane.auth.logOut( - onLoggedOut: () async { - setState(() {}); - }, - ); - }, - ), - if (!isSignedIn) - ElevatedButton( - child: const Text("Sign in"), - onPressed: () async { - await Arcane.auth.login>( - input: { - "email": "email", - "password": "password", - }, - onLoggedIn: () async { - setState(() {}); - }, - ); - }, - ), + const ArcaneLoggingExample(), ], ), ), ); } } + +// * 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() => _ArcaneLoggingExampleState(); +} + +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. + late final StreamSubscription _logStreamSubscriber; + + // Used to collect the logs from the stream. + final List latestLogs = []; + bool _persistentMetadataEnabled = false; + + @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 + unawaited(_logStreamSubscriber.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: Arcane.features.notifier, + builder: (context, enabledFeatures, _) { + return Padding( + padding: const EdgeInsets.all(16.0), + 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, + ), + 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", + 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, + }); + + @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: 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}"), + ), + ], + ); + }, + ), + ), + ); + }, + ); + } +} + +// * Theme +// 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, + }); + + static final Listenable themeListenable = + Listenable.merge([Arcane.theme.darkTheme, Arcane.theme.lightTheme]); + + @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: context.isDarkMode, + thumbIcon: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Icon(Icons.dark_mode); + } + return const Icon(Icons.light_mode); + }), + onChanged: (_) { + // 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, + }, + ); + }, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: Arcane.theme.isFollowingSystemTheme, + onChanged: (value) { + if (value == true) { + Arcane.theme.followSystemTheme(context); + } else { + // 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"), + ], + ), + ], + ), + 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, 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, + }); + + @override + Widget build(BuildContext context) { + 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); + }, + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +// * 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 { + 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( + 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 ArcaneEnvironmentService environment = Arcane.environment; + final Environment previousEnvironment = environment.current; + final Environment nextEnvironment = _nextEnvironment( + previousEnvironment, + ); + + environment.setEnvironment(nextEnvironment); + + Arcane.log( + "Environment changed.", + metadata: { + "previous": previousEnvironment.name, + "current": nextEnvironment.name, + }, + ); + }, + child: const Text("Cycle environment"), + ), + ValueListenableBuilder( + valueListenable: Arcane.environment.notifier, + builder: (context, environment, _) { + return Text( + "Environment: ${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 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, + ), + 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, + ), + ), + ); + }, + ), + ), + ], + ), + ), + 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/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/example/lib/services/favorite_color_service.dart b/example/lib/services/favorite_color_service.dart new file mode 100644 index 0000000..13e6f25 --- /dev/null +++ b/example/lib/services/favorite_color_service.dart @@ -0,0 +1,102 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; + +class FavoriteColorService extends ArcaneService { + FavoriteColorService(); + + MaterialColor? get myFavoriteColor => _notifier.value; + + final ValueNotifier _notifier = + ValueNotifier(null); + + ValueNotifier get notifier => _notifier; + + void setMyFavoriteColor(MaterialColor? newValue) { + if (_notifier.value == newValue) return; + + _notifier.value = newValue; + + if (newValue == null) return; + + // 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) { + 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); + } +} + +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)); + 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 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; + } +} 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, +); diff --git a/lib/arcane_framework.dart b/lib/arcane_framework.dart index 855f01a..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,10 +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_provider.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/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 5ebe7ca..414adec 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. @@ -6,45 +13,63 @@ import "package:arcane_framework/arcane_framework.dart"; /// `Arcane` provides access to important services like logging, feature flags, /// authentication, theming, secure storage, and ID management. It also offers a /// convenient method for logging messages using the integrated logger. -class Arcane { - Arcane._internal(); +abstract class Arcane { + // Internal registry for service instances, set by ArcaneApp if present. + static ValueNotifier>? registry; - /// Creates a singleton instance of `Arcane`. - /// - /// This factory constructor always returns the same instance of `Arcane`. - factory Arcane() => Arcane._internal(); + // 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 => List.unmodifiable([ + 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 => + List.unmodifiable(registry?.value ?? builtInServices); /// Logs a message using the integrated logger. /// @@ -63,16 +88,21 @@ 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. static void log( String message, { String? module, String? method, Level level = Level.debug, StackTrace? stackTrace, - Map? metadata, + Map? metadata, Object? extra, + bool skipAutodetection = false, }) { ArcaneLogger.I.log( message, @@ -82,6 +112,7 @@ class Arcane { stackTrace: stackTrace, metadata: metadata, extra: extra, + skipAutodetection: skipAutodetection, ); } } diff --git a/lib/src/arcane_app.dart b/lib/src/arcane_app.dart index 01ddd0d..b938d29 100644 --- a/lib/src/arcane_app.dart +++ b/lib/src/arcane_app.dart @@ -1,53 +1,188 @@ -import "package:arcane_framework/arcane_framework.dart"; +import "package:arcane_framework/src/service/arcane_service.dart"; +import "package:collection/collection.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 /// 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 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: [ArcaneAuthenticationService(), ArcaneFeatureFlags()], -/// child: MyApp(), +/// services: [MyArcaneService()], +/// builder: (context, _) => 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; + /// 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. /// - /// This widget will be wrapped by the service and environment providers. - 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]. + /// A root widget for an Arcane-powered application. /// - /// The [child] is required, while the [services] list is optional. By - /// default, the [services] list is empty. + /// `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({ - 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(); +} + +class _ArcaneAppState extends State { + static const ListEquality _serviceListEquality = + ListEquality(IdentityEquality()); + + 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 didUpdateWidget(covariant ArcaneApp oldWidget) { + super.didUpdateWidget(oldWidget); + + final List mergedServices = _computeMergedServices(); + if (!_serviceListEquality.equals(_serviceNotifier.value, mergedServices)) { + _serviceNotifier.value = mergedServices; + } + } + + @override + void dispose() { + Arcane.clearRegistry(); + _serviceNotifier.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return ArcaneEnvironmentProvider( - child: ArcaneServiceProvider( - serviceInstances: services, - child: child, + 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: appChild, + ), + ), ), ); } diff --git a/lib/src/providers/environment_provider.dart b/lib/src/providers/environment_provider.dart deleted file mode 100644 index 6a5fc35..0000000 --- a/lib/src/providers/environment_provider.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:arcane_framework/arcane_framework.dart"; -import "package:flutter/widgets.dart"; -import "package:flutter_bloc/flutter_bloc.dart"; - -/// A `Cubit` that manages the application environment state. -/// -/// The `ArcaneEnvironment` cubit holds the current environment (`debug` or `normal`) -/// and provides a method to enable debug mode. -class ArcaneEnvironment extends Cubit { - /// Initializes the cubit with the `normal` environment as the default state. - ArcaneEnvironment() : super(Environment.normal); - - /// Enables debug mode by setting the environment to `Environment.debug`. - void enableDebugMode() { - if (state == Environment.debug) return; - - emit(Environment.debug); - } - - /// Disables debug mode by setting the environment to `Environment.normal`. - void disableDebugMode() { - if (state == Environment.normal) return; - - emit(Environment.normal); - } -} - -/// A widget that provides `ArcaneEnvironment` to the widget tree using `BlocProvider`. -/// -/// This widget wraps around a child widget and makes `ArcaneEnvironment` available -/// to the rest of the widget tree. It should be used in combination with `BlocProvider` -/// from the `flutter_bloc` package. -/// -/// Example: -/// ```dart -/// ArcaneEnvironmentProvider( -/// child: MyApp(), -/// ); -/// ``` -class ArcaneEnvironmentProvider extends StatelessWidget { - /// The widget that will be provided with access to the `ArcaneEnvironment`. - final Widget child; - - /// Constructs an `ArcaneEnvironmentProvider` with the given [child]. - const ArcaneEnvironmentProvider({required this.child, super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ArcaneEnvironment(), - child: child, - ); - } -} diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart deleted file mode 100644 index 46793f8..0000000 --- a/lib/src/providers/service_provider.dart +++ /dev/null @@ -1,110 +0,0 @@ -import "package:arcane_framework/arcane_framework.dart"; -import "package:collection/collection.dart"; -import "package:flutter/widgets.dart"; - -/// A provider that makes a list of `ArcaneService` instances available to the widget tree. -/// -/// This class extends `InheritedNotifier` and allows `ArcaneService` instances to be -/// accessed throughout the widget tree by descendant widgets. It should be used to -/// provide service instances that are shared across the application. -/// -/// Example: -/// ```dart -/// ArcaneServiceProvider( -/// serviceInstances: [myService], -/// child: MyApp(), -/// ); -/// ``` -/// To access the provided services: -/// ```dart -/// final provider = ArcaneServiceProvider.of(context); -/// ``` -class ArcaneServiceProvider extends InheritedNotifier { - /// A list of `ArcaneService` instances available through the provider. - final List serviceInstances; - - /// 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, - required super.child, - 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(ArcaneServiceProvider oldWidget) { - return true; - } - - /// 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. - /// - /// Example: - /// ```dart - /// final provider = ArcaneServiceProvider.of(context); - /// ``` - static ArcaneServiceProvider of(BuildContext context) { - final ArcaneServiceProvider? result = - context.dependOnInheritedWidgetOfExactType(); - - if (result == null) { - throw Exception("ArcaneServiceProvider not found in context"); - } - - return result; - } -} - -/// 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. -/// -/// Example usage: -/// ```dart -/// final MyService? myService = context.serviceOfType(); -/// ``` -extension ServiceProvider 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(); - /// ``` - T? serviceOfType() { - final T? builtInService = - Arcane.services.firstWhereOrNull((s) => s.runtimeType == T) as T?; - - if (builtInService != null) return builtInService; - - final T? foundService = - dependOnInheritedWidgetOfExactType() - ?.serviceInstances - .firstWhereOrNull((s) => s.runtimeType == T) as T?; - return foundService; - } -} - -/// 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` -/// method on `BuildContext`. -abstract class ArcaneService with ChangeNotifier {} diff --git a/lib/src/service/arcane_service.dart b/lib/src/service/arcane_service.dart new file mode 100644 index 0000000..f782142 --- /dev/null +++ b/lib/src/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/service/service_provider.dart b/lib/src/service/service_provider.dart new file mode 100644 index 0000000..5f156d7 --- /dev/null +++ b/lib/src/service/service_provider.dart @@ -0,0 +1,146 @@ +part of "arcane_service.dart"; + +/// A provider that makes a list of `ArcaneService` instances available to the widget tree. +/// +/// This class extends `InheritedNotifier` and allows `ArcaneService` instances to be +/// accessed throughout the widget tree by descendant widgets. It should be used to +/// provide service instances that are shared across the application. +/// +/// Example: +/// ```dart +/// ArcaneServiceProvider( +/// serviceInstances: [myService], +/// child: MyApp(), +/// ); +/// ``` +/// To access the provided services: +/// ```dart +/// final provider = ArcaneServiceProvider.of(context); +/// final myService = ArcaneServiceProvider.serviceOfType(context); +/// ``` +class ArcaneServiceProvider + extends InheritedNotifier>> { + /// A list of `ArcaneService` instances available through the provider. + List get registeredServices => + List.from([...?notifier?.value]); + + /// Creates an `ArcaneServiceProvider` that provides [serviceInstances] to the widget tree. + /// + /// 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: serviceNotifier ?? + ValueNotifier>(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. + /// + /// Throws an assertion error if no provider is found. + /// + /// Example: + /// ```dart + /// final provider = ArcaneServiceProvider.of(context); + /// ``` + 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.serviceOfType(context); + /// ``` + static T? serviceOfType(BuildContext context) { + final provider = maybeOf(context); + if (provider == null) return null; + + 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.requiredServiceOfType(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. + 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 = registeredServices.indexWhere( + (s) => s.runtimeType == service.runtimeType, + ); + + final List newList = + List.from(registeredServices); + + if (existingIndex >= 0) { + newList[existingIndex] = service; + } else { + newList.add(service); + } + + notifier?.value = newList; + } + + /// Removes all services of the specified type from the registry. + /// Returns true if any services were removed, false otherwise. + bool removeService() { + final List newList = + List.from(registeredServices); + final int originalLength = newList.length; + + newList.removeWhere((service) => service.runtimeType == T); + + if (newList.length == originalLength) { + return false; + } + + notifier?.value = newList; + return true; + } +} diff --git a/lib/src/service/service_provider_extensions.dart b/lib/src/service/service_provider_extensions.dart new file mode 100644 index 0000000..1466aea --- /dev/null +++ b/lib/src/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 provider-registered services so app-specific overrides win. + final providerService = ArcaneServiceProvider.serviceOfType(this); + if (providerService != null) return providerService; + + // Fall back to built-in services. + return Arcane.services.whereType().firstOrNull; + } + + /// 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("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 bca4e31..a0dd540 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; @@ -55,16 +48,3 @@ enum AuthenticationStatus { /// Returns `true` if the current status is `unauthenticated`. bool get isUnauthenticated => this == unauthenticated; } - -/// An enum representing the different application environments. -/// -/// 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, - - /// The normal environment for production use. - normal, -} 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 fb22e97..5ad05ab 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -2,7 +2,6 @@ import "dart:async"; import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/widgets.dart"; -import "package:flutter_bloc/flutter_bloc.dart"; part "authentication_enums.dart"; part "authentication_interface.dart"; @@ -26,12 +25,26 @@ class ArcaneAuthenticationService extends ArcaneService { /// A `ValueNotifier` that emits the current `AuthenticationStatus`. ValueNotifier get notifier => _notifier; - /// Returns the current `AuthenticationStatus`. + 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` 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. /// - `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; @@ -40,23 +53,33 @@ 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); /// 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. - Future get accessToken => + Future get accessToken async => authInterface?.accessToken ?? Future.value(""); /// Returns a JWT refresh token if the registered `ArcaneAuthInterface` /// provides one. - Future get refreshToken => + Future get refreshToken async => authInterface?.refreshToken ?? Future.value(""); /// Removes any registered `ArcaneAuthInterface` and resets all values to @@ -65,7 +88,8 @@ class ArcaneAuthenticationService extends ArcaneService { _authInterface = null; _notifier.value = AuthenticationStatus.unauthenticated; _isSignedIn.value = isAuthenticated; - notifyListeners(); + _statusController.add(_notifier.value); + _signedInController.add(_isSignedIn.value); } /// Registers an `ArcaneAuthInterface` within the `ArcaneAuthenticationService`. @@ -78,64 +102,36 @@ 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, { + BuildContext _, { Future Function()? onDebugModeSet, }) async { - ArcaneEnvironment? environment; + final Environment previousEnvironment = Arcane.environment.current; - try { - environment = context.read(); - final Environment previousEnvironment = environment.state; + if (previousEnvironment == Environment.debug) return; - if (previousEnvironment == Environment.debug) return; + Arcane.environment.enableDebugMode(); - environment.enableDebugMode(); - - final Environment currentEnvironment = environment.state; - - if (previousEnvironment == currentEnvironment) { - throw Exception("Unable to switch to debug mode."); - } - - _setStatus(AuthenticationStatus.debug); - if (onDebugModeSet != null) await onDebugModeSet(); - } catch (_) { - throw Exception("No ArcaneEnvironment found in BuildContext"); - } + if (onDebugModeSet != null) await onDebugModeSet(); } - /// 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, { + BuildContext _, { Future Function()? onDebugModeUnset, }) async { - ArcaneEnvironment? environment; + final Environment previousEnvironment = Arcane.environment.current; - try { - environment = context.read(); - final Environment previousEnvironment = environment.state; + if (previousEnvironment == Environment.normal) return; - if (previousEnvironment == Environment.normal) return; + Arcane.environment.disableDebugMode(); - environment.disableDebugMode(); - - final Environment currentEnvironment = environment.state; - - if (previousEnvironment == currentEnvironment) { - throw Exception("Unable to switch to normal mode."); - } - - _setStatus(AuthenticationStatus.debug); - if (onDebugModeUnset != null) await onDebugModeUnset(); - } catch (_) { - throw Exception("No ArcaneEnvironment found in BuildContext"); - } + if (onDebugModeUnset != null) await onDebugModeUnset(); } /// Sets `status` to `AuthenticationStatus.authenticated`. @@ -152,8 +148,18 @@ class ArcaneAuthenticationService extends ArcaneService { if (_notifier.value != newStatus) { _notifier.value = newStatus; _isSignedIn.value = isAuthenticated; + _statusController.add(_notifier.value); + _signedInController.add(_isSignedIn.value); } - notifyListeners(); + } + + @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 @@ -162,16 +168,19 @@ 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) { + return const 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(); } return loggedOut; @@ -183,16 +192,16 @@ 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( input: input, + onLoggedIn: onLoggedIn, ); if (result.isSuccess) { setAuthenticated(); - if (onLoggedIn != null) await onLoggedIn(); } return result; @@ -205,11 +214,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.", ); } @@ -221,7 +230,7 @@ class ArcaneAuthenticationService extends ArcaneService { ); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } @@ -236,11 +245,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.", ); } @@ -253,7 +262,7 @@ class ArcaneAuthenticationService extends ArcaneService { ); if (result == null) { - return Result.error( + return const Result.error( "Registered ArcaneAuthInterface returned a null value.", ); } @@ -265,11 +274,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.", ); } @@ -280,7 +289,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.", ); } @@ -300,11 +309,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.", ); } @@ -318,7 +327,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/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/services/environment/environment_provider.dart b/lib/src/services/environment/environment_provider.dart new file mode 100644 index 0000000..b5a09da --- /dev/null +++ b/lib/src/services/environment/environment_provider.dart @@ -0,0 +1,137 @@ +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 +/// 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; + + final ValueChanged _switchEnvironment; + + /// Creates an `ArcaneEnvironment` widget. + const ArcaneEnvironment({ + required this.environment, + required void Function(Environment) switchEnvironment, + required super.child, + super.key, + }) : _switchEnvironment = switchEnvironment; + + /// Retrieves the `ArcaneEnvironment` instance from the nearest ancestor. + /// + /// Returns `null` if no `ArcaneEnvironment` ancestor is found. + static ArcaneEnvironment? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Retrieves the `ArcaneEnvironment` instance from the nearest ancestor. + /// + /// Throws a `StateError` if no `ArcaneEnvironment` ancestor is found. + static ArcaneEnvironment of(BuildContext context) { + final ArcaneEnvironment? result = maybeOf(context); + if (result == null) { + throw StateError("No ArcaneEnvironment found in context"); + } + return result; + } + + @override + bool updateShouldNotify(ArcaneEnvironment oldWidget) { + return environment != oldWidget.environment; + } + + void setEnvironment(Environment environment) => + _switchEnvironment(environment); + + void enableDebugMode() => _switchEnvironment(Environment.debug); + void disableDebugMode() => _switchEnvironment(Environment.normal); +} + +/// A `StatefulWidget` that manages and provides the `ArcaneEnvironment`. +/// +/// This widget holds the internal state of the environment and rebuilds +/// its descendants when the environment changes. +class ArcaneEnvironmentProvider extends StatefulWidget { + /// The child widget that will have access to the `ArcaneEnvironment`. + final Widget child; + + /// The initial environment state. Defaults to `Environment.normal`. + final Environment environment; + + /// Creates an `ArcaneEnvironmentProvider`. + const ArcaneEnvironmentProvider({ + required this.child, + Key? key, + this.environment = Environment.normal, + }) : super(key: key); + + @override + State createState() => + _ArcaneEnvironmentProviderState(); +} + +class _ArcaneEnvironmentProviderState extends State { + late Environment _environment; + + void _handleEnvironmentChange() { + if (!mounted) return; + + final nextEnvironment = Arcane.environment.current; + if (nextEnvironment == _environment) return; + + setState(() { + _environment = nextEnvironment; + }); + } + + @override + void initState() { + super.initState(); + _environment = Arcane.environment.current; + + if (_environment != widget.environment) { + Arcane.environment.setEnvironment(widget.environment); + _environment = Arcane.environment.current; + } + + 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`. + void enableDebugMode() { + if (_environment == Environment.debug) return; + 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) { + Arcane.environment.setEnvironment(environment); + } + + @override + Widget build(BuildContext context) { + return ArcaneEnvironment( + environment: _environment, + switchEnvironment: setEnvironment, + child: widget.child, + ); + } +} diff --git a/lib/src/services/environment/environment_service.dart b/lib/src/services/environment/environment_service.dart new file mode 100644 index 0000000..f0e4243 --- /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 [current] 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 current => _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 fa06c3d..817729c 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -1,31 +1,45 @@ +import "dart:async"; + import "package:arcane_framework/arcane_framework.dart"; 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. @@ -37,6 +51,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; @@ -63,27 +89,26 @@ 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; - _enabledFeatures.add(feature); - _notifier.value.add(feature); + _notifier.value = [..._enabledFeatures, feature]; + _enabledFeaturesController.add(List.from(_notifier.value)); 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(): "✅", }, ); } - notifyListeners(); return I; } @@ -94,26 +119,25 @@ 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; - _enabledFeatures.remove(feature); - _notifier.value.remove(feature); + _notifier.value = [..._enabledFeatures]..removeWhere((i) => i == feature); + _enabledFeaturesController.add(List.from(_notifier.value)); 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(): "❌", }, ); } - notifyListeners(); return I; } @@ -123,10 +147,34 @@ 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(); - + if (I._initialized) return; + reset(); I._initialized = true; - notifyListeners(); + } + + /// Resets the feature flags to their initial state. + /// + /// 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 = []; + _enabledFeaturesController.add(List.from(_notifier.value)); + I._initialized = false; + } + + @override + void dispose() { + unawaited(_enabledFeaturesStreamController?.close()); + _enabledFeaturesStreamController = null; + super.dispose(); + } + + void _listener() { + _enabledFeatures + ..clear() + ..addAll(notifier.value); } } diff --git a/lib/src/services/logging/log_event.dart b/lib/src/services/logging/log_event.dart new file mode 100644 index 0000000..a375329 --- /dev/null +++ b/lib/src/services/logging/log_event.dart @@ -0,0 +1,39 @@ +part of "logging_service.dart"; + +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..4ceb82e --- /dev/null +++ b/lib/src/services/logging/log_interceptor.dart @@ -0,0 +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 == Level.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, + 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); + } +} diff --git a/lib/src/services/logging/logging_interface.dart b/lib/src/services/logging/logging_interface.dart index 269061f..85386c4 100644 --- a/lib/src/services/logging/logging_interface.dart +++ b/lib/src/services/logging/logging_interface.dart @@ -5,36 +5,49 @@ 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; - - /// 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; - } + const LoggingInterface(); /// 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 LoggingInitialization implements LoggingInitializable { + bool _initialized = false; + + @override + bool get initialized => _initialized; + + @override + Future init() async { + _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/lib/src/services/logging/logging_service.dart b/lib/src/services/logging/logging_service.dart index 846dd46..dbace2e 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,16 +21,37 @@ 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 = {}; /// Additional metadata that is included in all logs. Map get additionalMetadata => I._additionalMetadata; + 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._logController.stream; + bool _initialized = false; /// Whether the logger has been initialized. @@ -76,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. @@ -114,95 +137,302 @@ class ArcaneLogger { /// ``` /// void log( + /// The message to be logged String message, { + /// The Dart class from which the `log` call was invoked. This is useful + /// in determining which part of the code called the log event. If the + /// [module] is not specified and [skipAutodetection] is set to [false], + /// [ArcaneLogger] will attempt to derive this value automatically. However, + /// this may fail in some cases and could potentially adversely impact + /// performance. String? module, + + /// The method from which the `log` call was invoked. This is useful + /// in determining which part of the code called the log event. If the + /// [method] is not specified and [skipAutodetection] is set to [false], + /// [ArcaneLogger] will attempt to derive this value automatically. However, + /// this may fail in some cases and could potentially adversely impact + /// performance. String? method, + + /// This value defines the severity of the log message. The default value is + /// [Level.debug]. Level level = Level.debug, + + /// A [StackTrace] can be passed into the `log` call for further processing + /// by the registered [LoggingInterface]s. StackTrace? stackTrace, - Map? metadata, + + /// The provided [metadata] will be merged with any previously registered + /// persistent metadata. If the [module] and/or [method] are provided, these + /// values will be merged with the [metadata] as well, otherwise if one or + /// none of these values are provided and [skipAutodetection] is set to + /// [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, + + /// The [extra] parameter can be used to pass _any_ object into the + /// registered [LoggingInterface]s. Object? extra, + + /// If set to [true], this parameter will skip automatically trying to + /// determine the current log message's [module], [method], and + /// [filenameAndLineNumber]. + /// + /// If set to [true] and the [filenameAndLineNumber] are desired, they + /// should be calculated by the [LoggingInterface] and added as as + /// [metadata]. + /// + /// If this value is [false] (default), the [module] and [method] will only + /// be added to the [metadata] if they are not otherwise provided. However, + /// the [filenameAndLineNumber] will automatically be added _unless_ it is + /// already in the [metadata] provided. + /// + /// When set to [true], the automatic generation of these values _may_ + /// impact performance. + bool skipAutodetection = false, }) { - if (!I._initialized) { - throw Exception("ArcaneLogger has not yet been initialized."); + final Map logMetadata = { + ...?metadata, + }; + logMetadata.putIfAbsent( + "timestamp", + () => DateTime.now().toIso8601String(), + ); + + String? filenameAndLineNumber; + if (!skipAutodetection) { + String? parts; + try { + parts = StackTrace.current + .toString() + .split("\n")[2] + .split(RegExp("#2"))[1] + .trim(); + } catch (_) {} + + module ??= parts?.split(".").firstOrNull?.replaceFirst("new ", ""); + + method ??= ((parts?.split(".").length ?? 0) <= 1) + ? null + : parts + ?.split(".")[1] + .split(" ") + .firstOrNull + ?.replaceAll(" fileAndLineParts = [ + ...?parts?.split("(package:").lastOrNull?.split(":"), + ]; + + if (fileAndLineParts.length < 2) { + filenameAndLineNumber = fileAndLineParts.firstOrNull; + } else { + filenameAndLineNumber = "${fileAndLineParts[0]}:${fileAndLineParts[1]}"; + } } - metadata ??= {}; + // Module management + if (module.isNotEmptyOrNull) { + logMetadata.putIfAbsent("module", () => module!); + } - final String now = DateTime.now().toIso8601String(); - metadata.putIfAbsent("timestamp", () => now); + // Method managmeent + if (method.isNotEmptyOrNull) { + logMetadata.putIfAbsent("method", () => method!); + } - try { - final List parts = StackTrace.current - .toString() - .split("\n")[2] - .split(RegExp("#2"))[1] - .trimLeft() - .split("."); - - module ??= parts.first.replaceFirst("new ", ""); - method ??= parts[1].split(" ").first.replaceAll(" fileAndLineParts = StackTrace.current - .toString() - .split("\n")[2] - .split(RegExp("#2"))[1] - .trim() - .split("(package:") - .last - .split(":"); - - final String fileAndLine = - "${fileAndLineParts[0]}:${fileAndLineParts[1]}"; - - metadata.putIfAbsent("module", () => module!); - if (method.isNotNullOrEmpty) - metadata.putIfAbsent("method", () => method!); - metadata.putIfAbsent("filenameAndLineNumber", () => fileAndLine); - } catch (_) {} - - metadata.addAll(additionalMetadata); - - // Send logs to registered interface(s) - for (final LoggingInterface i in I._interfaces) { - i.log( - message, - level: level, - metadata: metadata, - stackTrace: stackTrace, - extra: extra, + // Filename and line number management + if (filenameAndLineNumber.isNotNullOrEmpty) { + logMetadata.putIfAbsent( + "filenameAndLineNumber", + () => filenameAndLineNumber!, ); } + + 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 _LoggingInterfaceRegistration registration + in I._interfaceRegistrations) { + if (initialized) { + 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, + ); + } + } + + _logController.add( + "${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. + /// Registers a [LoggingInterface] with the [ArcaneLogger]. /// - /// 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, { + List? interceptors, + }) async { + if (!initialized) await _init(); + + 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]. + /// + /// 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; } - /// Initializes all registered [LoggingInterface]s by calling their - /// [LoggingInterface.init] methods. - Future initializeInterfaces() async { - assert( - I._interfaces.isNotEmpty, - "No logging interfaces have been registered.", + /// Unregisters a [LoggingInterface] from the [ArcaneLogger], if it was + /// previously registered. + Future unregisterInterface( + LoggingInterface interface, + ) async { + if (!initialized) await _init(); + + I._interfaceRegistrations.removeWhere( + (_LoggingInterfaceRegistration registration) => + identical(registration.interface, interface), ); - if (!I._initialized) await _init(); - for (final LoggingInterface i in I._interfaces) { - if (!i.initialized) await i.init(); + return I; + } + + /// Unregisters a `List` of [LoggingInterface] from the [ArcaneLogger], if + /// they were previously registered. + Future unregisterInterfaces( + List interfaces, + ) async { + if (!initialized) await _init(); + + for (final LoggingInterface i in interfaces) { + I._interfaceRegistrations.removeWhere( + (_LoggingInterfaceRegistration registration) => + identical(registration.interface, i), + ); + } + + return I; + } + + /// Unregisters all previously registered [LoggingInterface] from the + /// [ArcaneLogger], if any were previously registered. + Future unregisterAllInterfaces() async { + if (!initialized) await _init(); + I._interfaceRegistrations.clear(); + return I; + } + + /// Initializes registered interfaces that opt into [LoggingInitializable]. + /// + /// Interfaces that do not implement [LoggingInitializable] are skipped. + Future initializeInterfaces() async { + if (I._interfaceRegistrations.isEmptyOrNull) { + throw Exception("No logging interfaces have been registered."); + } + + if (!initialized) await _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; @@ -245,4 +475,50 @@ class ArcaneLogger { /// Clears all persistent metadata. void clearPersistentMetadata() => _additionalMetadata.clear(); + + /// Resets the Arcane logging service by clearing all persistent metadata, + /// 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, + 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/lib/src/services/reactive_theme/reactive_theme_extensions.dart b/lib/src/services/reactive_theme/reactive_theme_extensions.dart deleted file mode 100644 index 604bd18..0000000 --- a/lib/src/services/reactive_theme/reactive_theme_extensions.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of "reactive_theme_service.dart"; - -/// An extension on `BuildContext` to check the current system dark mode setting. -/// -/// This extension provides a convenient way to check whether the device is in dark mode. -extension DarkMode on BuildContext { - /// Returns `true` if the system is currently set to dark mode. - /// - /// This uses `MediaQuery.of(this).platformBrightness` to check the system's brightness setting. - /// - /// Example: - /// ```dart - /// if (context.isDarkMode) { - /// // The system is in dark mode. - /// } - /// ``` - bool get isDarkMode { - final brightness = MediaQuery.of(this).platformBrightness; - return brightness == Brightness.dark; - } -} diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart deleted file mode 100644 index cd9553c..0000000 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ /dev/null @@ -1,109 +0,0 @@ -import "package:arcane_framework/arcane_framework.dart"; -import "package:flutter/foundation.dart"; -import "package:flutter/material.dart"; - -part "reactive_theme_extensions.dart"; - -/// A singleton service that manages theme switching and customization for the application. -/// -/// `ArcaneReactiveTheme` 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. -class ArcaneReactiveTheme extends ArcaneService { - /// The singleton instance of `ArcaneReactiveTheme`. - static final ArcaneReactiveTheme _instance = ArcaneReactiveTheme._internal(); - - /// Provides access to the singleton instance of `ArcaneReactiveTheme`. - static ArcaneReactiveTheme get I => _instance; - - ArcaneReactiveTheme._internal(); - - /// Whether the current theme is dark. - bool _isDark = false; - - /// Returns the current theme mode based on `_isDark`. - /// - /// If `_isDark` is true, it returns `ThemeMode.dark`, otherwise it returns `ThemeMode.light`. - ThemeMode get currentMode => _isDark ? ThemeMode.dark : ThemeMode.light; - - /// The `ThemeData` for the dark theme. - ThemeData _darkTheme = ThemeData.dark(); - - /// The `ThemeData` for the light theme. - ThemeData _lightTheme = ThemeData.light(); - - /// Returns the current dark theme `ThemeData`. - ThemeData get dark => _darkTheme; - - /// Returns the current light theme `ThemeData`. - ThemeData get light => _lightTheme; - - /// A listenable that notifies listeners when the syste theme mode changes. - ValueListenable get systemTheme => - ValueNotifier(_isDark ? ThemeMode.dark : ThemeMode.light); - - /// Switches the current theme between light and dark modes. - /// - /// If the theme is currently light, it switches to dark, and vice versa. It also - /// notifies listeners to update the UI accordingly. - /// - /// Example: - /// ```dart - /// ArcaneReactiveTheme.I.switchTheme(); - /// ``` - ArcaneReactiveTheme switchTheme() { - _isDark = !_isDark; - notifyListeners(); - - return I; - } - - /// Switches the current theme between light and dark modes automatically - /// based upon the system's current mode. - /// - /// Example: - /// ```dart - /// ArcaneReactiveTheme.I.followSystemTheme(context); - /// final ThemeMode mode = Arcane.theme.systemTheme.value; - /// ``` - ArcaneReactiveTheme followSystemTheme(BuildContext context) { - final ThemeMode systemMode = - context.isDarkMode ? ThemeMode.dark : ThemeMode.light; - - if (currentMode != systemMode) { - switchTheme(); - } - - return I; - } - - /// Sets a custom `ThemeData` for the dark theme. - /// - /// This allows you to customize the dark theme and notify listeners to apply the - /// changes immediately. - /// - /// Example: - /// ```dart - /// ArcaneReactiveTheme.I.setDarkTheme(customDarkTheme); - /// ``` - ArcaneReactiveTheme setDarkTheme(ThemeData theme) { - _darkTheme = theme; - notifyListeners(); - return I; - } - - /// Sets a custom `ThemeData` for the light theme. - /// - /// This allows you to customize the light theme and notify listeners to apply the - /// changes immediately. - /// - /// Example: - /// ```dart - /// ArcaneReactiveTheme.I.setLightTheme(customLightTheme); - /// ``` - ArcaneReactiveTheme setLightTheme(ThemeData theme) { - _lightTheme = theme; - notifyListeners(); - return I; - } -} diff --git a/lib/src/services/theme/arcane_theme.dart b/lib/src/services/theme/arcane_theme.dart new file mode 100644 index 0000000..c90ee2a --- /dev/null +++ b/lib/src/services/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, + super.key, + this.themeMode = ThemeMode.light, + this.followSystem = false, + this.theme, + }); + + 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/theme/theme_extensions.dart b/lib/src/services/theme/theme_extensions.dart new file mode 100644 index 0000000..575bd2b --- /dev/null +++ b/lib/src/services/theme/theme_extensions.dart @@ -0,0 +1,34 @@ +import "package:flutter/material.dart"; + +import "arcane_theme.dart"; +import "theme_service.dart"; + +/// An extension on `BuildContext` to check the current effective 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 current effective theme is dark. + /// + /// 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 active app theme is dark. + /// } + /// ``` + bool get isDarkMode { + final brightness = Theme.of(this).brightness; + 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/theme/theme_service.dart b/lib/src/services/theme/theme_service.dart new file mode 100644 index 0000000..b96b962 --- /dev/null +++ b/lib/src/services/theme/theme_service.dart @@ -0,0 +1,298 @@ +import "dart:async"; + +import "package:arcane_framework/src/service/arcane_service.dart"; +import "package:flutter/material.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. +/// +/// `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 ArcaneThemeService extends ArcaneService { + ArcaneThemeService._internal(); + static final ArcaneThemeService _instance = ArcaneThemeService._internal(); + static ArcaneThemeService get I => _instance; + + // ************************************************************************ // + // * 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 `ThemeMode` corresponding to the current system theme + ThemeMode get systemThemeMode => _currentSystemThemeMode; + + /// Tracks the current system theme mode + ThemeMode _currentSystemThemeMode = ThemeMode.system; + + StreamController? _systemStreamController; + + StreamController get _systemController { + _systemStreamController ??= StreamController.broadcast(); + return _systemStreamController!; + } + + // ************************************************************************ // + // * MARK: ThemeMode + // ************************************************************************ // + /// 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, 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.system; + + /// Stream of `ThemeMode` changes that can be listened to for reactive UI updates. + Stream get themeModeChanges => I._themeModeController.stream; + + StreamController? _themeModeStreamController; + + StreamController get _themeModeController { + _themeModeStreamController ??= StreamController.broadcast(); + return _themeModeStreamController!; + } + + // ************************************************************************ // + // * MARK: ThemeData + // ************************************************************************ // + /// 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(); + + /// 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 { + _themeStreamController ??= StreamController.broadcast(); + return _themeStreamController!; + } + + // ************************************************************************ // + // * MARK: Light/Dark theme + // ************************************************************************ // + /// 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; + + /// 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()); + + /// 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; + + /// 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()); + + // ************************************************************************ // + // * 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 + /// also notifies listeners to update the UI accordingly. + /// + /// Example: + /// ```dart + /// ArcaneThemeService.I.switchTheme(); + /// // or + /// ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.dark); + /// // or + /// Arcane.theme.switchTheme(themeMode: ThemeMode.light); + /// ``` + ArcaneThemeService switchTheme({ThemeMode? themeMode}) { + _followingSystemTheme = false; + + if (themeMode != null) { + _updateTheme(themeMode); + } else { + final ThemeMode effectiveMode = _effectiveThemeMode; + _updateTheme( + effectiveMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark, + ); + } + + return I; + } + + /// Switches the current theme between light and dark modes automatically + /// based upon the system's current mode. + /// + /// This will also register for system theme changes, so the theme will + /// automatically update when the system theme changes. + /// + /// Example: + /// ```dart + /// ArcaneThemeService.I.followSystemTheme(context); + /// // or + /// Arcane.theme.followSystemTheme(context); + /// ``` + ArcaneThemeService followSystemTheme(BuildContext context) { + _followingSystemTheme = true; + + _currentSystemThemeMode = + MediaQuery.platformBrightnessOf(context) == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; + _systemController.add(_currentSystemThemeMode); + _updateTheme(_currentSystemThemeMode); + + final ThemeData theme = systemThemeMode == ThemeMode.dark ? dark : light; + _themeController.add(theme); + _currentTheme = theme; + + return I; + } + + /// Sets a custom `ThemeData` for the dark theme. + /// + /// This allows you to customize the dark theme and notify listeners to apply + /// the changes immediately. + /// + /// Example: + /// ```dart + /// 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) { + _themeController.add(theme); + _currentTheme = theme; + } + return I; + } + + /// Sets a custom `ThemeData` for the light theme. + /// + /// This allows you to customize the light theme and notify listeners to apply + /// the changes immediately. + /// + /// Example: + /// ```dart + /// 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) { + _themeController.add(theme); + _currentTheme = theme; + } + return I; + } + + /// Syncs the initial theme with the platform/effective mode snapshot. + /// + /// This is invoked automatically by `ArcaneThemeSwitcher` during the first + /// dependency pass when using `ArcaneApp`, so most apps do not need to call + /// this directly. + void setInitialTheme(BuildContext context) { + // Only update when no custom theme was explicitly provided by the user. + if (_themeOverriddenByUser) { + 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 + /// disables system theme following. + @visibleForTesting + void reset() { + _darkTheme.value = ThemeData.dark(); + _lightTheme.value = ThemeData.light(); + _themeOverriddenByUser = false; + _followingSystemTheme = false; + _updateTheme(ThemeMode.light); + _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; + _themeModeController.add(themeMode); + } + + ThemeMode get _effectiveThemeMode { + if (_currentThemeMode != ThemeMode.system) { + return _currentThemeMode; + } + + return _currentTheme.brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light; + } +} diff --git a/lib/src/services/theme/theme_switcher.dart b/lib/src/services/theme/theme_switcher.dart new file mode 100644 index 0000000..860df0a --- /dev/null +++ b/lib/src/services/theme/theme_switcher.dart @@ -0,0 +1,88 @@ +import "dart:async"; + +import "package:flutter/material.dart"; + +import "arcane_theme.dart"; +import "theme_service.dart"; + +class ArcaneThemeSwitcher extends StatefulWidget { + final Widget child; + + const ArcaneThemeSwitcher({ + required this.child, + Key? key, + }) : super(key: key); + + @override + State createState() => _ArcaneThemeSwitcherState(); +} + +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 = ArcaneThemeService.I.themeModeChanges.listen((_) { + setState(() {}); + }); + _themeSubscription = ArcaneThemeService.I.themeDataChanges.listen((_) { + setState(() {}); + }); + } + + @override + void dispose() { + unawaited(_themeModeSubscription.cancel()); + unawaited(_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); + + // ArcaneApp defaults to following the platform theme until the user + // explicitly picks a manual light/dark mode. + ArcaneThemeService.I.followSystemTheme(context); + _initialized = true; + } + } + + @override + Widget build(BuildContext context) { + return ArcaneTheme( + themeMode: ArcaneThemeService.I.currentThemeMode, + followSystem: ArcaneThemeService.I.isFollowingSystemTheme, + theme: ArcaneThemeService.I.currentTheme, + child: widget.child, + ); + } + + @override + void didChangePlatformBrightness() { + // When system brightness changes, find the current builder context + // and use it to check the system theme + if (mounted) { + // Use the current context from the key to check system theme + if (ArcaneThemeService.I.isFollowingSystemTheme) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ArcaneThemeService.I.followSystemTheme(context); + }); + } + } + super.didChangePlatformBrightness(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index beb5127..c87fa51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,7 @@ name: arcane_framework -description: "Agnostic Reusable Component Architecture for New Ecosystems: a modern framework for bootstrapping new applications" -version: 1.2.7 +description: "Agnostic Reusable Component Architecture for New Ecosystems: a + modern framework for bootstrapping new applications" +version: 2.0.0 repository: https://github.com/hanskokx/arcane_framework issue_tracker: https://github.com/hanskokx/arcane_framework/issues @@ -13,13 +14,13 @@ environment: dependencies: arcane_helper_utils: ^1.4.7 - collection: ^1.18.0 + collection: ^1.19.0 flutter: sdk: flutter - flutter_bloc: ^9.0.0 - result_monad: ^2.3.2 + result_monad: ^4.0.0 dev_dependencies: arcane_analysis: ^1.0.4 flutter_test: sdk: flutter + mocktail: ^1.0.5 diff --git a/test/arcane_test.dart b/test/arcane_test.dart new file mode 100644 index 0000000..f570868 --- /dev/null +++ b/test/arcane_test.dart @@ -0,0 +1,21 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + setUpAll(() async { + ArcaneFeatureFlagService.I.reset(); + await 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())); + }); + }); +} diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart new file mode 100644 index 0000000..a714bf9 --- /dev/null +++ b/test/providers/service_provider_test.dart @@ -0,0 +1,338 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneServiceProvider", () { + late List testServices; + + setUp(() { + testServices = [ + MockArcaneService(), + AnotherMockService(), + ]; + }); + + testWidgets("provides services to widget tree", (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final provider = ArcaneServiceProvider.of(context); + 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(); + }, + ), + ), + ); + }); + + 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( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = + ArcaneServiceProvider.serviceOfType( + context, + ); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets( + "static serviceOfType method returns correct service and returns null when not found", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // Should find this service + final service = + ArcaneServiceProvider.serviceOfType( + context, + ); + + expect(service, isA()); + + // Returns null for unregistered services + expect( + ArcaneServiceProvider.serviceOfType( + context, + ), + isNull, + ); + + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("service extension returns correct service", (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.service(); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("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 { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // Should find this service + final service = context.requiredService(); + expect(service, isA()); + + // Should throw for missing service + expect( + () => context.requiredService(), + throwsA(isA()), + ); + + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("service returns null for unregistered service", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.service(); + expect(service, isNull); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("legacy serviceOfType method still works but is deprecated", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + // ignore: deprecated_member_use_from_same_package + final service = context.serviceOfType(); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("service updates trigger rebuilds", (tester) async { + late ArcaneServiceProvider provider; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneServiceProvider( + serviceInstances: testServices, + child: Builder( + builder: (context) { + provider = ArcaneServiceProvider.of(context); + buildCount++; + // Access a service to create dependency + context.service(); + return const Text("Test"); + }, + ), + ), + ), + ); + + expect(buildCount, 1); + + // Update services and verify rebuild + provider.setServices([MockArcaneService(), AnotherMockService()]); + await tester.pump(); + expect(buildCount, 2); + + // Add a service and verify rebuild + provider.addService(UnregisteredService()); + await tester.pump(); + expect(buildCount, 3); + }); + + testWidgets("ArcaneService.of static helper works", (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = ArcaneService.ofType(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.requiredOfType(context); + expect(service, isA()); + + expect( + () => + ArcaneService.requiredOfType(context), + throwsA(isA()), + ); + + return const SizedBox(); + }, + ), + ), + ); + }); + }); +} + +// Mock classes for testing +class MockArcaneService extends ArcaneService {} + +class AnotherMockService extends ArcaneService {} + +class UnregisteredService extends ArcaneService {} + +class MockBuildContext extends Fake implements BuildContext {} diff --git a/test/services/authentication/authentication_enums_test.dart b/test/services/authentication/authentication_enums_test.dart new file mode 100644 index 0000000..6eff24c --- /dev/null +++ b/test/services/authentication/authentication_enums_test.dart @@ -0,0 +1,22 @@ +import "package:arcane_framework/src/services/authentication/authentication_service.dart"; +import "package:flutter_test/flutter_test.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..46f8d43 --- /dev/null +++ b/test/services/authentication/authentication_interface_test.dart @@ -0,0 +1,50 @@ +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/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart new file mode 100644 index 0000000..bb32719 --- /dev/null +++ b/test/services/authentication/authentication_service_test.dart @@ -0,0 +1,514 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +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", () { + setUp(() async { + authInterface = MockArcaneAuthInterface(); + + // Initialize the service + await ArcaneAuthenticationService.I.reset(); + Arcane.environment.reset(); + + 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(authInterface); + }); + + testWidgets("login with success", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + environment: Environment.normal, + child: Builder( + builder: (context) { + return Container(); + }, + ), + ), + ), + ); + final result = await ArcaneAuthenticationService.I.login( + input: {"username": "test"}, + ); + expect(result.isSuccess, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.authenticated), + ); + }); + + testWidgets("login with failure", (WidgetTester tester) async { + 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"}); + expect(result.isFailure, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.unauthenticated), + ); + }); + + testWidgets("logout with success", (WidgetTester tester) async { + ArcaneAuthenticationService.I.setAuthenticated(); + final result = await ArcaneAuthenticationService.I.logOut(); + expect(result.isSuccess, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.unauthenticated), + ); + }); + + testWidgets("setDebug enables debug mode", (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + await tester.pump(); + ArcaneEnvironment.of(capturedContext).enableDebugMode(); + await tester.pump(); + + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.debug), + ); + }); + + 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.current, Environment.debug); + + await ArcaneAuthenticationService.I.setNormal(capturedContext); + expect(Arcane.environment.current, Environment.normal); + }, + ); + + testWidgets("setNormal disables debug mode", (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + await tester.pump(); + ArcaneEnvironment.of(capturedContext).enableDebugMode(); + await tester.pump(); + + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.debug), + ); + + ArcaneEnvironment.of(capturedContext).disableDebugMode(); + await tester.pump(); + + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.normal), + ); + }); + + 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, + 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; + }); + + 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; + }); + }); +} 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/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 new file mode 100644 index 0000000..c713f66 --- /dev/null +++ b/test/services/feature_flags/feature_flags_service_test.dart @@ -0,0 +1,109 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneFeatureFlagService", () { + late ArcaneFeatureFlagService featureFlags; + + setUp(() { + featureFlags = ArcaneFeatureFlagService.I; + Arcane.features.reset(); + }); + + test("singleton instance is consistent", () { + expect(identical(ArcaneFeatureFlagService.I, featureFlags), true); + }); + + group("feature management", () { + test("enableFeature adds feature to enabled list", () { + featureFlags.enableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures, contains(MockFeature.test)); + expect(featureFlags.isEnabled(MockFeature.test), true); + }); + + test("disableFeature removes feature from enabled list", () { + featureFlags.enableFeature(MockFeature.test); + featureFlags.disableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures, isNot(contains(MockFeature.test))); + expect(featureFlags.isDisabled(MockFeature.test), true); + }); + + test("enabling already enabled feature has no effect", () { + featureFlags.enableFeature(MockFeature.test); + final initialCount = featureFlags.enabledFeatures.length; + featureFlags.enableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures.length, equals(initialCount)); + }); + + test("disabling already disabled feature has no effect", () { + final initialCount = featureFlags.enabledFeatures.length; + featureFlags.disableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures.length, equals(initialCount)); + }); + }); + + group("notifications", () { + test("enableFeature notifies listeners", () { + var notified = false; + featureFlags.notifier.addListener(() => notified = true); + featureFlags.enableFeature(MockFeature.test); + expect(notified, true); + }); + + test("disableFeature notifies listeners", () { + featureFlags.enableFeature(MockFeature.test); + var notified = false; + 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(); + }); + }); + }); +} + +enum MockFeature { + test, + another, +} 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 new file mode 100644 index 0000000..28e1ddf --- /dev/null +++ b/test/services/logging/logging_service_test.dart @@ -0,0 +1,470 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; + +class TestLoggingInterface extends LoggingInterface with LoggingInitialization { + TestLoggingInterface(this.name); + + 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); + + 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 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]", + }, + ); + } +} + +void main() { + late TestLoggingInterface myInterface; + late LogInterceptor prefixInterceptor; + + setUp(() { + Arcane.logger.reset(); + myInterface = TestLoggingInterface("primary"); + prefixInterceptor = LogInterceptor( + (event, context) { + return event.copyWith(message: "[global] ${event.message}"); + }, + ); + }); + + 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); + + expect( + Arcane.logger.interfaces, + contains(isA()), + ); + }); + + test("registering an interface doesn't initialize it", () async { + await Arcane.logger.registerInterface(myInterface); + + expect(Arcane.logger.interfaces.first, isA()); + + expect(myInterface.initialized, false); + expect(myInterface.initCallCount, 0); + }); + + test("registering an interface initializes the logger", () async { + expect(Arcane.logger.initialized, false); + + await Arcane.logger.registerInterface(myInterface); + + expect(Arcane.logger.initialized, true); + }); + + test("interfaces can be initialized through the logger", () async { + await Arcane.logger.registerInterface(myInterface); + + 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(); + + 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([ + TestLoggingInterface("first"), + TestLoggingInterface("second"), + ]); + + expect( + Arcane.logger.interfaces, + contains(isA()), + ); + expect( + 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, 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"], + ); + }); + }); + + group("persistent metadata", () { + test("addPersistentMetadata adds metadata correctly", () { + Arcane.logger.addPersistentMetadata({"test": "value"}); + expect(Arcane.logger.additionalMetadata["test"], equals("value")); + }); + + test("removePersistentMetadata removes specific key", () { + Arcane.logger.addPersistentMetadata({"test": "value", "keep": "this"}); + Arcane.logger.removePersistentMetadata("test"); + expect(Arcane.logger.additionalMetadata.containsKey("test"), false); + expect(Arcane.logger.additionalMetadata["keep"], equals("this")); + }); + + test("clearPersistentMetadata removes all metadata", () { + Arcane.logger + .addPersistentMetadata({"test": "value", "another": "value"}); + Arcane.logger.clearPersistentMetadata(); + expect(Arcane.logger.additionalMetadata.isEmpty, true); + }); + }); + + group("logging messages", () { + const String logMessage = "Test"; + + setUp(() async { + await Arcane.logger.registerInterface(myInterface); + }); + + test("logging a basic message works", () async { + Arcane.log(logMessage); + + expect(myInterface.events.single.message, logMessage); + }); + + test("logging at a different level works", () async { + Arcane.log( + logMessage, + level: Level.info, + ); + + expect(myInterface.events.last.level, Level.info); + + Arcane.log( + logMessage, + level: Level.warning, + ); + + expect(myInterface.events.last.level, Level.warning); + }); + + test("logging a stacktrace works", () async { + final stackTrace = StackTrace.current; + Arcane.log(logMessage, stackTrace: stackTrace); + + expect(myInterface.events.single.stackTrace, stackTrace); + }); + + test("logging an extra object works", () async { + const bool extraObject = true; + Arcane.log( + logMessage, + extra: extraObject, + ); + + expect(myInterface.events.single.extra, extraObject); + }); + + test("logging metadata works", () async { + final Map metadata = {"test": "value"}; + Arcane.log( + logMessage, + metadata: metadata, + ); + + 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, context) { + expect(context.interface, same(myInterface)); + return event.copyWith(message: "${event.message}:first"); + }), + LogInterceptor((event, 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, 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, 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, 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, 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, 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); + }); + }); + }); +} diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart new file mode 100644 index 0000000..43485d3 --- /dev/null +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -0,0 +1,214 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneReactiveTheme", () { + late ArcaneReactiveTheme theme; + + setUp(() { + theme = ArcaneReactiveTheme.I; + Arcane.theme.reset(); + }); + + test("singleton instance is consistent", () { + expect(identical(ArcaneReactiveTheme.I, theme), true); + }); + + group("theme mode", () { + test("initial mode is light", () { + expect(theme.currentThemeMode, equals(ThemeMode.light)); + }); + + test("switchTheme toggles between light and dark", () { + expect(theme.currentThemeMode, equals(ThemeMode.light)); + theme.switchTheme(); + expect(theme.currentThemeMode, equals(ThemeMode.dark)); + theme.switchTheme(); + 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) { + emittedMode = mode; + }); + + theme.switchTheme(); + 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(); + }); + }); + + group("theme customization", () { + test("setDarkTheme updates dark theme", () { + final customTheme = ThemeData.dark().copyWith( + primaryColor: Colors.purple, + ); + theme.setDarkTheme(customTheme); + expect(theme.dark.primaryColor, equals(Colors.purple)); + }); + + test("setLightTheme updates light theme", () { + final customTheme = ThemeData.light().copyWith( + primaryColor: Colors.orange, + ); + theme.setLightTheme(customTheme); + expect(theme.light.primaryColor, equals(Colors.orange)); + }); + + test("theme updates notify notifier and streams", () async { + bool darkNotified = false; + bool lightNotified = false; + 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; + }); + + theme.lightTheme.addListener(() { + lightNotified = true; + }); + + final darkTheme = ThemeData.dark().copyWith( + primaryColor: Colors.teal, + ); + final lightTheme = ThemeData.light().copyWith( + primaryColor: Colors.amber, + ); + + theme.setDarkTheme(darkTheme); + theme.setLightTheme(lightTheme); + await Future.delayed(Duration.zero); + + expect(darkNotified, true); + expect(lightNotified, true); + expect(emittedThemeData, isNotNull); + + theme.switchTheme(); + await Future.delayed(Duration.zero); + expect(theme.currentThemeMode, ThemeMode.dark); + expect(emittedMode, ThemeMode.dark); + + theme.switchTheme(); + await Future.delayed(Duration.zero); + expect(theme.currentThemeMode, ThemeMode.light); + expect(emittedMode, ThemeMode.light); + + await modeSubscription.cancel(); + await dataSubscription.cancel(); + }); + }); + + group("system theme following", () { + setUp(() { + Arcane.theme.reset(); + }); + + testWidgets("followSystemTheme updates theme based on context brightness", + (WidgetTester tester) async { + // Create widgets with different brightness contexts + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.light), + child: ArcaneApp( + child: SizedBox(), + ), + ), + ); + + final BuildContext lightContext = tester.element(find.byType(SizedBox)); + Arcane.theme.followSystemTheme(lightContext); + await tester.pumpAndSettle(); + + expect(theme.currentThemeMode, equals(ThemeMode.light)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: ArcaneApp( + child: SizedBox(), + ), + ), + ); + + final BuildContext darkContext = tester.element(find.byType(SizedBox)); + Arcane.theme.followSystemTheme(darkContext); + await tester.pumpAndSettle(); + + expect(theme.currentThemeMode, equals(ThemeMode.dark)); + }); + }); + }); +} 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); + }); + }); +} 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(); + }, + ), + ), + ); + }); +} 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(); + }); + }); +}