Proposal: Soliplex Frontend Shell — Modular Architecture
Status: Implemented
Date: 2026-03-11
Branch: feat/shell-core
Context
The old soliplex_frontend is a white-label Flutter app configured via
branding, URLs, feature flags, and theme colors. Features are controlled by
flags rather than composed as independent units, and infrastructure (auth,
agent, routing) is not pluggable. This repo replaces it with a modular
architecture where features are composed by including/excluding module
functions. It serves as both a runnable app and an importable library.
Proposed Design
Module Composition
Each module is a plain Dart function that receives dependencies via
constructor injection and returns a ModuleContribution (routes,
Riverpod overrides, and an optional redirect). The compiler enforces
dependency order — you can't call a module function without providing its
deps.
class ModuleContribution {
final List<RouteBase> routes; // unmodifiable
final List<Override> overrides; // unmodifiable
final GoRouterRedirect? redirect; // optional
ModuleContribution({
List<RouteBase> routes = const [],
List<Override> overrides = const [],
this.redirect,
}) : routes = List.unmodifiable(routes),
overrides = List.unmodifiable(overrides);
}
ModuleContribution authModule({required AuthState auth}) => ModuleContribution(
overrides: [
authStateProvider.overrideWithValue(auth),
],
);
ShellConfig takes a list of modules and flattens them:
class ShellConfig {
final String appName;
final ThemeData theme;
final String initialRoute;
final List<ModuleContribution> modules; // unmodifiable
ShellConfig({
required this.appName,
required this.theme,
this.initialRoute = '/',
List<ModuleContribution> modules = const [],
}) : modules = List.unmodifiable(modules);
List<RouteBase> get routes => modules.expand((m) => m.routes).toList();
List<Override> get overrides => modules.expand((m) => m.overrides).toList();
List<GoRouterRedirect> get redirects =>
modules.map((m) => m.redirect).whereType<GoRouterRedirect>().toList();
}
Flavor functions compose modules into a ShellConfig. Adding or removing
a module is one line:
ShellConfig standard() {
final auth = Unauthenticated();
return ShellConfig(
appName: 'Soliplex',
theme: ThemeData.light(), // plain ThemeData; no custom palette wrapper yet
modules: [
authModule(auth: auth),
lobbyModule(auth: auth),
chatModule(auth: auth),
],
);
}
For structural navigation (bottom nav, drawers), a navigation module accepts
child ModuleContribution objects (not just routes) so their overrides are
preserved. Feature modules stay ignorant of the navigation shell.
runSoliplexShell(config) validates routes (see Step 2 for details), builds a
GoRouter with composed redirects (module order determines priority; first
non-null result wins), collects all overrides into a single root
ProviderScope, and renders MaterialApp.router. Syntax validation (leading
slashes, empty paths) is left to GoRouter itself.
To disable a module, remove its call. Runtime feature flags (A/B, remote config) can be added to flavor functions later.
Known limitations:
- If two modules override the same Riverpod provider, the last one wins silently. Acceptable with few overrides; fix later with explicit provider registration in the flavor function if needed.
Deliberate omissions:
- No module lifecycle hooks — Riverpod providers handle disposal;
initialization happens in flavor functions. An optional async callback
can be added to
ModuleContributionlater if needed. - No error route — GoRouter's
errorBuildercan be added toShellConfigwhen needed. - No scoped DI for logout — on logout, recreate the entire app by
re-running
runSoliplexShellwith fresh state from the flavor function. The root widget must use a unique key (e.g.UniqueKey()) on each boot so Flutter tears down the oldProviderScopeinstead of reconciling it. No nestedProviderScopes or manual reset methods needed.
State & Reactivity
soliplex_agent (signals_core) → Module functions (constructor injection) → Flutter UI (Riverpod DI + signals)
- Riverpod is DI/service locator only — no AsyncNotifier or FutureProvider chains
signalspackage bridges signal reactivity to Flutter widget rebuilds- Widgets use
ref.watchto obtain service instances, thensignal.watch(context)to observe reactive state within them. Side effects use Signaleffect()orSignalsMixin, notref.listen - All wiring is explicit at the call site; missing deps are compile errors
Network Observability
Network observability comes from soliplex_agent's HttpObserver
infrastructure, integrated via the agent module (future work). The shell
itself has no network layer.
Key Design Decisions
- Constructor injection — explicit wiring, compile-time dependency checking
- Modules are cohesive units — routes, overrides, and an optional redirect in one
ModuleContribution; no base class, no registry - Riverpod as widget-tree DI only — overrides collected into single root
ProviderScope - Signals for reactivity —
soliplex_agentusessignals_core; Flutter UI bridges viasignalspackage - Interfaces, not implementations —
AuthStateis sealed; flavors create concrete instances - Providers co-located with their type —
authStateProviderlives alongsideAuthStateininterfaces/; modules import it directly. Constructor injection at the flavor level is the single cross-module dependency channel; providers deliver injected values to widgets - Composition over configuration — modules included/excluded by presence in flavor functions
- Shell has no soliplex_agent dependency — agent integration comes via a module
- App + library in one repo — unified
ai.soliplex.clientidentifiers for seamless replacement
Package Structure
lib/
├── soliplex_frontend.dart ← public barrel export
├── main.dart ← app entry point
├── src/
│ ├── core/
│ │ ├── shell.dart ← runSoliplexShell(), SoliplexShell widget
│ │ ├── shell_config.dart ← ShellConfig, ModuleContribution
│ │ └── router.dart ← route validation, GoRouter assembly
│ │
│ ├── interfaces/
│ │ └── auth_state.dart ← AuthState sealed class + authStateProvider
│ │
│ ├── modules/
│ │ ├── auth/
│ │ │ └── auth_module.dart ← authModule() function
│ │ ├── lobby/ ← future (shown for illustration)
│ │ │ └── lobby_module.dart
│ │ └── chat/ ← future (shown for illustration)
│ │ └── chat_module.dart
│ │
│ └── flavors/
│ └── standard.dart ← standard flavor
interfaces/ holds shared sealed types and their providers that cross module
boundaries. modules/ holds feature modules. soliplex_agent types flow
through constructor injection without wrapper interfaces.
Implementation Steps
Step 1: Project Scaffold ✅
- [x]
pubspec.yamlwith dependencies - [x]
lib/soliplex_frontend.dart(empty barrel) - [x]
lib/main.dart(app entry point) - [x]
analysis_options.yaml - [x] Platform scaffold (android, ios, macos, linux, windows, web)
- [x] Unified
ai.soliplex.clientidentifiers across all platforms - [x] Local.xcconfig pattern for code signing
- [x] Developer setup documentation
Step 2: Core — ModuleContribution, ShellConfig & Route Validation (TDD)
- [x]
ModuleContributiondata class (routes,overrides,redirect) - [x]
ShellConfigimmutable data class (appName,theme,modules,initialRoute) - [x]
ShellConfig.routes/ShellConfig.overrides/ShellConfig.redirectsgetters that flatten modules - [x] Route validation pure function (recursive tree walk: no duplicate paths with parameterized segment normalization, initial route exists when routes are non-empty, no path shadowing — parameterized sibling before literal sibling is an error); returns list of error descriptions (empty = valid). Note:
RouteBaseis abstract — the walk must type-checkGoRoute(haspath),ShellRoute(no path, recurse intoroutes), andStatefulShellRoute(no path, iteratebranchesthen recurse) - [x] Tests: valid config, empty modules, duplicate paths (exact and normalized parameterized), missing initial route, nested route validation, path shadowing detection, module flattening
Step 3: Core — Shell Bootstrap (TDD)
- [x]
runSoliplexShell()andSoliplexShellwidget - [x] Validate routes (throw
ArgumentErroron failure) → build GoRouter → ProviderScope with overrides → MaterialApp.router - [x] Tests: empty config throws
ArgumentError, override composition (overrides from multiple modules compose into a single ProviderScope; each provider may only be overridden once across all modules), redirect composition (first non-null wins)
Step 4: Interfaces & Auth Module
- [x]
AuthStatesealed class +authStateProviderininterfaces/auth_state.dart - [x]
authModule()function inmodules/auth/auth_module.dart
Step 5: Barrel Export, Flavor & App Entry Point
- [x] Fill
soliplex_frontend.dartwith public exports:ShellConfig,ModuleContribution,runSoliplexShell,AuthState,Authenticated,Unauthenticated,authStateProvider,authModule. Flavors stay private (src/) - [x]
standard()flavor function usingUnauthenticated()directly - [x] Wire
main.dartto userunSoliplexShellwith standard flavor
Dependencies
dependencies:
flutter: sdk
flutter_riverpod: ^3.1.0
go_router: ^17.1.0
signals: ^6.2.0
dev_dependencies:
flutter_test: sdk
flutter_lints: ^6.0.0
soliplex_agent is NOT a dependency yet — it will be added when the real
agent module is built.
Verification
After each step:
flutter analyze— no warningsflutter test— all tests pass
After all steps:
- App boots cleanly (at least one module with routes is required)
- Can be imported as a library from a separate Flutter project
- Test suite verifies composition: multi-module merging, module removal,
route validation — using test fixtures in
test/
What Comes After (not in this plan)
- Real auth module (OIDC via flutter_appauth)
- Agent module (wraps soliplex_agent)
- Real lobby module (room list from backend)
- Real chat module (agent sessions, message streaming)
- Inspector module (network inspector UI using soliplex_agent's HttpObserver)
- Settings, Quiz modules
- Custom theme abstraction (AppColors/AppTheme — when multiple flavors need it)