mini_program_ui

Pure-Dart authoring helpers for the Mp JSON mini-program engine.

This package intentionally has no Flutter, Stac, Material, Cupertino, analyzer, or build_runner dependency. Mini-program authors write Mp.* source, then mini_program_tooling runs tool/build_mp.dart and writes versioned JSON for the SDK renderer.

Supported Import

Mini-program source should use only the package barrel:

import 'package:mini_program_ui/mini_program_ui.dart';

Files below lib/src/ are implementation details. The implementation is organized into dependency-free core values, program assembly, and feature-owned node/action builders. The temporary legacy src compatibility re-exports were removed in 0.2.0; direct internal imports are unsupported.

Program Shape

import 'package:mini_program_ui/mini_program_ui.dart';

final miniProgram = MpProgram(
  screens: <String, MpScreenBuilder>{
    'coupon_home': buildCouponHome,
    'coupon_details': buildCouponDetails,
  },
);

The build script is small and deterministic:

import 'package:mini_program_ui/mini_program_ui.dart';

import '../mp/program.dart';

Future<void> main(List<String> arguments) {
  return writeMpBuildOutput(miniProgram, arguments: arguments);
}

Basic UI

MpNode buildCouponHome() {
  return Mp.page(
    child: Mp.column(
      children: <MpNode>[
        Mp.topBar(
          leading: Mp.icon('coupon', semanticLabel: 'Coupons'),
          title: Mp.heading('Coupon Center'),
          actions: <MpNode>[
            Mp.iconButton(
              'refresh',
              semanticLabel: 'Refresh coupons',
              action: Mp.backend.refresh(requestId: 'coupon-list'),
            ),
          ],
        ),
        Mp.expanded(
          child: Mp.scrollView(
            child: Mp.column(
              children: <MpNode>[
                Mp.text('Portable rewards for host apps.'),
                Mp.image(src: 'https://example.com/reward.png'),
                Mp.card(child: Mp.text('SDK-owned component styling')),
                Mp.primaryButton(
                  label: 'Open details',
                  action: Mp.navigation.openScreen('coupon_details'),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  );
}

Use Mp.page for new full-screen mini-program screens. It owns the safe area and avoids legacy outer scroll padding. Keep Mp.topBar outside the expanded scrolling body so headers do not move or compress when the keyboard opens.

Auth

Mp.authBuilder(
  loading: Mp.text('Checking session...'),
  signedOut: Mp.card(
    child: Mp.column(
      children: <MpNode>[
        Mp.text('Sign in to continue.'),
        Mp.primaryButton(
          label: 'Sign in with email',
          action: Mp.auth.showEmailAuth(),
        ),
      ],
    ),
  ),
  signedIn: Mp.card(
    child: Mp.column(
      children: <MpNode>[
        Mp.text('Signed in as {{auth.user.email}}'),
        Mp.secondaryButton(label: 'Sign out', action: Mp.auth.signOut()),
      ],
    ),
  ),
  error: Mp.text('{{auth.message}}'),
);

Auth bindings never expose idToken, refreshToken, passwords, or backend secrets.

Backend Data

Mp.backendBuilder(
  requestId: 'home',
  endpoint: 'home/bootstrap',
  loading: Mp.text('Loading...'),
  error: Mp.text('{{backend.home.message}}'),
  child: Mp.card(
    child: Mp.column(
      children: <MpNode>[
        Mp.heading('{{backend.home.data.title}}'),
        Mp.text('{{backend.home.data.message}}'),
      ],
    ),
  ),
);

Artifact-Local Data And Visualization

Large immutable lookup data can stay under the mini-program's assets/ directory. The runtime validates it, persists it only in the host-approved data cache, and keeps search indexes outside live state.

Mp.initialize(
  actions: <MpAction>[
    Mp.data.loadJsonAsset(
      id: 'locations',
      asset: 'data/locations.json',
      statusState: 'location.resource_status',
      errorState: 'location.resource_error',
    ),
  ],
  child: Mp.column(
    children: <MpNode>[
      Mp.searchField(
        stateKey: 'location.query',
        hint: 'Dhaka',
        onChanged: Mp.data.search(
          resourceId: 'locations',
          query: '{{state.location.query}}',
          fields: const <String>['name', 'district'],
          itemsPath: 'locations',
          targetState: 'location.results',
        ),
      ),
      Mp.lineChart(
        source: '{{state.forecast.hourly}}',
        valueField: 'temperature',
        labelField: 'timeLabel',
      ),
    ],
  ),
);

JSON data paths are artifact-relative and cannot be remote URLs. Use direction: 'horizontal' with an explicit height on Mp.listView or Mp.repeat for horizontal collections. Mp.refreshIndicator is supported only as a screen root.

Local Text Editing

Use a state-bound field for local drafts and editors that are not submitted as a backend form. The SDK keeps its controller synchronized with live state, and the host's live-state limits still apply.

Mp.stateTextField(
  stateKey: 'note.body',
  hint: 'Enter text...',
  maxLength: 4096,
  minLines: 8,
  maxLines: 30,
  keyboardType: 'multiline',
  textInputAction: 'newline',
);

Wrap custom visual content with Mp.tap when it needs one semantic action without adopting button presentation.

Paged Lists

Use Mp.lazy.chunk when repeated data is large, dynamic, comes from a Publisher API, and needs pagination or manual Load more:

Mp.lazy.chunk(
  id: 'rewards_chunk',
  itemsState: 'rewards.items',
  cursorState: 'rewards.next_cursor',
  hasMoreState: 'rewards.has_more',
  statusState: 'rewards.status',
  cacheKeyPrefix: 'rewards_chunk',
  placeholder: Mp.text('Loading rewards...'),
  loadingMore: Mp.text('Loading more...'),
  empty: Mp.text('No rewards yet.'),
  end: Mp.text('No more rewards.'),
  error: Mp.text('Rewards failed to load.'),
  itemTemplate: Mp.card(child: Mp.text('{{item.title}}')),
  initialActions: <MpAction>[
    Mp.backend.loadMore(
      requestId: 'rewards',
      endpoint: 'coupons/page',
      limit: 20,
    ),
  ],
  loadMoreActions: <MpAction>[
    Mp.backend.loadMore(
      requestId: 'rewards',
      endpoint: 'coupons/page',
      limit: 20,
    ),
  ],
  loadMore: Mp.secondaryButton(
    label: 'Load more',
    action: Mp.lazy.loadMore(id: 'rewards_chunk'),
  ),
);

Do not use Mp.lazy.chunk for login pages, small settings pages, static about pages, single detail pages, payment forms, fixed menus, or small local JSON lists.

Mp.pagedBackendBuilder remains available for direct backend-bound paged lists:

Mp.pagedBackendBuilder(
  requestId: 'rewards',
  endpoint: 'coupons/page',
  limit: 20,
  loading: Mp.text('Loading rewards...'),
  loadingMore: Mp.text('Loading more...'),
  empty: Mp.text('No rewards yet.'),
  end: Mp.text('No more rewards.'),
  error: Mp.text('{{backend.rewards.message}}'),
  itemTemplate: Mp.card(
    child: Mp.column(
      children: <MpNode>[
        Mp.heading('{{item.title}}'),
        Mp.text('{{item.description}}'),
      ],
    ),
  ),
  loadMore: Mp.secondaryButton(
    label: 'Load more',
    action: Mp.backend.loadMore(requestId: 'rewards'),
  ),
);

Default provider-neutral response shape:

{
  "items": [],
  "nextCursor": null,
  "hasMore": false
}
Mp.primaryButton(
  label: 'Open details',
  action: Mp.navigation.openScreen('coupon_details'),
);

Mp.secondaryButton(
  label: 'Back',
  action: Mp.navigation.popScreen(),
);

Control Flow And Timers

Conditions are strict booleans or full bindings. Countdown state contains the remaining whole seconds, rounded up.

Mp.timer.countdown(
  duration: const Duration(seconds: 10),
  running: '{{state.screen.running}}',
  restartToken: '{{state.screen.content_id}}',
  remainingState: 'screen.remaining_seconds',
  onComplete: Mp.action.ifElse(
    condition: '{{state.screen.can_advance}}',
    thenAction: Mp.state.set('screen.status', 'advanced'),
    elseAction: Mp.state.set('screen.status', 'expired'),
  ),
  child: Mp.condition(
    condition: '{{state.screen.ready}}',
    whenTrue: Mp.text('{{state.screen.remaining_seconds}} seconds'),
    whenFalse: Mp.text('Waiting'),
  ),
);

Setting running to false pauses the countdown. Changing restartToken resets it to the configured duration. Timers are cancelled when their node is disposed.

Current Location

Mini-programs can request one foreground, approximate location through a host provider. The host must separately accept the request and install the native provider.

Mp.location.getCurrent(
  accuracy: 'approximate',
  timeout: const Duration(seconds: 10),
  targetState: 'location.current',
  statusState: 'location.status',
  errorState: 'location.error',
  requestId: 'current-location',
);

This API does not support background tracking, continuous updates, or precise location.

Camera And Flashlight

Camera capture delegates to the Android system camera. Bounds are optional; when omitted, the camera's native output size is retained. The host returns an opaque media reference and metadata rather than a path, URI, or image bytes.

Mp.camera.capturePhoto(
  quality: 95,
  maxWidth: 1920,
  targetState: 'camera.photo',
  statusState: 'camera.status',
  errorState: 'camera.error',
);

Mp.camera.cancel(statusState: 'camera.status');

Mp.flashlight.toggle(
  targetState: 'flashlight.status',
  errorState: 'flashlight.error',
);

Camera and flashlight are separate host permissions. Camera does not provide a live camera feed, video recording, or arbitrary camera access. A captured photo can be rendered through the trusted host media provider without exposing its path or bytes to mini-program state:

Mp.image(
  src: '{{state.camera.photo.mediaRef}}',
  source: MpImageSource.hostMedia,
  alt: 'Captured photo',
);

QR Codes

QR generation is a cross-platform renderer node and requires no host permission. Scanning requires a trusted host provider, accepted permissions.qrScanner policy, and an explicit user gesture.

Mp.qr.generate(
  value: '{{state.share.url}}',
  size: 240,
  errorCorrection: 'medium',
  semanticLabel: 'Share link QR code',
);

Mp.button(
  text: 'Scan QR code',
  action: Mp.qr.scan(
    allowTorch: true,
    timeout: const Duration(seconds: 60),
    targetState: 'qr.result',
    statusState: 'qr.status',
    errorState: 'qr.error',
  ),
);

The scan target contains rawValue, format, valueType, and scannedAtUtc. Treat rawValue as untrusted text. The runtime never opens a scanned URL or performs another action automatically.

System Sharing

System sharing must run from an explicit button or other user gesture. A host must install a platform provider and accept permissions.share for the app.

Mp.button(
  text: 'Share note',
  action: Mp.share.open(
    title: 'Share note',
    text: '{{state.note.text}}',
    url: 'https://example.com/notes/42',
    mediaRefs: '{{state.note.mediaRefs}}',
    targetState: 'share.result',
    statusState: 'share.status',
    errorState: 'share.error',
  ),
);

mediaRefs are opaque temporary resources already owned by the mini-program, such as delegated camera results. The result is {"chooserOpened": true}; it does not indicate whether the user selected a target or completed sharing. Native paths, content URIs, and raw bytes are never accepted.

Temporary Display Brightness

Use a lifecycle scope around content such as an on-screen QR code. The host may deny or clamp the request and always owns restoration.

Mp.display.brightnessScope(
  level: 1,
  statusState: 'invite.brightness_status',
  errorState: 'invite.brightness_error',
  child: Mp.qr.generate(
    value: '{{state.invite.url}}',
    size: 240,
    semanticLabel: 'Friend invitation QR code',
  ),
);

Do not pair this node with a manual brightness-off action. The SDK releases the override when the child unmounts or the host leaves the foreground.

Streamed Audio And Inline Video

Media sources are either immutable artifact assets or relative Publisher API routes. Arbitrary URLs and device files are rejected. Audio is headless; video is rendered inline in the authored layout.

Mp.audio.play(
  audioId: 'answer-sound',
  source: MpAudioSource.publisher(endpoint: 'media/answer-sound'),
  cacheMode: 'temporary',
  statusState: 'audio.status',
  errorState: 'audio.error',
);

Mp.videoView(
  playerId: 'product-demo',
  source: MpVideoSource.asset('video/product-demo.mp4'),
  poster: 'images/product-poster.webp',
  controls: true,
  onReady: Mp.state.set('video.ready', true),
  onEnded: Mp.state.increment('video.completedCount'),
  onError: Mp.state.set('video.failed', true),
  semanticLabel: 'Product demonstration',
);

Use Mp.audio.pause/seek/stop/setVolume/setSpeed/getStatus/release and Mp.video.play/pause/seek/stop/setMuted/setVolume/setSpeed/enterFullscreen/ exitFullscreen/getStatus/release for explicit controls. Temporary cache is only a request; host-accepted media and cache policy remains authoritative.

Publisher File Transfers

File actions use relative routes on the artifact-declared Publisher API. The host must accept file policy and install a platform transfer provider. Files are streamed by the host; mini-program state receives only progress and sanitized result metadata.

Mp.file.upload(
  endpoint: 'files/upload',
  mimeTypes: const <String>['image/*', 'application/pdf'],
  mediaRefs: const <String>['{{state.camera.photo.mediaRef}}'],
  multiple: true,
  metadata: const <String, Object?>{'folderId': 'inbox'},
  progressState: 'files.progress',
  targetState: 'files.uploadResult',
  statusState: 'files.status',
  errorState: 'files.error',
);

Mp.file.download(
  endpoint: 'files/download',
  request: const <String, Object?>{'fileId': 'file-1'},
  destination: 'downloads',
  suggestedName: 'report.pdf',
  expectedMimeType: 'application/pdf',
  progressState: 'files.progress',
  targetState: 'files.downloadResult',
);

Mp.file.cancel(
  transferId: '{{state.files.progress.transferId}}',
  statusState: 'files.status',
);

Mp.media.release(
  mediaRef: '{{state.camera.photo.mediaRef}}',
  statusState: 'camera.status',
);

Upload and download are network operations, not aliases for local pick/save. The publisher server owns file IDs, folders, ACLs, and business metadata. When mediaRefs is empty, upload opens the platform document picker. When it is present, the host streams already-owned temporary media. Release media only after upload succeeds or when the user discards it; host lifecycle cleanup is the final fallback.

Security Model

mini_program_ui only serializes declarative JSON. It does not execute host code and it does not contain renderer logic. Runtime validation, auth sessions, runtime API headers, and bridge dispatch are owned by mini_program_sdk.

Libraries

mini_program_ui
Pure-Dart authoring helpers for versioned Mp JSON mini-program screens.