arcane_framework 2.0.5
arcane_framework: ^2.0.5 copied to clipboard
Agnostic Reusable Component Architecture for New Ecosystems: a modern framework for bootstrapping new applications
Arcane Framework #
Agnostic Reusable Component Architecture for New Ecosystems
The Arcane Framework is a powerful Dart package designed to provide a robust architecture for managing key application services such as logging, authentication, feature flags, theming, and more. This framework is ideal for building scalable applications that require dynamic configuration and service management.

Features #
- Service Management: Centralized access to multiple services (logging, authentication, theming, etc.).
- Feature Flags: Dynamically enable or disable features using
ArcaneFeatureFlagService. - Logging: Easily log messages with metadata, stack traces, and different
log levels via
ArcaneLogger. - Authentication: Built-in support for handling user authentication workflows.
- Dynamic Theming: Switch between light and dark themes and update theme
definitions on-the-fly with
ArcaneThemeService. - Extensible Service Definitions: Implement your own
ArcaneServiceservices and leverage the inherent powers of Arcane. - Realtime Streams: In addition to
ValueNotifiers, core services expose broadcast streams for reactive consumers.
Installation #
To use Arcane Framework in your Dart or Flutter project, follow these steps:
-
Add the dependency to your
pubspec.yaml:flutter pub add arcane_framework -
(optional) Wrap your
MaterialApporCupertinoAppwithArcaneApp.ArcaneAppwires up Arcane's built-in app-level providers for services, feature flags, environment, and theme updates:import 'package:arcane_framework/arcane_framework.dart'; void main() { runApp( ArcaneApp( builder: (context, _) => MainApp(), ), ); }
ArcaneApp.child remains available for backward compatibility, but is
deprecated in favor of ArcaneApp.builder.
ArcaneApp Builder Migration (v1.x -> v2.x) #
Arcane now prefers ArcaneApp.builder over ArcaneApp.child.
Why this is better:
- Your app root is built with Arcane providers already in scope.
- You can access Arcane-backed context values immediately at app-root build time.
- You no longer need an extra
Builderwrapper just to capture provider-aware context.
When to use each API:
| Situation | Recommended API | Why |
|---|---|---|
| New app code | ArcaneApp.builder |
Preferred, future-facing API. |
Root widget needs Arcane-aware BuildContext during construction |
ArcaneApp.builder |
Context is captured inside Arcane's provider tree. |
Existing app already uses child and you want minimal churn right now |
ArcaneApp.child (deprecated) |
Still supported for compatibility while you migrate. |
| You only need to pass through a static root widget | ArcaneApp.builder |
Keeps usage consistent with migration target and avoids later refactors. |
Migration example:
// Before (deprecated)
ArcaneApp(
child: MainApp(),
)
// After (preferred)
ArcaneApp(
builder: (context, _) => MainApp(),
)
Arcane.log Metadata Migration (v1.x -> v2.x) #
Arcane.log(...) now accepts metadata as Map<String, Object?>?.
This allows metadata values to be non-strings (for example int, bool,
lists, or nested maps).
Migration example:
// Before
Arcane.log(
"Login attempt",
metadata: {
"userId": userId.toString(),
"attempt": attempt.toString(),
"rememberMe": rememberMe.toString(),
},
);
// After
Arcane.log(
"Login attempt",
metadata: {
"userId": userId,
"attempt": attempt,
"rememberMe": rememberMe,
},
);
If your logger destination serializes metadata, ensure it can handle
Object? values (or convert values to strings at that boundary).
Usage #
The following sections provide more information about how to use the package's 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 registeredArcaneServiceinstances. Note: This widget is already part of theArcaneAppwidget, however if you are not using theArcaneAppwidget you can instead use this widget directly.Arcane.service: Typed service lookup entrypoint withofType<T>(context)andrequiredOfType<T>(context).- The
service<T>andrequiredService<T>extensions onBuildContext: nullable and non-nullable getters used to locate a givenArcaneServiceviaBuildContext. Note: For app-defined services, these lookups require anArcaneServiceProviderin the widget tree. For Arcane built-in singleton services (such asArcane.auth,Arcane.features,Arcane.theme, andArcane.environment), lookups fall back to built-ins even when no provider is available.
Defining an example ArcaneService
As noted previously, what a service is or does is not enforced by the framework. Therefore, the following example is only in service of the remainder of the documentation of the Arcane services feature.
This example service is a singleton service that stores and provides access to a
user's favorite color, leveraging a ValueNotifier to trigger rebuilds as
appropriate:
class FavoriteColorService extends ArcaneService {
FavoriteColorService();
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:
ArcaneApp(
services: [
FavoriteColorService(),
],
builder: (context, _) => 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.
// The service is not included at compile-time
ArcaneApp(
builder: (context, _) => MainApp(),
),
// Add the service at runtime
ArcaneServiceProvider.of(context).addService(FavoriteColorService());
Unregistering an already registered ArcaneService is as simple as:
ArcaneServiceProvider.of(context).removeService<FavoriteColorService>()
When both a provider-registered service and an Arcane built-in singleton match
the same requested type, provider-registered services take precedence for
context.service<T>() and context.requiredService<T>().
Locating an ArcaneService
There are numerous ways to locate a registered ArcaneService. Feel free to use
whatever method you prefer:
// If a service of the given type is not registered, `null` is returned.
final FavoriteColorService? nullableService = ArcaneService.ofType<FavoriteColorService>(context);
final FavoriteColorService? nullableViaArcane = Arcane.service.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 nonNullableViaArcane = Arcane.service.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:
// 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:
final FavoriteColorService service = ArcaneService.requiredOfType<FavoriteColorService>(context);
service.notifier.addListener(() {
final Color? color = service.myFavoriteColor;
// Do something with our value
});
We can also simply use a ValueListenableBuilder:
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:
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 ArcaneFeatureFlagService built-in
service. Feature flags are useful for enabling or disabling different parts of
your application under different circumstances. For example, you may want to
enable a new feature only once it has finished development and testing, while
still having the ability to ship the unfinished code. You could also leverage
feature flags to enable different modes within your application (e.g., "free" vs
"paid"). Furthermore, they can be used for A/B testing. The options are truly
unlimited.
To get started, create an enum to define your features:
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:
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(
ArcaneApp(
builder: (context, _) => MainApp(),
),
);
}
When you want to determine if a feature is enabled, you can use one of the helper extensions:
// 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:
// 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:
final List<Enum> enabledFeatures = Arcane.features.enabledFeatures;
enabledFeatures is a snapshot read. Reading it does not subscribe to updates
and does not trigger widget rebuilds.
It is also possible to add a listener to watch for changes in the enabled features.
Arcane.features.notifier.addListener(() {
print("Features changed: ${Arcane.features.enabledFeatures}");
});
If you prefer stream-based subscriptions, you can listen to
enabledFeaturesChanges and cancel the subscription in dispose.
late final StreamSubscription<List<Enum>> subscription;
@override
void initState() {
super.initState();
subscription = Arcane.features.enabledFeaturesChanges.listen((features) {
print("Features changed: $features");
});
}
@override
void dispose() {
subscription.cancel();
super.dispose();
}
When using ArcaneApp, you can also depend on feature flags via
context.featureFlags. This resolves to the nearest
ArcaneFeatureFlagProvider and widgets that read it rebuild automatically when
feature flags change.
class FeatureGate extends StatelessWidget {
const FeatureGate({super.key});
@override
Widget build(BuildContext context) {
final flags = context.featureFlags;
if (flags.isDisabled(Feature.awesomeFeature)) {
return const SizedBox.shrink();
}
return const Text("Awesome feature enabled");
}
}
Additional BuildContext helpers are also available:
final ArcaneFeatureFlagProvider? maybeFlags = context.maybeFeatureFlags;
if (context.isFeatureEnabled(Feature.awesomeFeature)) {
// Feature is enabled
}
if (context.isFeatureDisabled(Feature.prettyOkFeature)) {
// Feature is disabled
}
Note that it is possible to register multiple different Enum types in the
feature flag service, should one have a need to do so.
Logging #
The Arcane Framework provides a robust logging system for your application. This allows you to log messages with metadata, stack traces, and different log levels while routing a single log event to multiple destinations.
To get started, first create one or more logging interfaces by extending
LoggingInterface.
class DebugConsole extends LoggingInterface {
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
debugPrint(
"$message\n"
"$metadata\n",
);
}
}
If your destination needs setup (SDK start, permission checks, etc.), opt into
the initialization lifecycle with LoggingInitialization:
class ExternalLogger extends LoggingInterface with LoggingInitialization {
@override
Future<void> init() async {
if (initialized) return;
// Configure and start the SDK.
await super.init();
}
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
if (!initialized) return;
// Forward to the SDK.
}
}
If you want to give a destination a name and access it at runtime,
mix in LoggerName and override name:
class AnalyticsLogger extends LoggingInterface with LoggerName {
@override
String get name => 'Analytics';
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
// name is accessible here at runtime.
// Forward to analytics pipeline.
}
}
Arcane.logger.interceptors.add(
LogInterceptor((
LogEvent event,
LogInterceptorContext context,
) {
if (event.level == Level.debug) {
return null;
}
return event;
}),
matcher: (interface) => interface is AnalyticsLogger,
);
You can use name inside log() and keep destination routing
explicit in interceptors.
class AuthLogger extends LoggingInterface with LoggerName {
@override
String get name => 'auth';
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {
// Forward to auth destination.
}
}
await Arcane.logger.registerInterface(AnalyticsLogger());
await Arcane.logger.registerInterface(AuthLogger());
Next, register your logging interface with the Arcane logger service. You can
attach interceptors when registering an interface, or manage logger
interceptors via Arcane.logger.interceptors later at runtime.
final DebugConsole debugConsole = DebugConsole();
await Arcane.logger.registerInterface(
debugConsole,
interceptors: [
LogInterceptor((
LogEvent event,
LogInterceptorContext context,
) {
if (event.level == Level.debug) {
return null;
}
return event;
}),
],
);
Arcane.logger.interceptors.add(
LogInterceptor((
LogEvent event,
LogInterceptorContext context,
) {
return event.copyWith(
metadata: {
...?event.metadata,
"session": "startup",
},
);
}),
);
// Optional: initialize only interfaces that implement LoggingInitializable
// (for example, SDK-backed loggers that mix in LoggingInitialization).
await Arcane.logger.initializeInterfaces();
Global interceptors are evaluated for each registered interface, and interface
interceptors run immediately after them for that same destination. Use
Arcane.logger.interceptors.add(...) with no matcher for global behavior, or
provide a matcher for scoped behavior. Every
interceptor receives a LogInterceptorContext whose interface value is the
current destination.
If you want subtype-inclusive matching (for example, a base interface plus all
derived interfaces), use Arcane.logger.interceptors.add(...) with
an explicit matcher.
Arcane.logger.interceptors.add(
LogInterceptor((
LogEvent event,
LogInterceptorContext context,
) {
return event.copyWith(message: "[scoped] ${event.message}");
}),
matcher: (interface) => interface is AnalyticsLogger,
);
Returning null from an interceptor drops the event for the current scope.
Returning a modified LogEvent allows you to rewrite the message, metadata,
level, stack trace, or extra payload before it is logged.
Finally, add any additional persistent metadata to your log messages (optional) and log a message:
// 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",
"attempt": 1,
"retryable": true,
},
stackTrace: StackTrace.current,
);
// Optional: skip automatic module/method/file-line detection.
Arcane.log(
"Manual log routing",
module: "CustomModule",
method: "customMethod",
skipAutodetection: true,
);
You can also listen to logStream for realtime log events, and cancel and
re-register subscribers as widget lifecycles change:
late final StreamSubscription<String> logSubscription;
@override
void initState() {
super.initState();
logSubscription = Arcane.logger.logStream.listen((message) {
debugPrint("Log stream event: $message");
});
}
@override
void dispose() {
logSubscription.cancel();
super.dispose();
}
You can also add, remove, and clear interceptors after startup. Because every
interceptor receives a LogInterceptorContext, global interceptors can still
make interface-specific decisions when needed by checking context.interface.
If you prefer, you can also define your own interceptor class by implementing
LogInterceptor instead of using the callback constructor.
final LogInterceptor redactSecrets = LogInterceptor((
LogEvent event,
LogInterceptorContext context,
) {
final Object? token = event.metadata?["token"];
if (token == null) return event;
return event.copyWith(
metadata: {
...?event.metadata,
"token": "[redacted]",
},
);
});
Arcane.logger.interceptors.add(redactSecrets);
Arcane.logger.interceptors.remove(redactSecrets);
If you prefer a reusable named type, you can also implement LogInterceptor
directly:
class RedactingLogInterceptor implements LogInterceptor {
const RedactingLogInterceptor();
@override
LogEvent? call(
LogEvent event, {
required LogInterceptorContext context,
}) {
final Object? token = event.metadata?["token"];
if (token == null) return event;
return event.copyWith(
metadata: {
...?event.metadata,
"token": "[redacted]",
},
);
}
}
final LogInterceptor redactSecrets = RedactingLogInterceptor();
Arcane.logger.interceptors.add(redactSecrets);
Arcane.logger.interceptors.remove(redactSecrets);
Multiple logging interfaces and multiple interceptors can be registered
simultaneously. Interface-specific interceptors receive copied LogEvent
instances, so mutations made for one destination do not leak into another.
Important: Initialization is now optional per interface. Call
initializeInterfaces() when you have interfaces that opt into
LoggingInitializable (for example via LoggingInitialization). Simple
destinations like a debug console can skip initialization entirely.
Authentication #
The Arcane Framework provides a useful interface for performing common authentication tasks, such as registration, password resets, login, log out, and enabling a debug mode.
To get started, create an authentication interface provider and register it in the Arcane authentication module:
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({
Future<void> Function()? onLoggedOut,
}) async {
Arcane.log("Logging out");
_isSignedIn = false;
if (onLoggedOut != null) await onLoggedOut();
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(DebugAuthInterface.I);
Once your interface has been created and registered, you can use it to perform a number of common authentication tasks:
// 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();
Authentication updates can also be consumed through streams:
late final StreamSubscription<AuthenticationStatus> statusSubscription;
late final StreamSubscription<bool> signedInSubscription;
@override
void initState() {
super.initState();
statusSubscription = Arcane.auth.statusChanges.listen((status) {
debugPrint("Auth status changed: $status");
});
signedInSubscription = Arcane.auth.signedInChanges.listen((signedIn) {
debugPrint("Is signed in: $signedIn");
});
}
@override
void dispose() {
statusSubscription.cancel();
signedInSubscription.cancel();
super.dispose();
}
Application Environments #
Arcane environments are value-based and extensible. Two built-in values are
provided (Environment.normal and Environment.debug), and applications can define
their own environments (for example, staging).
const Environment staging = Environment("staging");
class EnvironmentSwitcher extends StatelessWidget {
const EnvironmentSwitcher({super.key});
@override
Widget build(BuildContext context) {
final ArcaneEnvironmentService environment = Arcane.environment;
return ElevatedButton(
onPressed: () {
environment.setEnvironment(staging);
},
child: const Text("Use staging"),
);
}
}
enableDebugMode() and disableDebugMode() are still available convenience
helpers that map to the built-in debug and normal environments.
ArcaneEnvironment and ArcaneEnvironmentProvider are still available for
backward compatibility, but are deprecated in favor of
Arcane.environment/ArcaneEnvironmentService.
Authentication status is intentionally separate from environment. Switching
environments does not change AuthenticationStatus.
Dynamic Theming #
The Arcane Framework provides a simple interface for managing themes in your application, with dynamic switching between dark and light themes based on the user's system settings, or manually switching between themes.
To get started, first register your ThemeData objects with the Arcane theme
module:
void main() {
// Set your Themes
Arcane.theme
..setDarkTheme(darkTheme)
..setLightTheme(lightTheme);
runApp(
ArcaneApp(
builder: (context, _) => MainApp(),
),
);
}
From here, you can either follow the system theme:
// ArcaneApp already enables system-follow behavior by default.
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: Arcane.theme.light,
darkTheme: Arcane.theme.dark,
themeMode: Arcane.theme.currentModeOf(context),
);
}
}
You can also manually control the theme mode:
// Manually control the theme mode
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: Arcane.theme.light,
darkTheme: Arcane.theme.dark,
themeMode: Arcane.theme.currentModeOf(context),
);
}
}
Then, you can switch modes whenever you want:
// Switch between light and dark themes
Arcane.theme.switchTheme();
// Access current theme data
final ThemeData currentTheme = Arcane.theme.currentThemeMode == 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);
// Equivalent assignment-style setter
Arcane.theme.dark = customDarkTheme;
// Set a custom light theme
Arcane.theme.setLightTheme(customLightTheme);
// Equivalent assignment-style setter
Arcane.theme.light = customLightTheme;
You can subscribe to theme streams to react to theme updates outside of widget build methods:
late final StreamSubscription<ThemeMode> modeSubscription;
late final StreamSubscription<ThemeData> themeSubscription;
@override
void initState() {
super.initState();
modeSubscription = Arcane.theme.themeModeChanges.listen((mode) {
debugPrint("Theme mode changed: $mode");
});
themeSubscription = Arcane.theme.themeDataChanges.listen((themeData) {
debugPrint("Theme data changed: ${themeData.brightness}");
});
}
@override
void dispose() {
modeSubscription.cancel();
themeSubscription.cancel();
super.dispose();
}
Contributing #
We welcome contributions to the Arcane Framework. If you’d like to contribute, please:
- Fork the repository.
- Create a new feature branch.
- Submit a pull request with a description of your changes.
For detailed information on how to contribute, please refer to CONTRIBUTING.md.