Skip to content

Building a Studio host

The Studio is storage-agnostic. To embed it in your app, you wire four small ports against your backend and mount one widget.

The contract

import 'package:pose_flow_studio/pose_flow_studio.dart';
StudioScope(
persistence: yourPersistence, // required
assetBasePath: 'pose_flow/', // optional (web only)
metadataPanelBuilder: (ctx, id) => ..., // optional
child: const PoseFlowStudioScreen(),
)

That’s the entire surface. StudioScope is an InheritedWidget; the Studio reads its props via StudioScope.of(context), StudioScope.assetBasePathOf(context), and StudioScope.metadataPanelBuilderOf(context).

Step 1. Implement the four ports

class MyStudioCatalog implements StudioCatalog {
// Load + save Movement instances.
@override Future<Movement?> load(String id) async { ... }
@override Future<void> save(Movement movement, ...) async { ... }
@override Future<List<MovementSummary>> listSummaries(...) async { ... }
@override String export(Movement movement) => ...;
}
class MyStudioAudit implements StudioAudit {
@override Future<void> log(StudioAuditEvent event) async { ... }
}
class MyStudioPhaseRepository implements StudioPhaseRepository {
@override Future<void> replaceAll(...) async { ... }
@override Future<List<PhaseDefinition>> list(...) async { ... }
@override Future<void> deleteForMovement(...) async { ... }
}
class MyStudioVersionRepository implements StudioVersionRepository {
@override Future<void> snapshot(...) async { ... }
@override Future<List<MovementSnapshot>> list(...) async { ... }
}

For backends where some ports don’t make sense (e.g. no audit log), implement as a no-op, the Studio doesn’t check return values, only that the call completes.

See persistence for the full interface contracts.

Step 2. Bundle them

StudioPersistence buildPersistence() {
return StudioPersistence(
catalog: MyStudioCatalog(),
audit: MyStudioAudit(),
phases: MyStudioPhaseRepository(),
versions: MyStudioVersionRepository(),
);
}

Step 3. Mount the Studio

import 'package:flutter/material.dart';
import 'package:pose_flow_studio/pose_flow_studio.dart';
class StudioRoute extends StatelessWidget {
const StudioRoute({super.key, this.movementId});
final String? movementId;
@override
Widget build(BuildContext context) {
return StudioScope(
persistence: buildPersistence(),
assetBasePath: 'pose_flow/',
child: PoseFlowStudioScreen(movementId: movementId),
);
}
}

movementId is optional, null opens an empty draft, a value hydrates the editor with the existing movement.

Step 4. Web assets (if shipping on web)

Copy the five pose-engine assets into your web app’s web/ directory. See web integration.

Quick-start with in-memory persistence

For development or a no-backend install:

final persistence = InMemoryStudioPersistence().bundle();
StudioScope(
persistence: persistence,
child: const PoseFlowStudioScreen(),
);

In-memory persistence ships with the Studio package, useful for demos, tests, and as a reference implementation when wiring your own.

Optional, the metadata panel hook

The Studio’s left column has a slot for a host-provided metadata panel (body parts, muscle groups, image URLs, fields specific to your host’s domain).

StudioScope(
persistence: persistence,
metadataPanelBuilder: (context, movementId) {
return YourMetadataPanel(movementId: movementId);
},
child: const PoseFlowStudioScreen(),
)

The Studio renders your widget inside a FloatingPanel above the Phases panel. When the builder is null (or returns null), the slot is hidden.

The typical pattern: your CMS owns the host-side metadata (image / video URLs, body parts, muscle groups, etc.) and surfaces it through a panel widget. The Studio doesn’t know what those fields are; it just gives the slot.

Reference patterns

Two persistence shapes most hosts pick from:

  • In-memory + a local SQL store (e.g. Drift / SQLite). No cloud, full-feature, useful for offline-friendly trainer authoring.
  • Firestore + Cloud Storage (or any cloud-backed equivalent). Multi-user, audited, the right shape when authoring happens inside a managed CMS.

Either can serve as a starting point, the StudioPersistence interface doesn’t care which.

Routing

The Studio doesn’t impose any router on you. Use go_router, the built-in Navigator, anything, push the route, mount the widget, you’re done.

For deep links into specific movements, expose PoseFlowStudioScreen(movementId: '...') from your router.

Testing

The Studio is testable in isolation with InMemoryStudioPersistence:

testWidgets('open studio with a movement loaded', (tester) async {
final persistence = InMemoryStudioPersistence();
await persistence.bundle().catalog.save(seededMovement, domain: 'fitness');
await tester.pumpWidget(MaterialApp(
home: StudioScope(
persistence: persistence.bundle(),
child: PoseFlowStudioScreen(movementId: seededMovement.id),
),
));
// ... assertions
});

The Studio’s own widget tests use this pattern.

Camera lifecycle

The Studio creates + owns its own camera + tracker. You don’t need to plumb a camera in; just mount the screen.

For native targets the platform camera plugin is auto-initialised. For web, the WASM worker initialises on first frame request.