Skip to content

Two features. One shared capability. ​

After the first feature, build an app with two independently declared features. Bookmarks owns saving and removing entries. Insights owns a summary screen. Both consume the same BookmarksPlugin contract selected by the app.

How the pieces fit together

Run the complete example ​

Use the project created in the quick start. Its dependencies are vyuh_core and go_router. Replace lib/main.dart with the code below and run flutter run.

dart
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:vyuh_core/vyuh_core.dart' as vc;

void main() {
  vc.runApp(
    initialLocation: '/bookmarks',
    plugins: vc.PluginDescriptor(others: [MemoryBookmarksPlugin()]),
    platformWidgetBuilder: platformWidgets,
    features: () => [
      bookmarksFeature(insightsPath: '/insights'),
      insightsFeature(bookmarksPath: '/bookmarks'),
    ],
  );
}

// The app customizes the Material UI root used by these feature widgets.
final platformWidgets = vc.PlatformWidgetBuilder.system.copyWith(
  appBuilder: (_, platform) => MaterialApp.router(
    debugShowCheckedModeBanner: false,
    routerConfig: platform.router.instance,
  ),
);

// Shared contract: features depend on this, while the app selects an adapter.
abstract class BookmarksPlugin extends vc.Plugin {
  BookmarksPlugin()
    : super(name: 'example.bookmarks', title: 'Shared bookmarks');

  ValueListenable<List<String>> get entries;
  void add(String title);
  void remove(String title);
}

// This adapter retains data only for the active platform lifecycle.
final class MemoryBookmarksPlugin extends BookmarksPlugin
    with vc.InitOncePlugin {
  late ValueNotifier<List<String>> _entries;

  @override
  ValueListenable<List<String>> get entries => _entries;

  @override
  Future<void> initOnce() async {
    _entries = ValueNotifier(const []);
  }

  @override
  void add(String title) {
    final value = title.trim();
    if (value.isEmpty || _entries.value.contains(value)) return;
    _entries.value = List.unmodifiable([..._entries.value, value]);
  }

  @override
  void remove(String title) {
    _entries.value = List.unmodifiable(
      _entries.value.where((entry) => entry != title),
    );
  }

  @override
  Future<void> disposeOnce() async {
    _entries.dispose();
  }
}

vc.FeatureDescriptor bookmarksFeature({String? insightsPath}) =>
    vc.FeatureDescriptor(
      name: 'bookmarks',
      title: 'Bookmarks',
      routes: () => [
        GoRoute(
          path: '/bookmarks',
          builder: (_, _) => BookmarksScreen(insightsPath: insightsPath),
        ),
      ],
    );

vc.FeatureDescriptor insightsFeature({String? bookmarksPath}) =>
    vc.FeatureDescriptor(
      name: 'insights',
      title: 'Reading insights',
      routes: () => [
        GoRoute(
          path: '/insights',
          builder: (_, _) => InsightsScreen(bookmarksPath: bookmarksPath),
        ),
      ],
    );

class BookmarksScreen extends StatefulWidget {
  final String? insightsPath;
  const BookmarksScreen({super.key, this.insightsPath});

  @override
  State<BookmarksScreen> createState() => _BookmarksScreenState();
}

class _BookmarksScreenState extends State<BookmarksScreen> {
  final _title = TextEditingController();
  late final BookmarksPlugin _bookmarks = vc.vyuh.getPlugin<BookmarksPlugin>()!;

  @override
  void dispose() {
    _title.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text('Bookmarks'),
      actions: [
        if (widget.insightsPath != null)
          TextButton(
            onPressed: () => context.go(widget.insightsPath!),
            child: const Text('Insights'),
          ),
      ],
    ),
    body: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          TextField(
            controller: _title,
            decoration: const InputDecoration(labelText: 'Bookmark title'),
          ),
          FilledButton(
            onPressed: () {
              _bookmarks.add(_title.text);
              _title.clear();
            },
            child: const Text('Save bookmark'),
          ),
          Expanded(
            child: ValueListenableBuilder<List<String>>(
              valueListenable: _bookmarks.entries,
              builder: (_, entries, _) => entries.isEmpty
                  ? const Center(child: Text('Save your first bookmark.'))
                  : ListView(
                      children: entries
                          .map(
                            (title) => ListTile(
                              title: Text(title),
                              trailing: IconButton(
                                tooltip: 'Remove bookmark',
                                icon: const Icon(Icons.delete_outline),
                                onPressed: () => _bookmarks.remove(title),
                              ),
                            ),
                          )
                          .toList(),
                    ),
            ),
          ),
        ],
      ),
    ),
  );
}

class InsightsScreen extends StatelessWidget {
  final String? bookmarksPath;
  const InsightsScreen({super.key, this.bookmarksPath});

  @override
  Widget build(BuildContext context) {
    final bookmarks = vc.vyuh.getPlugin<BookmarksPlugin>()!;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reading insights'),
        actions: [
          if (bookmarksPath != null)
            TextButton(
              onPressed: () => context.go(bookmarksPath!),
              child: const Text('Bookmarks'),
            ),
        ],
      ),
      body: Center(
        child: ValueListenableBuilder<List<String>>(
          valueListenable: bookmarks.entries,
          builder: (_, entries, _) => Text('${entries.length} saved bookmarks'),
        ),
      ),
    );
  }
}

Save a bookmark, open Insights, and check the count. Return to Bookmarks, remove it, and check the count again. Both screens read the same plugin instance. This example stores titles in memory, deduplicates equal titles, and resets data when the platform lifecycle ends.

What each layer owns ​

LayerResponsibility in this example
AppSelects the memory adapter, includes both feature descriptors, and chooses /bookmarks as the initial route
Bookmarks featureOwns its route, form controller, and editing UI
Insights featureOwns its route and summary UI
Plugin contractDefines the shared observable entries and commands
Memory adapterOwns the list, mutation rules, initialization, and cleanup

The two features do not import each other's screen or state. They have no initialization dependency on each other: sharing a plugin is sufficient. ValueListenableBuilder observes the capability with ordinary Flutter state tools.

The app also selects a standard Flutter MaterialApp.router through PlatformWidgetBuilder.appBuilder. That root supplies the Material theme and localization context used by these Flutter widgets; the router instance remains owned by the configured navigation plugin.

Give the boundaries their own packages ​

The single file keeps the example easy to run. In a product, move the BookmarksPlugin contract to a shared capability package, each feature's descriptor and widgets to its own Flutter package, and the memory adapter to an implementation package. The app imports those public entry points.

text
apps/reader/          app composition
features/bookmarks/  bookmark routes and editing UI
features/insights/   summary route and UI
packages/bookmarks/  shared capability contract
plugins/memory/      app-selected implementation

Both features depend on the capability package. The app depends on both feature packages and the adapter. Neither feature needs a dependency on the other feature's implementation.

Reuse a feature in another app ​

A second app can include Insights with another set of features and its own BookmarksPlugin implementation:

dart
vc.runApp(
  initialLocation: '/insights',
  plugins: vc.PluginDescriptor(others: [MemoryBookmarksPlugin()]),
  platformWidgetBuilder: platformWidgets,
  features: () => [insightsFeature()],
);

The first app supplies companion destinations through insightsPath and bookmarksPath. Omitting bookmarksPath in this second app hides the shortcut to that absent feature. Insights still owns its /insights route and consumes the same capability contract.

Portable features receive app-specific destinations and configuration rather than hard-code routes owned by an optional companion. A production adapter can persist or fetch entries while preserving the contract, with loading and failure behavior added explicitly to that contract.

See Why Vyuh?, custom plugins, and the core API for the contracts used here.