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.
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
| Layer | Responsibility in this example |
|---|---|
| App | Selects the memory adapter, includes both feature descriptors, and chooses /bookmarks as the initial route |
| Bookmarks feature | Owns its route, form controller, and editing UI |
| Insights feature | Owns its route and summary UI |
| Plugin contract | Defines the shared observable entries and commands |
| Memory adapter | Owns 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.
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 implementationBoth 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:
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.