mirror of
https://github.com/hanskokx/arcane_framework.git
synced 2026-05-14 10:29:06 +02:00
1e84e8f648
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
671 lines
20 KiB
Markdown
671 lines
20 KiB
Markdown
# Arcane Framework
|
||
|
||
> _**A**gnostic **R**eusable **C**omponent **A**rchitecture for **N**ew
|
||
> **E**cosystems_
|
||
|
||

|
||
|
||
The Arcane Framework is a powerful Dart package designed to provide a robust
|
||
architecture for managing key application services such as logging,
|
||
authentication, secure storage, feature flags, theming, and more. This framework
|
||
is ideal for building scalable applications that require dynamic configuration
|
||
and service management.
|
||
|
||
- [Arcane Framework](#arcane-framework)
|
||
- [Features](#features)
|
||
- [Installation](#installation)
|
||
- [Usage](#usage)
|
||
- [Services](#services)
|
||
- [Defining an example `ArcaneService`](#defining-an-example-arcaneservice)
|
||
- [Registering and unregistering an `ArcaneService`](#registering-and-unregistering-an-arcaneservice)
|
||
- [Locating an `ArcaneService`](#locating-an-arcaneservice)
|
||
- [Using `ArcaneService` services](#using-arcaneservice-services)
|
||
- [Feature Flags](#feature-flags)
|
||
- [Logging](#logging)
|
||
- [Authentication](#authentication)
|
||
- [Dynamic Theming](#dynamic-theming)
|
||
- [Contributing](#contributing)
|
||
|
||
## Features
|
||
|
||
- **Service Management**: Centralized access to multiple services (logging,
|
||
authentication, theming, etc.).
|
||
- **Feature Flags**: Dynamically enable or disable features using
|
||
`ArcaneFeatureFlags`.
|
||
- **Logging**: Easily log messages with metadata, stack traces, and different
|
||
log levels via `ArcaneLogger`.
|
||
- **Authentication**: Built-in support for handling user authentication
|
||
workflows.
|
||
- **Dynamic Theming**: Switch between light and dark themes with
|
||
`ArcaneReactiveTheme`.
|
||
|
||
## Installation
|
||
|
||
To use Arcane Framework in your Dart or Flutter project, follow these steps:
|
||
|
||
1. Add the dependency to your `pubspec.yaml`:
|
||
|
||
```shell
|
||
flutter pub add arcane_framework
|
||
```
|
||
|
||
2. (optional) Wrap your `MaterialApp` or `CupertinoApp` with the `ArcaneApp`
|
||
Widget:
|
||
|
||
```dart
|
||
import 'package:arcane_framework/arcane_framework.dart';
|
||
|
||
void main() {
|
||
runApp(
|
||
ArcaneApp(
|
||
child: MainApp(),
|
||
),
|
||
);
|
||
}
|
||
```
|
||
|
||
## Usage
|
||
|
||
The following sections provide more information about how to use the package's
|
||
available features.
|
||
|
||
### Services
|
||
|
||
The Arcane Framework provides a centralized way to manage services across your
|
||
application, while optionally leveraging a built-in service locator.
|
||
|
||
Unlike most of the features in Arcane, a _service_ is broadly user-defined. What
|
||
a service is, or does, is not rigorously enforced by the framework itself. What
|
||
an `ArcaneService` offers, however, is the ability to be registered (and
|
||
unregistered), as well as located via `BuildContext`. The locators are the key
|
||
value proposition that Arcane provides.
|
||
|
||
The following tools are provided by Arcane to assist with creating and using
|
||
services:
|
||
|
||
- `ArcaneService`: The base class from which to extend your own services. This
|
||
what Arcane uses to locate services.
|
||
- `ArcaneServiceProvider`: A widget used to provide access to registered
|
||
`ArcaneService` instances. **Note**: This widget is already part of the*
|
||
`ArcaneApp`*widget, however if you are not using the `ArcaneApp` widget you
|
||
can instead use this widget directly.
|
||
- The `service<T>` and `requiredService<T>` extensions on `BuildContext`:
|
||
nullable and non-nullable getters used to locate a given `ArcaneService` via
|
||
`BuildContext`. **Note**: Use of these extensions requires that an
|
||
`ArcaneServiceProvider` widget is in your widget tree, either by adding it
|
||
directly or by using the `ArcaneApp` widget.
|
||
|
||
#### Defining an example `ArcaneService`
|
||
|
||
As noted previously, _what_ a service is or does is not enforced by the
|
||
framework. Therefore, the following example is only in service of the remainder
|
||
of the documentation of the Arcane services feature.
|
||
|
||
This example service is a singleton service that stores and provides access to a
|
||
user's favorite color, leveraging a `ValueNotifier` to trigger rebuilds as
|
||
appropriate:
|
||
|
||
```dart
|
||
class FavoriteColorService extends ArcaneService {
|
||
FavoriteColorService._internal();
|
||
static final FavoriteColorService _instance = FavoriteColorService._internal();
|
||
static FavoriteColorService get I => _instance;
|
||
|
||
final ValueNotifier<Color?> _notifier = ValueNotifier<Color?>(null);
|
||
ValueNotifier<Color?> get notifier => _notifier;
|
||
|
||
Color? get myFavoriteColor => _notifier.value;
|
||
|
||
void setMyFavoriteColor(Color? color) {
|
||
if (_notifier.value != color) {
|
||
_notifier.value = color;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Registering and unregistering an `ArcaneService`
|
||
|
||
The quickest and easiest way to register an `ArcaneService` is to use the
|
||
built-in `ArcaneApp` widget. However, this is not the _only_ method available.
|
||
|
||
To register your `ArcaneService` using an app with the `ArcaneApp` widget, you
|
||
have a couple of options. First, you can simply add the service (in our case, a
|
||
singleton instance) to the `services` list directly:
|
||
|
||
```dart
|
||
ArcaneApp(
|
||
services: [
|
||
FavoriteColorService.I,
|
||
],
|
||
child: MainApp(),
|
||
),
|
||
```
|
||
|
||
You can also defer adding the service by invoking `ArcaneServiceProvider`. Note
|
||
that this requires either `ArcaneServiceProvider` _or_ `ArcaneApp` (which
|
||
already includes `ArcaneServiceProvider`) to be in your widget tree.
|
||
|
||
```dart
|
||
// The service is not included at compile-time
|
||
ArcaneApp(
|
||
child: MainApp(),
|
||
),
|
||
|
||
// Add the service at runtime
|
||
ArcaneServiceProvider.of(context).addService(FavoriteColorService.I);
|
||
```
|
||
|
||
Unregistering an already registered `ArcaneService` is as simple as:
|
||
|
||
```dart
|
||
ArcaneServiceProvider.of(context).removeService<FavoriteColorService>()
|
||
```
|
||
|
||
#### Locating an `ArcaneService`
|
||
|
||
There are numerous ways to locate a registered `ArcaneService`. Feel free to use
|
||
whatever method you prefer:
|
||
|
||
```dart
|
||
// If a service of the given type is not registered, `null` is returned.
|
||
final FavoriteColorService? nullableService = ArcaneService.ofType<FavoriteColorService>(context);
|
||
final FavoriteColorService? nullableViaContext = context.service<FavoriteColorService>();
|
||
final FavoriteColorService? nullableViaProvider = ArcaneServiceProvider.serviceOfType<FavoriteColorService>(context);
|
||
|
||
// If a service of the given type is not registered, an exception is thrown.
|
||
final FavoriteColorService nonNullableService = ArcaneService.requiredOfType<FavoriteColorService>(context);
|
||
final FavoriteColorService nonNullableViaContext = context.requiredService<FavoriteColorService>();
|
||
final FavoriteColorService nonNullableViaProvider = ArcaneServiceProvider.requiredServiceOfType<FavoriteColorService>(context);
|
||
```
|
||
|
||
In addition, you can locate a `ArcaneServiceProvider` in a similar way:
|
||
|
||
```dart
|
||
// Returns `null` if no `ArcaneServiceProvider` is found in the widget tree.
|
||
final ArcaneServiceProvider? nullableProvider = ArcaneServiceProvider.maybeOf(context);
|
||
|
||
// Throws an exception if no `ArcaneServiceProvider` is found in the widget tree.
|
||
final ArcaneServiceProvider nonNullableProvider = ArcaneServiceProvider.of(context);
|
||
```
|
||
|
||
#### Using `ArcaneService` services
|
||
|
||
Since the `ArcaneService` class includes a `ChangeNotifier`, invoking the
|
||
`notifyListeners()` method inside a service will trigger a rebuild. Using our
|
||
`FavoriteColorService` from earlier, we can add a listener to our notifier
|
||
value:
|
||
|
||
```dart
|
||
final FavoriteColorService service = ArcaneService.requiredOfType<FavoriteColorService>(context);
|
||
|
||
service.notifier.addListener(() {
|
||
final Color? color = service.myFavoriteColor;
|
||
// Do something with our value
|
||
});
|
||
```
|
||
|
||
We can also simply user a `ValueListenableBuilder`:
|
||
|
||
```dart
|
||
ValueListenableBuilder(
|
||
valueListenable: ArcaneService.requiredOfType<FavoriteColorService>(context).notifier,
|
||
builder: (context, color, _) {
|
||
return Text("My favorite color is $color"),
|
||
}
|
||
)
|
||
```
|
||
|
||
Meanwhile, setting the value in our service can be accomplished in the following
|
||
manner:
|
||
|
||
```dart
|
||
ArcaneService.requiredOfType<FavoriteColorService>(context).setMyFavoriteColor(Colors.purple);
|
||
```
|
||
|
||
Again, this example is _not_ the only way the Arcane Service system can be
|
||
utilized. One is limited only by their imagination!
|
||
|
||
### Feature Flags
|
||
|
||
You can easily manage feature flags using the `ArcaneFeatureFlags` built-in
|
||
service. Feature flags are useful for enabling or disabling different parts of
|
||
your application under different circumstances. For example, you may want to
|
||
enable a new feature only once it has finished development and testing, while
|
||
still having the ability to ship the unfinished code. You could also leverage
|
||
feature flags to enable different modes within your application (e.g., "free" vs
|
||
"paid"). Furthermore, they can be used for A/B testing. The options are truly
|
||
unlimited.
|
||
|
||
To get started, create an `enum` to define your features:
|
||
|
||
```dart
|
||
enum Feature {
|
||
awesomeFeature(true),
|
||
prettyOkFeature(false),
|
||
;
|
||
|
||
/// Determines whether the given [Feature] is enabled by default when the
|
||
/// application launches. Features can be enabled or disabled during runtime,
|
||
/// regardless of this value.
|
||
final bool enabledAtStartup;
|
||
|
||
const Feature(this.enabledAtStartup);
|
||
}
|
||
```
|
||
|
||
Next, ensure that your features are enabled at startup by registering them
|
||
within the feature flag service:
|
||
|
||
```dart
|
||
void main() {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// Register your Enum that you'll be using to enable and disable features.
|
||
for (final Feature feature in Feature.values) {
|
||
if (feature.enabledAtStartup) Arcane.features.enableFeature(feature);
|
||
}
|
||
|
||
runApp(const ArcaneApp());
|
||
}
|
||
```
|
||
|
||
When you want to determine if a feature is enabled, you can use one of the
|
||
helper extensions:
|
||
|
||
```dart
|
||
// Via an enum extension
|
||
final bool isMyAwesomeFeatureEnabled = Feature.awesomeFeature.enabled;
|
||
|
||
// Via the Arcane feature flag service
|
||
final bool isMyPrettyOkFeatureDisabled = Arcane.features.isDisabled(Feature.prettyOkFeature);
|
||
```
|
||
|
||
You can also enable and disable features at runtime:
|
||
|
||
```dart
|
||
// Via an enum extension
|
||
Feature.awesomeFeature.disable();
|
||
Feature.prettyOkFeature.enable();
|
||
|
||
// Via the Arcane features service
|
||
Arcane.features.disableFeature(Feature.awesomeFeature);
|
||
Arcane.features.enableFeature(Feature.prettyOkFeature);
|
||
```
|
||
|
||
To get a list of the currently enabled features, simply ask the Arcane feature
|
||
flag service:
|
||
|
||
```dart
|
||
final List<Enum> enabledFeatures = Arcane.features.enabledFeatures;
|
||
```
|
||
|
||
It is also possible to add a listener to watch for changes in the enabled
|
||
features.
|
||
|
||
```dart
|
||
Arcane.features.notifier.addListener(() {
|
||
print("Features changed: ${Arcane.features.enabledFeatures}");
|
||
});
|
||
```
|
||
|
||
Note that it is possible to register multiple different `Enum` types in the
|
||
feature flag service, should one have a need to do so.
|
||
|
||
### Logging
|
||
|
||
The Arcane Framework provides a robust logging system for your application. This
|
||
allows you to easily log messages with metadata, stack traces, and different log
|
||
levels. The framework also provides an easy way to configure the logger's
|
||
behavior (e.g., whether or not to show stack traces).
|
||
|
||
To get started, first create one or more logging interfaces, extending the
|
||
`LoggingInterface` base class.
|
||
|
||
```dart
|
||
class DebugConsole implements LoggingInterface {
|
||
static final DebugConsole _instance = DebugConsole._internal();
|
||
static DebugConsole get I => _instance;
|
||
DebugConsole._internal();
|
||
|
||
final bool _initialized = true;
|
||
|
||
@override
|
||
bool get initialized => I._initialized;
|
||
|
||
|
||
@override
|
||
void log(
|
||
String message, {
|
||
Map<String, dynamic>? metadata,
|
||
Level? level,
|
||
StackTrace? stackTrace,
|
||
}) {
|
||
debugPrint(
|
||
"$message\n"
|
||
"$metadata\n",
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<LoggingInterface?> init() async => I;
|
||
}
|
||
```
|
||
|
||
Next, register your logging interface with the Arcane logger service:
|
||
|
||
```dart
|
||
// Register your logging interface(s)
|
||
await Arcane.logger.registerInterfaces([
|
||
DebugConsole.I,
|
||
]);
|
||
|
||
// Initialize registered logging interfaces
|
||
// NOTE: This step may be deferred until a user has consented to app tracking.
|
||
await Arcane.logger.initializeInterfaces();
|
||
```
|
||
|
||
Finally, add any additional persistent metadata to your log messages (optional)
|
||
and log a message:
|
||
|
||
```dart
|
||
// Add metadata to the logger
|
||
Arcane.logger.addPersistentMetadata({
|
||
"app_name": "My App",
|
||
"environment": "production",
|
||
});
|
||
|
||
// Log a message!
|
||
Arcane.log(
|
||
"This is a debug message",
|
||
level: Level.debug,
|
||
module: "ModuleName",
|
||
method: "MethodName",
|
||
metadata: {"key": "value"},
|
||
stackTrace: StackTrace.current,
|
||
);
|
||
```
|
||
|
||
Multiple logging interfaces can be registered simultaneously.
|
||
|
||
**Important**: Logging interfaces should generally be initialized after being
|
||
registered with the logger service. This ensures that all logging interfaces are
|
||
properly initialized before any messages are logged. This should typically be
|
||
done manually in order to properly present the user with a message stating that
|
||
they're about to be prompted for tracking permissions (on iOS).
|
||
|
||
### Authentication
|
||
|
||
The Arcane Framework provides a useful interface for performing common
|
||
authentication tasks, such as registration, password resets, login, log out, and
|
||
enabling a debug mode.
|
||
|
||
To get started, create an authentication interface provider and register it in
|
||
the Arcane authentication module:
|
||
|
||
```dart
|
||
import "package:arcane_framework/arcane_framework.dart";
|
||
|
||
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;
|
||
|
||
@override
|
||
Future<bool> get isSignedIn => Future.value(_isSignedIn);
|
||
bool _isSignedIn = false;
|
||
|
||
@override
|
||
Future<String?> get accessToken => isSignedIn.then(
|
||
(loggedIn) => loggedIn ? "access_token" : null,
|
||
);
|
||
|
||
@override
|
||
Future<String?> get refreshToken => isSignedIn.then(
|
||
(loggedIn) => loggedIn ? "refresh_token" : null,
|
||
);
|
||
|
||
@override
|
||
Future<Result<void, String>> logout() async {
|
||
Arcane.log("Logging out");
|
||
|
||
_isSignedIn = false;
|
||
|
||
return Result.ok(null);
|
||
}
|
||
|
||
@override
|
||
Future<Result<void, String>> login<Credentials>({
|
||
Credentials? input,
|
||
Future<void> Function()? onLoggedIn,
|
||
}) async {
|
||
final bool alreadyLoggedIn = await isSignedIn;
|
||
|
||
if (alreadyLoggedIn) return Result.ok(null);
|
||
|
||
final credentials = input as ({String email, String password});
|
||
|
||
final String email = credentials.email;
|
||
final String password = credentials.password;
|
||
|
||
Arcane.log("Logging in as $email using password $password");
|
||
|
||
_isSignedIn = true;
|
||
|
||
return Result.ok(null);
|
||
}
|
||
|
||
// Provided by the ArcaneAuthAccountRegistration mixin
|
||
@override
|
||
Future<Result<String, String>> resendVerificationCode<T>({
|
||
T? input,
|
||
}) async {
|
||
Arcane.log("Re-sending verification code to $input");
|
||
return Result.ok("Code sent");
|
||
}
|
||
|
||
// Provided by the ArcaneAuthAccountRegistration mixin
|
||
@override
|
||
Future<Result<SignUpStep, String>> register<Credentials>({
|
||
Credentials? input,
|
||
}) async {
|
||
if (input != null) {
|
||
final credentials = input as ({String email, String password});
|
||
|
||
final String email = credentials.email;
|
||
final String password = credentials.password;
|
||
|
||
Arcane.log("Creating account for $email with password $password");
|
||
}
|
||
|
||
return Result.ok(SignUpStep.confirmSignUp);
|
||
}
|
||
|
||
// Provided by the ArcaneAuthAccountRegistration mixin
|
||
@override
|
||
Future<Result<bool, String>> confirmSignup({
|
||
String? username,
|
||
String? confirmationCode,
|
||
}) async {
|
||
Arcane.log(
|
||
"Confirming registration for $username with code $confirmationCode",
|
||
);
|
||
return Result.ok(true);
|
||
}
|
||
|
||
// Provided by the ArcaneAuthPasswordManagement mixin
|
||
@override
|
||
Future<Result<bool, String>> resetPassword({
|
||
String? email,
|
||
String? newPassword,
|
||
String? code,
|
||
}) async {
|
||
Arcane.log("Resetting password for $email");
|
||
return Result.ok(true);
|
||
}
|
||
|
||
@override
|
||
Future<void> init() async {
|
||
Arcane.log("Debug auth interface initialized.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Register an interface to handle user authentication.
|
||
await Arcane.auth.registerInterface(AuthProviderInterface.I);
|
||
```
|
||
|
||
Once your interface has been created and registered, you can use it to perform a
|
||
number of common authentication tasks:
|
||
|
||
```dart
|
||
// Register an account using the ArcaneAuthAccountRegistration mixin
|
||
final nextStep = await Arcane.auth.register<Credentials>(
|
||
input: ("email": "user@example.com", "password": "password123"),
|
||
);
|
||
|
||
// Confirm a newly registered account using the ArcaneAuthAccountRegistration mixin
|
||
final accountConfirmed = await Arcane.auth.confirmSignup(
|
||
email: "user@example.com",
|
||
confirmationCode: "123456",
|
||
);
|
||
|
||
// Re-send a verification code using the ArcaneAuthAccountRegistration mixin
|
||
final response = await Arcane.auth.resendVerificationCode("user@example.com");
|
||
|
||
// Initiate a password reset flow using the ArcaneAuthPasswordManagement mixin
|
||
final passwordResetStarted = await Arcane.auth.resetPassword(
|
||
email: "user@example.com",
|
||
newPassword: "password456",
|
||
);
|
||
|
||
// Confirm password reset using the ArcaneAuthPasswordManagement mixin
|
||
final passwordResetFinished = await Arcane.auth.resetPassword(
|
||
email: "user@example.com",
|
||
newPassword: "password456",
|
||
confirmationCode: "123456",
|
||
);
|
||
|
||
// Sign in with email and password
|
||
final result = await Arcane.auth.login(
|
||
input: ("email": "user@example.com", "password": "password123")
|
||
onLoggedIn: () => Arcane.log("User logged in"),
|
||
);
|
||
|
||
// Sign out
|
||
await Arcane.auth.logout();
|
||
```
|
||
|
||
### Dynamic Theming
|
||
|
||
The Arcane Framework provides a simple interface for managing themes in your
|
||
application, with dynamic switching between dark and light themes based on the
|
||
user's system settings, or manually switching between themes.
|
||
|
||
To get started, first register your `ThemeData` objects with the Arcane theme
|
||
module:
|
||
|
||
```dart
|
||
void main() {
|
||
// Set your Themes
|
||
Arcane.theme
|
||
..setDarkTheme(darkTheme)
|
||
..setLightTheme(lightTheme);
|
||
|
||
runApp(
|
||
ArcaneApp(
|
||
child: MainApp(),
|
||
),
|
||
);
|
||
}
|
||
```
|
||
|
||
From here, you can either follow the system theme:
|
||
|
||
```dart
|
||
// Follow the system's theme mode
|
||
class MainApp extends StatefulWidget {
|
||
const MainApp({super.key});
|
||
|
||
@override
|
||
State<MainApp> createState() => _MainAppState();
|
||
}
|
||
|
||
class _MainAppState extends State<MainApp> {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ArcaneApp(
|
||
child: MaterialApp(
|
||
theme: Arcane.theme.light,
|
||
darkTheme: Arcane.theme.dark,
|
||
themeMode: Arcane.theme.systemTheme.value,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
Arcane.theme.followSystemTheme(context);
|
||
super.didChangeDependencies();
|
||
}
|
||
}
|
||
```
|
||
|
||
or manually control the theme mode:
|
||
|
||
```dart
|
||
// Manually control the theme mode
|
||
class MainApp extends StatelessWidget {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ArcaneApp(
|
||
child: MaterialApp(
|
||
theme: Arcane.theme.light,
|
||
darkTheme: Arcane.theme.dark,
|
||
themeMode: Arcane.theme.currentMode,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
Then, you can switch modes whenever you want:
|
||
|
||
```dart
|
||
// Switch between light and dark themes
|
||
Arcane.theme.switchTheme();
|
||
|
||
// Access current theme data
|
||
final ThemeData currentTheme = Arcane.theme.currentMode == ThemeMode.dark
|
||
? Arcane.theme.dark
|
||
: Arcane.theme.light;
|
||
|
||
if (context.isDarkMode) {
|
||
// Do something when dark mode is active
|
||
}
|
||
|
||
// Set a custom dark theme
|
||
Arcane.theme.setDarkTheme(customDarkTheme);
|
||
|
||
// Set a custom light theme
|
||
Arcane.theme.setLightTheme(customLightTheme);
|
||
```
|
||
|
||
## Contributing
|
||
|
||
We welcome contributions to the Arcane Framework. If you’d like to contribute,
|
||
please:
|
||
|
||
1. Fork the repository.
|
||
2. Create a new feature branch.
|
||
3. Submit a pull request with a description of your changes.
|
||
|
||
For detailed information on how to contribute, please refer to CONTRIBUTING.md.
|