Files
arcane_framework/README.md

671 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Arcane Framework
> _**A**gnostic **R**eusable **C**omponent **A**rchitecture for **N**ew
> **E**cosystems_
![style: arcane analysis](https://img.shields.io/badge/style-arcane_analysis-6E35AE)
The Arcane Framework is a powerful Dart package designed to provide a robust
architecture for managing key application services such as logging,
authentication, secure storage, feature flags, theming, and more. This framework
is ideal for building scalable applications that require dynamic configuration
and service management.
- [Arcane Framework](#arcane-framework)
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [Services](#services)
- [Defining an example `ArcaneService`](#defining-an-example-arcaneservice)
- [Registering and unregistering an `ArcaneService`](#registering-and-unregistering-an-arcaneservice)
- [Locating an `ArcaneService`](#locating-an-arcaneservice)
- [Using `ArcaneService` services](#using-arcaneservice-services)
- [Feature Flags](#feature-flags)
- [Logging](#logging)
- [Authentication](#authentication)
- [Dynamic Theming](#dynamic-theming)
- [Contributing](#contributing)
## Features
- **Service Management**: Centralized access to multiple services (logging,
authentication, theming, etc.).
- **Feature Flags**: Dynamically enable or disable features using
`ArcaneFeatureFlags`.
- **Logging**: Easily log messages with metadata, stack traces, and different
log levels via `ArcaneLogger`.
- **Authentication**: Built-in support for handling user authentication
workflows.
- **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 youd 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.