Skip to content

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 ModuleContribution later if needed.
  • No error route — GoRouter's errorBuilder can be added to ShellConfig when needed.
  • No scoped DI for logout — on logout, recreate the entire app by re-running runSoliplexShell with 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 old ProviderScope instead of reconciling it. No nested ProviderScopes 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
  • signals package bridges signal reactivity to Flutter widget rebuilds
  • Widgets use ref.watch to obtain service instances, then signal.watch(context) to observe reactive state within them. Side effects use Signal effect() or SignalsMixin, not ref.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

  1. Constructor injection — explicit wiring, compile-time dependency checking
  2. Modules are cohesive units — routes, overrides, and an optional redirect in one ModuleContribution; no base class, no registry
  3. Riverpod as widget-tree DI only — overrides collected into single root ProviderScope
  4. Signals for reactivitysoliplex_agent uses signals_core; Flutter UI bridges via signals package
  5. Interfaces, not implementationsAuthState is sealed; flavors create concrete instances
  6. Providers co-located with their typeauthStateProvider lives alongside AuthState in interfaces/; modules import it directly. Constructor injection at the flavor level is the single cross-module dependency channel; providers deliver injected values to widgets
  7. Composition over configuration — modules included/excluded by presence in flavor functions
  8. Shell has no soliplex_agent dependency — agent integration comes via a module
  9. App + library in one repo — unified ai.soliplex.client identifiers 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.yaml with 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.client identifiers across all platforms
  • [x] Local.xcconfig pattern for code signing
  • [x] Developer setup documentation

Step 2: Core — ModuleContribution, ShellConfig & Route Validation (TDD)

  • [x] ModuleContribution data class (routes, overrides, redirect)
  • [x] ShellConfig immutable data class (appName, theme, modules, initialRoute)
  • [x] ShellConfig.routes / ShellConfig.overrides / ShellConfig.redirects getters 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: RouteBase is abstract — the walk must type-check GoRoute (has path), ShellRoute (no path, recurse into routes), and StatefulShellRoute (no path, iterate branches then 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() and SoliplexShell widget
  • [x] Validate routes (throw ArgumentError on 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] AuthState sealed class + authStateProvider in interfaces/auth_state.dart
  • [x] authModule() function in modules/auth/auth_module.dart

Step 5: Barrel Export, Flavor & App Entry Point

  • [x] Fill soliplex_frontend.dart with public exports: ShellConfig, ModuleContribution, runSoliplexShell, AuthState, Authenticated, Unauthenticated, authStateProvider, authModule. Flavors stay private (src/)
  • [x] standard() flavor function using Unauthenticated() directly
  • [x] Wire main.dart to use runSoliplexShell with 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 warnings
  • flutter test — all tests pass

After all steps:

  1. App boots cleanly (at least one module with routes is required)
  2. Can be imported as a library from a separate Flutter project
  3. Test suite verifies composition: multi-module merging, module removal, route validation — using test fixtures in test/

What Comes After (not in this plan)

  1. Real auth module (OIDC via flutter_appauth)
  2. Agent module (wraps soliplex_agent)
  3. Real lobby module (room list from backend)
  4. Real chat module (agent sessions, message streaming)
  5. Inspector module (network inspector UI using soliplex_agent's HttpObserver)
  6. Settings, Quiz modules
  7. Custom theme abstraction (AppColors/AppTheme — when multiple flavors need it)