v2.0.0: Overhaul core, services, and app integration (#6)

* [UNTESTED] Fixes notifiers and adds some additional methods. Adds tests.

Changes:
// ArcaneEnvironment
breaking: context.read<ArcaneEnvironment>() -> ArcaneEnvironment.of(context)
breaking: context.read<ArcaneEnvironment>().state -> ArcaneEnvironment.of(context).environment;

// Feature flag service
added: reset()

// Logging service
added: registerInterface()
added: unregisterInterfaces()
added: unregisterAllInterfaces()

// ArcaneReactiveTheme
fixed: currentMode, dark, light now actually emit new values when changed
added: getters for lightTheme, darkTheme, and systemTheme
TODO: test systemTheme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* ArcaneReactiveTheme now optionally takes a ThemeMode parameter when calling switchTheme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added `of(context)` extension to `ArcaneService`

* Updated changelog

* Logging service:

- added: reset()
- added: `skipAutodetection` option for module, method, and metadata
- added: unregisterInterface()
- Created FileAndLineNumber mixin

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fix module/method/fileAndLineNumber calculations in logging service

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Minor fix to fileAndLineParts calculation

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove debug logging

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Minor change

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixed theme

- fixed: currentMode, dark, light now actually emit new values when changed
- added: getters for lightTheme, darkTheme, and systemTheme
- breaking: currentMode -> systemTheme
- added: currentTheme
- breaking: currentMode => currentTheme
- change: Arcane.theme.followSystemTheme(context) required to follow system theme
- change: After following the system theme, calling Arcane.theme.switchTheme() cancels following the system theme
- added: ArcaneReactiveTheme is now registered in ArcaneServiceProvider when using ArcaneApp

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Removed arcane theme from service provider

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixed late initialized value in logging service

* Fixes the reactive theme service to properly follow the system brightness

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixes tests and updates reactive theme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixes broken tests

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixes bug with following/unfollowing system theme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Re-add getters on ArcaneTheme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added logStream to logger. Updated example code.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Setting a theme style now automatically switches to that theme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Updated theme example to set color of theme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added examples for feature flags

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added environment example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Center text

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added environment logging

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Rename onEnvironmentChanged to switchEnvironment

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Made ArcaneServiceProvider.of(context) nullable

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove invalid test

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Extra log messages will not longer be added to the log stream if more than one LoggingInterface is registered

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Only send log messages to the LoggingInterfaces if the logging service has been initialized

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Check for empty interfaces before initializing them in the logger

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Moved platform brightness checking from ArcaneApp to ArcaneThemeSwitcher, where it is more appropriate

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Correctly export the theme switcher widget

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove unused import

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Made the switchEnvironment field in ArcaneEnvironment private (_switchEnvironment)

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Testing GH actions

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Set correct .puro.json filename in github action config

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Check different path for .puro.json

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Test different GH action workflow

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Attempt to correctly address "stable" version in .puro.json

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Another GH action attempt

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add environment variables

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Debug

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add puro config to git :)

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove debug

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat(framework): Overhauls framework core, services, and app integration for 2.0.0 (#5)

* Made ArcaneTheme private (_ArcaneTheme)

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Updated changelog

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fix updateShouldNotify on ArcaneServiceProvider

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update service provider

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Put service locators in an extension

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Try using a mixin instead

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove 'of' and 'requiredOf' locators

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Re-add service locators. They should be called as:

ArcaneService.of<MyService>(context)

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Renamed getters on ArcaneService

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Renamed serviceInstances => registeredServices and added a removeService method

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Broke up arcane service into separate files

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Added requiredServiceOfType getter on ArcaneServiceProvider

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Updated services section of the readme

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Updated example to add a services example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Removed unused variable

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update example documentation

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Breaking up example into smaller widgets

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove bloc dependency

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixes the notifier for feature flags and updates the example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fixed a bug in the example

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update ServiceProvider to make service instances optional

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove unnecessary notifyListeners calls

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Organize theme service

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fix authentication interface and service methods for logout and login.

* Fix DebugAuthInterface logout method

Added environment key to ArcaneEnvironment constructor
 Renamed switchEnvironment parameter in ArcaneEnvironment constructor

Bumped arcane_helper_utils dependency version

* Enhance theme management with StreamBuilder for dynamic updates

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add configuration files and update authentication service error handling

- Introduced .vscode/settings.json and .vscode/launch.json for IDE configuration.
- Updated DebugAuthInterface and ArcaneAuthenticationService to return const Result.ok() for consistency.
- Added ArcaneTheme class for theme management.
- Updated pubspec.yaml to change result_monad dependency version.
- Modified authentication_service_test to return const Result.ok() in mock setups.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update dependency versions to use 'any' for arcane_helper_utils and arcane_analysis

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Remove unnecessary 'const' keyword from Result.ok calls in DebugAuthInterface methods

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add 'const' keyword to Result.ok calls in DebugAuthInterface methods

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Enhance logging service with new lifecycle capabilities and interceptor support

- Updated `LoggingInterface` to remove singleton-style initialization.
- Introduced `LoggingInitializable` and `LoggingInitializationMixin` for optional lifecycle management.
- Added `LogEvent` and `LogInterceptor` classes for improved logging event handling and interception.
- Updated `ArcaneLogger` to support multiple logging interfaces and global interceptors.
- Modified `DebugPrint` and `DebugAuthInterface` to align with new logging structure.
- Enhanced tests to cover new logging features and interceptor functionality.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add stream-based updates for authentication, feature flags, logging, and theming

- Added `statusChanges` and `signedInChanges` streams to `ArcaneAuth` for real-time authentication updates.
- Introduced `enabledFeaturesChanges` stream in `ArcaneFeatureFlags` for observing feature updates.
- Improved `ArcaneLogger` to maintain `logStream` usability after listener cancellation.
- Updated `ArcaneTheme` to provide streams for theme mode and theme data changes.
- Enhanced documentation and examples to reflect new stream APIs and usage patterns.
- Added regression tests for stream listener cancellation and re-subscription across services.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Refactor ArcaneEnvironment and AuthenticationService for improved environment management and status handling

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add application environment management and tests for authentication status coherence

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat: Enhance service management with ArcaneApp provider composition

This commit significantly refactors Arcane's service architecture.
ArcaneApp now acts as a StatefulWidget, composing InheritedWidget-
based providers for core services (features, environment, theme, auth).
Service lookups (e.g., Arcane.features, context.service()) now
prioritize provider-registered instances, falling back to singletons.

Key changes include:
- Renamed ArcaneFeatureFlags to ArcaneFeatureFlagService and
  ArcaneReactiveTheme to ArcaneThemeService (with typedefs).
- Introduced ArcaneEnvironmentService and ArcaneEnvironmentProvider
  for centralized environment management.
- Added BuildContext extensions (e.g., context.featureFlags) for
  reactive, convenient service access.
- Removed .puro.json and its references, streamlining SDK setup.
- Updated CHANGELOG.md to reflect a 2.0.0 release, detailing all
  breaking changes and new features.

* chore(example): Add ignore rules for example builds

* feat(logging): Introduce log interceptors and origin control

Introduces the LogInterceptor class, enabling pre-processing, modification,
or suppression of log events before they reach registered interfaces.
The LogInterceptorContext provides contextual information such as the
originating LoggingInterface.

Adds a `skipAutodetection` parameter to Arcane.log, allowing control
over the automatic detection of log origin (module, method, file/line).

Also makes the LogEvent class extendable to support custom event types
and updates existing interceptor callback signatures for consistency.

* fix(theme): Update theme mode check to use currentModeOf method

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* fix(theme): Improve theme mode detection and toggling

`context.isDarkMode` now reflects the app's effective theme brightness, not
the raw platform brightness. This ensures `isDarkMode` accurately reports the
currently rendered theme.

`switchTheme()` now intelligently toggles from the *effective* theme when
in `ThemeMode.system` by considering the current app brightness.

`followSystemTheme()` is updated to always read the platform brightness
directly, decoupling its behavior from the app's overridden theme settings.

* feat(example): Improve theme and service integration demo

Updates the example application to better demonstrate service and theme
integration. The `FavoriteColorService` now actively sets the app's
color scheme and can initialize its state from the current theme.
Environment display is now reactive, and the UI responds dynamically
to service registration and removal. Initial themes are now explicitly
seeded.

* feat(example): Add interactive persistent metadata toggle

Previously, persistent metadata was added unconditionally.
This change allows users to dynamically enable or disable
the demo persistent metadata via a UI switch, better
showcasing the API functionality.

* feat(theme): Add assignment-style theme setters

Introduces convenience setters for dark and light themes, allowing
`Arcane.theme.dark = ...` and `Arcane.theme.light = ...` as an
alternative to `setDarkTheme` and `setLightTheme`. This improves
developer ergonomics and conciseness.

* docs(README): Add Application Environments section to features list

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Adds `ArcaneApp.builder` to provide a provider-aware `BuildContext`
for the application root. This deprecates the `ArcaneApp.child`
parameter, improving developer experience for accessing Arcane's
providers at build time.

Updates the `ArcaneThemeSwitcher` to default to `followSystemTheme`
when mounted under `ArcaneApp`, ensuring system theme behavior
is enabled out of the box.

Fixes `setDarkTheme` and `setLightTheme` to only update the rendered
theme if their respective modes are currently active. This prevents
unintended UI changes when updating the definition of an inactive theme.

Updates README and examples to reflect the new `builder` API and
clarified theme logic. Adds new tests for these changes.

* docs(arcane_app): Clarify ArcaneApp API and provider access documentation

Updates the documentation for the `ArcaneApp` constructor to
explicitly detail the preferred `builder` API and the deprecated
`child` parameter, including migration guidance. Also updates the
list of provided services to include `ArcaneFeatureFlagsProvider`.

* feat: Overhaul Arcane framework for 2.0.0 release notes

Migrate Arcane to a static utility surface, removing the instantiable
singleton constructor for simpler and more direct access to services.
Introduce new reactive streams for environment, authentication, and
theme services, enabling real-time observation of state changes.
Enhance `ArcaneApp` integration with the new `builder` callback
and `BuildContext` convenience accessors for services and feature flags.
Update `ArcaneAuthInterface.logout` signature and revise `src` import
paths, requiring consumers to update their implementations and imports.
Deprecate `ArcaneApp.child` and `BuildContext.serviceOfType<T>()`
in favor of new, more idiomatic APIs.
Remove direct `flutter_bloc` dependency and upgrade `result_monad`
to `^4.0.0`.

* refactor(environment): Rename environment access getter to current

This change unifies how the current application environment is accessed
across Arcane's services and providers. The `state` and `environment`
getters have been replaced with a consistent `current` getter,
simplifying API usage and aligning with new patterns.
Includes corresponding updates in example and internal code,
and documents the migration in the CHANGELOG.

* chore: Relax result_monad dependency constraint

The `result_monad` dependency was recently upgraded to `^4.0.0`.
This update relaxes the constraint to `any` to provide greater
flexibility and mitigate potential dependency conflicts for consumers
of Arcane, allowing them to use other compatible versions.

* refactor(logging): Replace LoggingInitializationMixin with LoggingInitialization and add LoggingFeature annotation support

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* docs(changelog): Add migration steps for ArcaneThemeService and update themeMode configuration examples

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* refactor(workflow): Simplify analyze-and-test workflow by removing unused environment variables and steps

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* docs(changelog): Update Arcane.log metadata type and add migration steps for structured values

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* test: Add unit tests for authentication and feature flags functionality

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat(theme): track user-defined theme overrides in ArcaneThemeService

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* refactor(service): update service access methods in ArcaneServiceProvider

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* refactor(tests): format login and logout method signatures in MockAuth for improved readability

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat(tests): add unit tests for ArcaneThemeService functionality

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

---------

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* fix(dependencies): update result_monad and arcane_helper_utils constraints

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* fix(version): update version from 2.0.0-dev to 2.0.0

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat(auth): add callbacks for login/logout events in DebugAuthInterface

fix(logging): ensure proper cancellation of log stream subscriber

refactor(arcane): make built-in services list unmodifiable

fix(app): update service notifier on widget updates

fix(service): improve removeService method to return boolean

fix(auth): update BuildContext parameter to unused in setDebug/setNormal

fix(logging): correct log level reference in LogInterceptorContext documentation

fix(theme): update documentation to reflect ThemeMode instead of ThemeData

fix(theme): ensure unawaited cancellation of theme subscriptions

chore(dependencies): update arcane_analysis version to ^1.0.4

test: make ArcaneAuthenticationService reset asynchronous in tests
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* feat(theme): enhance theme synchronization and documentation for ArcaneApp

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* fix(gitignore): add example/.metadata to .gitignore

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

---------

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
Co-authored-by: Hans Kokx <hans.kokx@airahome.com>
This commit is contained in:
2026-05-25 19:47:16 +02:00
committed by GitHub
parent d8b71cc255
commit e0f62df70a
57 changed files with 5968 additions and 865 deletions
@@ -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
+7 -1
View File
@@ -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/
View File
+291 -1
View File
@@ -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<T>()` is now deprecated in favor of
`BuildContext.service<T>()`.
### 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<Result<void, String>> logout() async {
// ...
return Result.ok(null);
}
```
After:
```dart
@override
Future<Result<void, String>> logout({
Future<void> 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<String, String>?` to
`Map<String, Object?>?` 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<LoggingInterface?> init() async => this;
@override
void log(String message, {Map<String, Object?>? metadata, Level? level}) {}
}
```
After:
```dart
class DebugConsole extends LoggingInterface {
@override
void log(String message, {Map<String, Object?>? metadata, Level? level}) {}
}
```
- For SDK-backed loggers, opt into initialization with the mixin.
```dart
class ExternalLogger extends LoggingInterface with LoggingInitialization {
@override
Future<void> init() async {
if (initialized) return;
// Start SDK.
await super.init();
}
@override
void log(String message, {Map<String, Object?>? 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<String, String>` 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
+709 -130
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
]
}
+47 -2
View File
@@ -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.
+14
View File
@@ -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<MaterialColor> colors = [
Colors.red,
Colors.orange,
Colors.yellow,
Colors.green,
Colors.blue,
Colors.purple,
Colors.deepPurple,
];
@@ -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<bool> get isSignedIn => Future.value(_isSignedIn);
@@ -25,24 +22,28 @@ class DebugAuthInterface
);
@override
Future<Result<void, String>> logout() async {
Future<Result<void, String>> logout({
Future<void> Function()? onLoggedOut,
}) async {
Arcane.log("Logging out");
_isSignedIn = false;
return Result.ok(null);
await onLoggedOut?.call();
return const Result.ok(null);
}
@override
Future<Result<void, String>> login<Credentials>({
Credentials? input,
Future<Result<void, String>> login<T>({
T? input,
Future<void> 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<Result<SignUpStep, String>> register<Credentials>({
Credentials? input,
Future<Result<SignUpStep, String>> register<T>({
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
@@ -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<String, dynamic>? metadata,
Map<String, Object?>? metadata,
Level? level = Level.debug,
StackTrace? stackTrace,
Object? extra,
}) {
if (Feature.logging.disabled) return;
debugPrint("[${level!.name}] $message ($metadata)");
}
@override
Future<LoggingInterface?> init() async => I;
}
+616 -91
View File
@@ -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<void> 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 => <String, dynamic>{"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<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
@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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@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<Map<String, String>>(
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<ArcaneLoggingExample> createState() => _ArcaneLoggingExampleState();
}
class _ArcaneLoggingExampleState extends State<ArcaneLoggingExample> {
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<String> _logStreamSubscriber;
// Used to collect the logs from the stream.
final List<String> 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<Credentials>(
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<Environment>(
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<List<ArcaneService>>(
valueListenable: serviceProvider.notifier!,
builder: (context, _, __) {
final FavoriteColorService? service =
ArcaneServiceProvider.serviceOfType<FavoriteColorService>(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<FavoriteColorService>();
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<MaterialColor?>(
valueListenable: service.notifier,
builder: (context, color, _) => buildCard(color),
);
},
);
}
}
-33
View File
@@ -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<String?> get sessionId =>
ValueNotifier<String?>(I._sessionId);
String get newId => uuid.v7();
/// The `Uuid` instance used for generating unique IDs.
static const Uuid uuid = Uuid();
Future<void> init() async {
Arcane.log(
"Initializing ID Service",
level: Level.debug,
);
I._sessionId = uuid.v7();
I._initialized = true;
notifyListeners();
}
}
@@ -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<MaterialColor?> _notifier =
ValueNotifier<MaterialColor?>(null);
ValueNotifier<MaterialColor?> 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<MaterialColor> palette) {
final Iterator<MaterialColor> 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;
}
}
+11 -2
View File
@@ -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,
);
+12 -5
View File
@@ -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";
+61 -30
View File
@@ -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<List<ArcaneService>>? 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<List<ArcaneService>> 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<ArcaneService> 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<ArcaneFeatureFlagService>().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<ArcaneAuthenticationService>().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<ArcaneThemeService>().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<ArcaneEnvironmentService>().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<ArcaneService> 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<ArcaneService> 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<String, Object?>`), 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<String, String>? metadata,
Map<String, Object?>? metadata,
Object? extra,
bool skipAutodetection = false,
}) {
ArcaneLogger.I.log(
message,
@@ -82,6 +112,7 @@ class Arcane {
stackTrace: stackTrace,
metadata: metadata,
extra: extra,
skipAutodetection: skipAutodetection,
);
}
}
+159 -24
View File
@@ -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<ArcaneService> 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<ArcaneApp> createState() => _ArcaneAppState();
}
class _ArcaneAppState extends State<ArcaneApp> {
static const ListEquality<ArcaneService> _serviceListEquality =
ListEquality<ArcaneService>(IdentityEquality<ArcaneService>());
late final ValueNotifier<List<ArcaneService>> _serviceNotifier;
List<ArcaneService> _computeMergedServices() {
final List<ArcaneService> merged =
List<ArcaneService>.from(widget.services);
final Set<Type> 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<List<ArcaneService>>(_computeMergedServices());
Arcane.setRegistry(_serviceNotifier);
}
@override
void didUpdateWidget(covariant ArcaneApp oldWidget) {
super.didUpdateWidget(oldWidget);
final List<ArcaneService> 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,
),
),
),
);
}
@@ -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<Environment> {
/// 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,
);
}
}
-110
View File
@@ -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<ArcaneService> 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<ArcaneServiceProvider>();
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<MyService>();
/// ```
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<MyService>();
/// ```
T? serviceOfType<T extends ArcaneService>() {
final T? builtInService =
Arcane.services.firstWhereOrNull((s) => s.runtimeType == T) as T?;
if (builtInService != null) return builtInService;
final T? foundService =
dependOnInheritedWidgetOfExactType<ArcaneServiceProvider>()
?.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 {}
+36
View File
@@ -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<MyService>(context);
/// ```
static T? ofType<T extends ArcaneService>(BuildContext context) =>
context.service<T>();
/// 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<MyService>(context);
/// ```
static T requiredOfType<T extends ArcaneService>(BuildContext context) =>
context.requiredService<T>();
}
+146
View File
@@ -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<MyService>(context);
/// ```
class ArcaneServiceProvider
extends InheritedNotifier<ValueNotifier<List<ArcaneService>>> {
/// A list of `ArcaneService` instances available through the provider.
List<ArcaneService> get registeredServices =>
List<ArcaneService>.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<ArcaneService> serviceInstances = const [],
ValueNotifier<List<ArcaneService>>? serviceNotifier,
super.key,
}) : super(
notifier: serviceNotifier ??
ValueNotifier<List<ArcaneService>>(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<ArcaneServiceProvider>();
}
/// 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<MyService>(context);
/// ```
static T? serviceOfType<T extends ArcaneService>(BuildContext context) {
final provider = maybeOf(context);
if (provider == null) return null;
return provider.registeredServices.whereType<T>().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<MyService>(context);
/// ```
static T requiredServiceOfType<T extends ArcaneService>(
BuildContext context,
) {
final provider = maybeOf(context);
assert(provider != null, "No ArcaneServiceProvider found");
final T? service = provider!.registeredServices.whereType<T>().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<ArcaneService> 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<ArcaneService> newList =
List<ArcaneService>.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<T extends ArcaneService>() {
final List<ArcaneService> newList =
List<ArcaneService>.from(registeredServices);
final int originalLength = newList.length;
newList.removeWhere((service) => service.runtimeType == T);
if (newList.length == originalLength) {
return false;
}
notifier?.value = newList;
return true;
}
}
@@ -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<MyService>();
/// ```
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<MyService>();
/// ```
T? service<T extends ArcaneService>() {
// First check provider-registered services so app-specific overrides win.
final providerService = ArcaneServiceProvider.serviceOfType<T>(this);
if (providerService != null) return providerService;
// Fall back to built-in services.
return Arcane.services.whereType<T>().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<MyService>();
/// ```
T requiredService<T extends ArcaneService>() {
final service = this.service<T>();
assert(service != null, "No service of type $T found");
return service!;
}
/// Legacy method to maintain backward compatibility.
///
/// Prefer using `service<T>()` instead.
@Deprecated("Deprecated in 2.0.0. Use service<T>() instead")
T? serviceOfType<T extends ArcaneService>() => service<T>();
}
@@ -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,
}
@@ -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<Result<void, String>> logout();
/// Upon a successful logout, the `onLoggedOut` method will be called if it
/// has been provided.
Future<Result<void, String>> logout({
Future<void> 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
@@ -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<AuthenticationStatus> get notifier => _notifier;
/// Returns the current `AuthenticationStatus`.
StreamController<AuthenticationStatus>? _statusStreamController;
StreamController<AuthenticationStatus> get _statusController {
_statusStreamController ??=
StreamController<AuthenticationStatus>.broadcast();
return _statusStreamController!;
}
/// Stream of authentication status updates.
Stream<AuthenticationStatus> 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<bool> _isSignedIn = ValueNotifier<bool>(false);
/// A `ValueNotifier` that emits `true` if the user is currently signed in.
ValueNotifier<bool> get isSignedIn => _isSignedIn;
StreamController<bool>? _signedInStreamController;
StreamController<bool> get _signedInController {
_signedInStreamController ??= StreamController<bool>.broadcast();
return _signedInStreamController!;
}
/// Stream of signed-in boolean updates.
Stream<bool> 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<String?> get accessToken =>
Future<String?> get accessToken async =>
authInterface?.accessToken ?? Future.value("");
/// Returns a JWT refresh token if the registered `ArcaneAuthInterface`
/// provides one.
Future<String?> get refreshToken =>
Future<String?> 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<void> setDebug(
BuildContext context, {
BuildContext _, {
Future<void> Function()? onDebugModeSet,
}) async {
ArcaneEnvironment? environment;
final Environment previousEnvironment = Arcane.environment.current;
try {
environment = context.read<ArcaneEnvironment>();
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<void> setNormal(
BuildContext context, {
BuildContext _, {
Future<void> Function()? onDebugModeUnset,
}) async {
ArcaneEnvironment? environment;
final Environment previousEnvironment = Arcane.environment.current;
try {
environment = context.read<ArcaneEnvironment>();
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<void> 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<void, String> loggedOut = await authInterface!.logout();
final Result<void, String> 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<void> 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<void, String> 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<Result<String, String>> 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.",
);
}
@@ -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)";
}
@@ -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<Environment> _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<ArcaneEnvironment>();
}
/// 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<ArcaneEnvironmentProvider> createState() =>
_ArcaneEnvironmentProviderState();
}
class _ArcaneEnvironmentProviderState extends State<ArcaneEnvironmentProvider> {
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,
);
}
}
@@ -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<Environment> _notifier =
ValueNotifier<Environment>(Environment.normal);
/// A notifier that emits updates when [current] changes.
ValueNotifier<Environment> get notifier => _notifier;
StreamController<Environment>? _environmentStreamController;
StreamController<Environment> get _environmentController {
_environmentStreamController ??= StreamController<Environment>.broadcast();
return _environmentStreamController!;
}
/// Stream of environment updates.
Stream<Environment> 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();
}
}
@@ -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);
}
}
@@ -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
@@ -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<Enum> enabledFeatures;
final ValueChanged<Enum> _enableFeature;
final ValueChanged<Enum> _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<ArcaneFeatureFlagProvider>();
}
/// 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<List<Enum>> get notifier => Arcane.features.notifier;
/// A stream of enabled feature flag updates.
Stream<List<Enum>> 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<ArcaneFeatureFlagsProvider> createState() =>
_ArcaneFeatureFlagsProviderState();
}
class _ArcaneFeatureFlagsProviderState
extends State<ArcaneFeatureFlagsProvider> {
late List<Enum> _enabledFeatures;
void _handleFeatureFlagsChange() {
if (!mounted) return;
final List<Enum> nextEnabled =
List<Enum>.from(Arcane.features.notifier.value);
if (listEquals(nextEnabled, _enabledFeatures)) return;
setState(() {
_enabledFeatures = nextEnabled;
});
}
@override
void initState() {
super.initState();
_enabledFeatures = List<Enum>.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<Enum>.unmodifiable(_enabledFeatures),
enableFeature: enableFeature,
disableFeature: disableFeature,
child: widget.child,
);
}
}
@@ -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<List<Enum>> get notifier => _notifier;
StreamController<List<Enum>>? _enabledFeaturesStreamController;
StreamController<List<Enum>> get _enabledFeaturesController {
_enabledFeaturesStreamController ??=
StreamController<List<Enum>>.broadcast();
return _enabledFeaturesStreamController!;
}
/// Stream of enabled feature list updates.
Stream<List<Enum>> 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<Enum>.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<Enum>.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<Enum>.from(_notifier.value));
I._initialized = false;
}
@override
void dispose() {
unawaited(_enabledFeaturesStreamController?.close());
_enabledFeaturesStreamController = null;
super.dispose();
}
void _listener() {
_enabledFeatures
..clear()
..addAll(notifier.value);
}
}
+39
View File
@@ -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<String, Object?>? 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<String, Object?>?,
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,
);
}
}
@@ -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);
}
}
+35 -22
View File
@@ -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<LoggingInterface?> 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<String, dynamic>? metadata,
Map<String, Object?>? 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<void> init();
}
/// Default initialization behavior for interfaces that opt into lifecycle.
mixin LoggingInitialization implements LoggingInitializable {
bool _initialized = false;
@override
bool get initialized => _initialized;
@override
Future<void> 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;
}
+341 -65
View File
@@ -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<LoggingInterface> _interfaces = [];
final List<_LoggingInterfaceRegistration> _interfaceRegistrations = [];
final List<LogInterceptor> _interceptors = [];
/// A list of registered logging interfaces.
List<LoggingInterface> get interfaces => I._interfaces;
List<LoggingInterface> get interfaces => [
for (final _LoggingInterfaceRegistration registration
in I._interfaceRegistrations)
registration.interface,
];
/// A list of globally registered interceptors.
List<LogInterceptor> get interceptors => [
...I._interceptors,
];
final Map<String, String> _additionalMetadata = {};
/// Additional metadata that is included in all logs.
Map<String, String> get additionalMetadata => I._additionalMetadata;
StreamController<String>? _logStreamController;
StreamController<String> get _logController {
_logStreamController ??= StreamController<String>.broadcast();
return _logStreamController!;
}
/// Stream of log messages being received and sent to the registered interfaces.
Stream<String> 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<String, String>?], _optional_):
/// - `metadata` ([Map<String, Object?>?], _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<String, String>? 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<String, Object?>? 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<String, Object?> 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("<anonymous", "");
final List<String> fileAndLineParts = [
...?parts?.split("(package:").lastOrNull?.split(":"),
];
if (fileAndLineParts.length < 2) {
filenameAndLineNumber = fileAndLineParts.firstOrNull;
} else {
filenameAndLineNumber = "${fileAndLineParts[0]}:${fileAndLineParts[1]}";
}
}
metadata ??= <String, String>{};
// 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<String> parts = StackTrace.current
.toString()
.split("\n")[2]
.split(RegExp("#2"))[1]
.trimLeft()
.split(".");
module ??= parts.first.replaceFirst("new ", "");
method ??= parts[1].split(" ").first.replaceAll("<anonymous", "");
final List<String> 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<String, Object?>.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<ArcaneLogger> registerInterface(
LoggingInterface loggingInterface, {
List<LogInterceptor>? 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<LogInterceptor> 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<ArcaneLogger> registerInterfaces(
List<LoggingInterface> interfaces,
) async {
List<LoggingInterface> interfaces, {
Map<LoggingInterface, List<LogInterceptor>>? 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<ArcaneLogger> initializeInterfaces() async {
assert(
I._interfaces.isNotEmpty,
"No logging interfaces have been registered.",
/// Unregisters a [LoggingInterface] from the [ArcaneLogger], if it was
/// previously registered.
Future<ArcaneLogger> 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<ArcaneLogger> unregisterInterfaces(
List<LoggingInterface> 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<ArcaneLogger> 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<ArcaneLogger> 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<LogInterceptor> interceptors,
required LogInterceptorContext context,
}) {
LogEvent? currentEvent = event;
for (final LogInterceptor interceptor in List<LogInterceptor>.from(
interceptors,
)) {
if (currentEvent == null) return null;
currentEvent = interceptor(currentEvent, context: context);
}
return currentEvent;
}
}
final class _LoggingInterfaceRegistration {
_LoggingInterfaceRegistration({
required this.interface,
List<LogInterceptor>? interceptors,
}) : interceptors = [
...?interceptors,
];
final LoggingInterface interface;
final List<LogInterceptor> interceptors;
}
@@ -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;
}
}
@@ -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<ThemeMode> get systemTheme =>
ValueNotifier<ThemeMode>(_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;
}
}
+26
View File
@@ -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<ArcaneTheme>();
}
@override
bool updateShouldNotify(ArcaneTheme oldWidget) {
return themeMode != oldWidget.themeMode ||
followSystem != oldWidget.followSystem ||
theme != oldWidget.theme;
}
}
@@ -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;
}
}
+298
View File
@@ -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<ThemeMode>? _systemStreamController;
StreamController<ThemeMode> get _systemController {
_systemStreamController ??= StreamController<ThemeMode>.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<ThemeMode> get themeModeChanges => I._themeModeController.stream;
StreamController<ThemeMode>? _themeModeStreamController;
StreamController<ThemeMode> get _themeModeController {
_themeModeStreamController ??= StreamController<ThemeMode>.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<ThemeData> get themeDataChanges => I._themeController.stream;
/// Tracks whether a custom light/dark theme has been explicitly provided by the user.
bool _themeOverriddenByUser = false;
StreamController<ThemeData>? _themeStreamController;
StreamController<ThemeData> get _themeController {
_themeStreamController ??= StreamController<ThemeData>.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<ThemeData> get darkTheme => I._darkTheme;
final ValueNotifier<ThemeData> _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<ThemeData> get lightTheme => I._lightTheme;
final ValueNotifier<ThemeData> _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;
}
}
@@ -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<ArcaneThemeSwitcher> createState() => _ArcaneThemeSwitcherState();
}
class _ArcaneThemeSwitcherState extends State<ArcaneThemeSwitcher>
with WidgetsBindingObserver {
bool _initialized = false;
late final StreamSubscription<ThemeMode> _themeModeSubscription;
late final StreamSubscription<ThemeData> _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();
}
}
+6 -5
View File
@@ -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
+21
View File
@@ -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<ArcaneFeatureFlagService>()));
expect(services, contains(isA<ArcaneAuthenticationService>()));
expect(services, contains(isA<ArcaneReactiveTheme>()));
expect(services, contains(isA<ArcaneEnvironmentService>()));
});
});
}
+338
View File
@@ -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<ArcaneService> 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<ArcaneFeatureFlagService>(),
isNotEmpty,
);
expect(
provider.registeredServices
.whereType<ArcaneAuthenticationService>(),
isNotEmpty,
);
expect(
provider.registeredServices.whereType<ArcaneThemeService>(),
isNotEmpty,
);
expect(
provider.registeredServices
.whereType<ArcaneEnvironmentService>(),
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<ArcaneEnvironmentService>()
.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<AssertionError>()),
);
});
testWidgets("static serviceOfType<T> method returns correct service",
(tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service =
ArcaneServiceProvider.serviceOfType<MockArcaneService>(
context,
);
expect(service, isNotNull);
expect(service, isA<MockArcaneService>());
return const SizedBox();
},
),
),
);
});
testWidgets(
"static serviceOfType<T> 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<MockArcaneService>(
context,
);
expect(service, isA<MockArcaneService>());
// Returns null for unregistered services
expect(
ArcaneServiceProvider.serviceOfType<UnregisteredService>(
context,
),
isNull,
);
return const SizedBox();
},
),
),
);
});
testWidgets("service<T> extension returns correct service", (tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service = context.service<MockArcaneService>();
expect(service, isNotNull);
expect(service, isA<MockArcaneService>());
return const SizedBox();
},
),
),
);
});
testWidgets("service<T> 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<ArcaneService>();
expect(service, same(providerService));
return const SizedBox();
},
),
),
);
});
testWidgets(
"requiredService<T> 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<MockArcaneService>();
expect(service, isA<MockArcaneService>());
// Should throw for missing service
expect(
() => context.requiredService<UnregisteredService>(),
throwsA(isA<AssertionError>()),
);
return const SizedBox();
},
),
),
);
});
testWidgets("service<T> returns null for unregistered service",
(tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service = context.service<UnregisteredService>();
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<MockArcaneService>();
expect(service, isNotNull);
expect(service, isA<MockArcaneService>());
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<MockArcaneService>();
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<T> static helper works", (tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service = ArcaneService.ofType<MockArcaneService>(context);
expect(service, isNotNull);
expect(service, isA<MockArcaneService>());
return const SizedBox();
},
),
),
);
});
testWidgets("ArcaneService.requiredOf<T> static helper works",
(tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service =
ArcaneService.requiredOfType<MockArcaneService>(context);
expect(service, isA<MockArcaneService>());
expect(
() =>
ArcaneService.requiredOfType<UnregisteredService>(context),
throwsA(isA<AssertionError>()),
);
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 {}
@@ -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);
});
});
}
@@ -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<Result<void, String>> login<T>({
T? input,
Future<void> Function()? onLoggedIn,
}) async {
if (onLoggedIn != null) await onLoggedIn();
return const Result.ok(null);
}
@override
Future<bool> get isSignedIn => Future.value(true);
@override
Future<String?>? get accessToken => Future.value("token");
@override
Future<String?>? get refreshToken => Future.value("refresh");
@override
Future<void> init() async {}
@override
Future<Result<void, String>> logout({
Future<void> 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);
});
}
@@ -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<Map<String, String>>(
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<Map<String, String>>(
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>[
AuthenticationStatus.authenticated,
AuthenticationStatus.unauthenticated,
],
),
);
final signedInEvents = expectLater(
ArcaneAuthenticationService.I.signedInChanges,
emitsInOrder(<bool>[true, false]),
);
ArcaneAuthenticationService.I.setAuthenticated();
ArcaneAuthenticationService.I.setUnauthenticated();
await statusEvents;
await signedInEvents;
});
});
}
@@ -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);
});
}
@@ -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<Stream<List<Enum>>>());
});
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();
},
),
);
});
}
@@ -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<Enum>? emitted;
final subscription =
featureFlags.enabledFeaturesChanges.listen((features) {
emitted = features;
});
featureFlags.enableFeature(MockFeature.test);
await Future<void>.delayed(Duration.zero);
expect(emitted, contains(MockFeature.test));
await subscription.cancel();
});
test("enabledFeaturesChanges works after listener cancellation",
() async {
List<Enum>? firstEmission;
final firstSubscription =
featureFlags.enabledFeaturesChanges.listen((features) {
firstEmission = features;
});
featureFlags.enableFeature(MockFeature.test);
await Future<void>.delayed(Duration.zero);
expect(firstEmission, contains(MockFeature.test));
await firstSubscription.cancel();
List<Enum>? secondEmission;
final secondSubscription =
featureFlags.enabledFeaturesChanges.listen((features) {
secondEmission = features;
});
featureFlags.disableFeature(MockFeature.test);
await Future<void>.delayed(Duration.zero);
expect(secondEmission, isNot(contains(MockFeature.test)));
await secondSubscription.cancel();
});
});
});
}
enum MockFeature {
test,
another,
}
@@ -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<String, Object?>? 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<DummyLogEvent>());
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);
});
});
}
@@ -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<LogEvent> events = [];
@override
Future<void> init() async {
await super.init();
initCallCount += 1;
}
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
events.add(
LogEvent(
message: message,
metadata: metadata == null ? null : Map<String, Object?>.from(metadata),
level: level,
stackTrace: stackTrace,
extra: extra,
),
);
}
}
class TestPassiveLoggingInterface extends LoggingInterface {
TestPassiveLoggingInterface(this.name);
final String name;
final List<LogEvent> events = [];
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
events.add(
LogEvent(
message: message,
metadata: metadata == null ? null : Map<String, Object?>.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<void>.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<void>.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<LoggingInterface>()),
);
});
test("registering an interface doesn't initialize it", () async {
await Arcane.logger.registerInterface(myInterface);
expect(Arcane.logger.interfaces.first, isA<LoggingInterface>());
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<TestLoggingInterface>()),
);
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<String, String> 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);
});
});
});
}
@@ -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<void>.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<void>.delayed(Duration.zero);
expect(firstEmission, ThemeMode.dark);
await firstSubscription.cancel();
ThemeMode? secondEmission;
final secondSubscription = theme.themeModeChanges.listen((mode) {
secondEmission = mode;
});
theme.switchTheme();
await Future<void>.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<void>.delayed(Duration.zero);
expect(darkNotified, true);
expect(lightNotified, true);
expect(emittedThemeData, isNotNull);
theme.switchTheme();
await Future<void>.delayed(Duration.zero);
expect(theme.currentThemeMode, ThemeMode.dark);
expect(emittedMode, ThemeMode.dark);
theme.switchTheme();
await Future<void>.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));
});
});
});
}
@@ -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);
});
});
}
@@ -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();
},
),
),
);
});
}
@@ -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<ThemeData>());
});
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();
});
});
}