How to build a Makerflow module in a separate repo and eventually integrate it into the host app. This is the playbook every module owner follows.
Your module is a self-contained Flutter project that:
- Imports
makerflow(this repo) for design tokens, widgets, and the API client - Builds its own screens, models, and Riverpod providers
- Eventually registers into the host app by editing one file
name: makerflow_inventory # replace with your module name
description: MakerFlow Inventory module
publish_to: none
version: 0.1.0
environment:
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
go_router: ^14.0.0
# Reference the core repo:
makerflow:
git:
url: https://github.com/your-org/makerflow.git
ref: main
# Or locally during development (see "Local vs git dependency" below):
# makerflow:
# path: ../makerflow
dev_dependencies:
flutter_lints: ^4.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: truelib/
├── main.dart # Standalone entry point for dev/testing
└── <your_module>/
├── models/ # Plain Dart data classes
├── providers/ # Riverpod AsyncNotifier providers
└── screens/ # ConsumerWidget screens
Use the path: form while actively developing — changes to core show up instantly:
makerflow:
path: ../makerflow # assumes both repos sit side by side on your machineSwitch back to git: before pushing or opening a PR — the path: form only works
on your machine and will break CI and everyone else's clone:
makerflow:
git:
url: https://github.com/BrandeisMakerLab/makerflow.git
ref: mainNever commit the path: version.
Every module follows the same pattern:
migration → backend handler → Dart model → Riverpod provider → Flutter screen
Add a SQL migration to server/src/db/migrations/ in the core repo.
Name it NNN_<module>.sql where NNN follows the existing sequence.
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'units',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);The migration runner applies files alphabetically on server boot. Never modify an applied migration — add a new one.
Add a handler file to server/src/handlers/<group>/.
// server/src/handlers/tracking/inventory.ts
import { db } from '../../db/connection'
import { requireAuth } from '../../middleware/auth'
import type { Handler } from '../index'
export const inventoryList: Handler = async (ctx) => {
requireAuth(ctx)
return db().prepare(
'SELECT * FROM inventory_items ORDER BY name ASC'
).all()
}
export const inventoryUpdate: Handler = async (ctx) => {
requireStaff(ctx)
const { id, quantity } = ctx.body as { id: number; quantity: number }
db().prepare(
'UPDATE inventory_items SET quantity = ? WHERE id = ?'
).run(quantity, id)
return { ok: true }
}Add to server/src/handlers/index.ts:
import { inventoryList, inventoryUpdate } from './tracking/inventory'
export const handlers = {
// ... existing entries
'tracking.inventory.list': inventoryList,
'tracking.inventory.update': inventoryUpdate,
}Command naming: <group>.<module>.<action> — all lowercase, dots, no spaces.
// lib/inventory/models/inventory_item.dart
class InventoryItem {
const InventoryItem({
required this.id,
required this.name,
required this.quantity,
required this.unit,
});
final int id;
final String name;
final int quantity;
final String unit;
factory InventoryItem.fromJson(Map<String, dynamic> j) => InventoryItem(
id: j['id'] as int,
name: j['name'] as String,
quantity: j['quantity'] as int,
unit: j['unit'] as String,
);
}// lib/inventory/providers/inventory_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:makerflow/providers/client_provider.dart';
import '../models/inventory_item.dart';
class InventoryNotifier extends AsyncNotifier<List<InventoryItem>> {
@override
Future<List<InventoryItem>> build() => _fetch();
Future<List<InventoryItem>> _fetch() async {
final r = await ref
.read(clientProvider)
.command('tracking.inventory.list', {});
if (!r.ok) throw Exception(r.error);
return (r.data as List)
.map((e) => InventoryItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
Future<void> refresh() => ref.refresh(inventoryProvider.future);
}
final inventoryProvider =
AsyncNotifierProvider<InventoryNotifier, List<InventoryItem>>(
InventoryNotifier.new,
);// lib/inventory/screens/inventory_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:makerflow/theme/tokens.dart';
import 'package:makerflow/widgets/mf_async_view.dart';
import 'package:makerflow/widgets/mf_card.dart';
import '../models/inventory_item.dart';
import '../providers/inventory_provider.dart';
class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(inventoryProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Group accent bar — use your group's color
Container(height: MFSpacing.xs, color: MFColors.teal),
Expanded(
child: MFAsyncView<List<InventoryItem>>(
value: items,
isEmpty: (l) => l.isEmpty,
emptyMessage: 'No inventory items yet.',
emptyIcon: Icons.inventory_2_outlined,
onRetry: () => ref.read(inventoryProvider.notifier).refresh(),
builder: (list) => ListView.separated(
padding: const EdgeInsets.all(MFSpacing.md),
itemCount: list.length,
separatorBuilder: (_, __) => const SizedBox(height: MFSpacing.sm),
itemBuilder: (_, i) => _InventoryTile(item: list[i]),
),
),
),
],
);
}
}
class _InventoryTile extends StatelessWidget {
const _InventoryTile({required this.item});
final InventoryItem item;
@override
Widget build(BuildContext context) {
return MFCard(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name, style: MFType.labelLarge),
Text('${item.quantity} ${item.unit}', style: MFType.bodyMedium),
],
),
);
}
}The core repo ships the shell, auth, API client, and design system — but no module screens. Your module repo has two ways to run.
Your module's lib/main.dart wraps your root screen in a minimal app. No sidebar,
no shell, no other modules. Use this for building UI fast.
// lib/main.dart in your module repo
void main() {
runApp(const ProviderScope(child: _DevApp()));
}
class _DevApp extends ConsumerWidget {
const _DevApp();
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
theme: mfTheme(),
home: const YourModuleScreen(),
);
}
}Run it pointing at the backend in the core repo:
# Terminal 1 — start the backend (from the core repo)
cd ../makerflow/server && npm run dev
# Terminal 2 — run your module
flutter run -d web-server --web-port 8081 \
--dart-define=SERVER_URL=http://localhost:3000To test your module inside the real shell with live auth and routing:
- In the core repo's
pubspec.yaml, temporarily add your module as a path dependency - Import your screen in
lib/config/modules.dartand flipenabled: true - Run the full makerflow app — your module appears in the nav
Revert both changes before opening your integration PR. The entry in modules.dart
already exists with enabled: false — you just flip it as part of the PR.
Migrations and handlers live in this core repo, not your module repo. You'll open PRs here for any new SQL migrations and handler files. Your Flutter code (models, providers, screens) lives in your module repo.
- Committing
path:dependency — always switch back togit:before pushing - Hardcoded values — every color, spacing value, and font must come from
MFColors,MFSpacing,MFType - Cross-module imports — never import from another module repo. If you need data owned by another module (e.g. contract status), call a backend command — don't reach into that module's code
- Auth redirects — don't push to a login route yourself. GoRouter in core handles unauthenticated users automatically; just read
authProviderand act on the role - Group accent bar on non-root screens — the accent bar goes on the root screen of your module only, not every screen
- Modifying applied migrations — never edit a migration file that has already been run. Add a new numbered migration instead
| Module group | Token | Hex |
|---|---|---|
| Space | MFColors.skyBlue |
#259FD5 |
| Equipment | MFColors.teal |
#0B7886 |
| Community | MFColors.crimson |
#E61A4F |
| Operations | MFColors.amber |
#FFDD00 |
Read the current user from authProvider:
import 'package:makerflow/auth/providers/auth_provider.dart';
final user = ref.watch(authProvider).valueOrNull;
if (user == null) return; // not logged in
if (user.isStaff) { /* show staff controls */ }
if (user.isAdmin) { /* show admin controls */ }Backend auth helpers (import from ../../middleware/auth):
| Helper | Requires |
|---|---|
requireAuth |
Any logged-in user |
requireStaff |
staff or admin role |
requireAdmin |
admin role only |
When your module is ready to integrate, edit one file in the core repo:
lib/config/modules.dart.
// 1. Add import at the top of modules.dart
import '../modules/inventory/screens/inventory_screen.dart';
// 2. Add entry to kModules
ModuleConfig(
id: 'inventory',
name: 'Inventory',
icon: Icons.inventory_2_outlined,
group: ModuleGroup.tracking,
route: '/tracking/inventory',
builder: _buildInventory,
),
// 3. Add builder function
Widget _buildInventory(BuildContext _) => const InventoryScreen();Nav, routing, and the shell update automatically. No other files need touching.
-
flutter analyze --no-pubpasses clean - Module screen renders: loading → data → empty → error + retry
- Touch targets are ≥ 48px (use Flutter inspector)
- Semantic labels set on all interactive elements
- Group accent bar present at the top of the root screen
- No hardcoded colors, sizes, or fonts (all from
MFColors,MFSpacing,MFType) - New backend commands documented in
contracts/envelope.md -
npx tsc --noEmitpasses for backend changes