Skip to content

Latest commit

 

History

History
419 lines (325 loc) · 11.6 KB

File metadata and controls

419 lines (325 loc) · 11.6 KB

Makerflow — Module Developer Guide

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.


Overview

Your module is a self-contained Flutter project that:

  1. Imports makerflow (this repo) for design tokens, widgets, and the API client
  2. Builds its own screens, models, and Riverpod providers
  3. Eventually registers into the host app by editing one file

Setting up your module repo

pubspec.yaml

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: true

File layout

lib/
├── main.dart                  # Standalone entry point for dev/testing
└── <your_module>/
    ├── models/                # Plain Dart data classes
    ├── providers/             # Riverpod AsyncNotifier providers
    └── screens/               # ConsumerWidget screens

Local vs git dependency

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 machine

Switch 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: main

Never commit the path: version.


The 5-layer stack

Every module follows the same pattern:

migration → backend handler → Dart model → Riverpod provider → Flutter screen

Step 1: Backend migration

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.

Step 2: Backend handler

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 }
}

Step 3: Register commands

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.

Step 4: Dart model

// 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,
  );
}

Step 5: Riverpod provider

// 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,
    );

Step 6: Flutter screen

// 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),
        ],
      ),
    );
  }
}

Running and testing your module

The core repo ships the shell, auth, API client, and design system — but no module screens. Your module repo has two ways to run.

Option A — Standalone (daily dev work)

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:3000

Option B — Full integration (before declaring done)

To test your module inside the real shell with live auth and routing:

  1. In the core repo's pubspec.yaml, temporarily add your module as a path dependency
  2. Import your screen in lib/config/modules.dart and flip enabled: true
  3. 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.

Where backend work lives

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.


What to avoid

  • Committing path: dependency — always switch back to git: 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 authProvider and 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

Group accent colors

Module group Token Hex
Space MFColors.skyBlue #259FD5
Equipment MFColors.teal #0B7886
Community MFColors.crimson #E61A4F
Operations MFColors.amber #FFDD00

Auth in your module

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

Registering your module into the host app

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.


PR checklist before opening a PR

  • flutter analyze --no-pub passes 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 --noEmit passes for backend changes