Flutter SDK

One package - morphui : that adapts your Flutter app to every user. Everything is opt-in. Defaults are privacy-first. Each feature ships with a fallback so your app never crashes when something goes wrong.

v0.1.2 · stableTracks the current morphui release. Older versions: see GitHub releases.

Introduction

Why Morph#

Building an app that feels right for one user is hard. Building one that feels right for every user left-handers, dark-mode enthusiasts, commuters in a tunnel, people on 12% battery usually means shipping more code than you wanted. Morph hands you that code as a single dependency.

We focus on the boring-but-critical adaptations that move retention and conversion: dark mode, interruption recovery, grip-aware layout, fatigue detection, battery-conscious rendering. Every feature is opt-in, plan-gated, and falls back gracefully when the underlying signal isn't available.

What you get on the free tier

Free
AI-generated dark mode (Claude-verified WCAG AA), AppColors integration, basic interruption recovery, system theme detection, local zone scoring. Enough to ship a production app today.

What you unlock on Professional

Professional
Behavioral zone reordering with explicit consent, contextual recovery messages (“you were transferring 50€ to Alice”), grip detection, battery-aware UI, fatigue detection, full suggestion engine, anonymous analytics dashboard.

What Business adds on top

Business
GPS context awareness, advanced interruption-recovery snapshots (multi-step KYC chains), cognitive fatigue baseline persistence, full backend telemetry exports.

How it works#

Three layers - each one independently useful, each one able to run without the others.

1. Detect

The provider reads system signals on every frame -platformBrightness, highContrast, textScaleFactor, accelerometer (when grip is on), battery state, GPS speed (when you pipe it in). Pure local, no network. Available on the FREE tier.

2. Adapt

The detected signals turn into ThemeData variants, layout overrides, and Material widgets that swap based on real conditions. Backend Claude generates an opposite-theme palette tailored to your brand colors when needed; everything else is computed on-device.

3. Suggest

Long-term behavioral patterns, what zones the user clicks, what times they open the app, what flows they abandon , feed a suggestion engine that never mutates the UI directly. It surfaces a card, the user taps Accept, then the change applies. Reversible by design.
The SDK never requests new permissions. Location is piped in by you. Notifications stay handled by Firebase. Microphone, camera, contacts, Morph touches none of it.

5-min Quick Start#

From flutter pub add morphui to a working adaptive theme in three steps.

bash
flutter pub add morphui
lib/main.dartdart
import 'package:flutter/material.dart';
import 'package:morphui/morphui.dart';

void main() {
  runApp(
    MorphProvider(
      licenseKey: 'morph-free-demo',
      baseTheme: ThemeData.light(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final adapted = context.maybeMorph?.adaptedTheme;
    return MaterialApp(
      theme: ThemeData.light(),
      darkTheme: adapted ?? ThemeData.dark(),
      themeMode: ThemeMode.system,
      home: const HomeScreen(),
    );
  }
}

That's it. Toggle your phone's system dark mode the app follows, with a Claude-generated palette derived from your base colors. No signup required for themorph-free-demo key.

Want behavioral suggestions too?

Add features: const MorphFeatures(suggestionsEnabled: true) and place MorphSuggestionOverlay inside your MaterialApp's builder. The engine starts learning silently you'll see your first card after ~20 interactions.

Setup

Install#

pubspec.yamlyaml
dependencies:
  flutter:
    sdk: flutter
  morphui: ^0.1.2
bash
flutter pub get
Native plugin requirements sensors_plus (grip), battery_plus (battery), and package_info_plus (origin binding) ship as transitive dependencies. Run cd ios && pod install once. Android is plug-and-play.

Provider setup#

MorphProvider goes at the top of your widget tree, above MaterialApp. Every block belowlicenseKey is optional. the defaults are conservative on purpose.

dart
MorphProvider(
  licenseKey: 'morph-pro-xxxxxxxx',

  // Theme pass either a ThemeData (CAS 1) or a MorphColors palette (CAS 2),
  // or both (CAS 3). Backend Claude generates the dark variant.
  baseTheme: AppTheme.lightTheme,
  colors: const MorphColors(
    background: Colors.white,
    primary: Color(0xFF4F46E5),
    surface: Colors.white,
    text: Color(0xFF0F172A),
    error: Color(0xFFDC2626),
    success: Color(0xFF10B981),
    warning: Color(0xFFF59E0B),
  ),

  // Curated feature presets  see the "Curated presets" callout below.
  features: MorphFeatures.fintech(),

  // Anonymous analytics disabled by default; consent-gated when on.
  analytics: MorphAnalyticsConfig(
    enabled: true,
    userConsent: _userHasConsented,
    uploadInterval: const Duration(hours: 24),
  ),

  // Suggestion-engine hooks called when the user accepts a suggestion.
  onDarkModeRequested: (ctx) => themeController.setDark(),
  onResumePosition: (route, depth) => scrollController.jumpToPercent(depth),

  child: const MyApp(),
)

Curated presets

Pick one of three opinionated bundles instead of toggling each flag manually. MorphFeatures.ecommerce() ships recovery + grip; MorphFeatures.fintech() adds battery + fatigue + GPS; MorphFeatures.media() emphasises the suggestion engine and dark mode. Override individual flags as needed.

License keys#

Four tiers. Each license key resolves to a plan at boot via POST /api/flutter/license/validate. The backend response is cached locally for 24 hours so an offline user never loses access mid-session.

  • morph-free-demo - free tier, no signup. Use it in your local dev loop and CI.
  • morph-professional-xxxxxxxx - Professional tier ($29/mo). Generated at app.morphui.dev/dashboard.
  • morph-business-xxxxxxxx - Business tier ($99/mo). Same dashboard.
  • morph-enterprise-xxxxxxxx - Enterprise tier (custom). Issued by sales adds SSO, SLA, and dedicated infrastructure.

Origin binding (Professional and above)

Professional, Business, and Enterprise keys are bound to your iOS bundle id / Android package name on the dashboard. The backend rejects any call whose appId isn't in the license's allowed_packages list - leaked keys can't be reused in another app.

Theme adaptation

AI dark mode#

You don't need to ship a hand-crafted dark theme. Pass your existing light ThemeData (or a MorphColors palette) and Morph generates the dark variant: AI-derived from your brand hues, WCAG AA verified, with four surface depth levels and a coherent secondary scale.

dart
final adapted = context.maybeMorph?.adaptedTheme;

return MaterialApp(
  theme: AppTheme.lightTheme,
  darkTheme: adapted ?? AppTheme.darkTheme, // null until first generate
  themeMode: ThemeMode.system,
  home: const HomeScreen(),
);

On every platformBrightness change the provider re-derives, with a 12-second timeout and a deterministic HSL flip fallback if the backend is unreachable. There's no “dark theme not loaded” flash the previous adapted theme stays mounted until the new one lands.

Where the AI adds value

Naive HSL inversion gives you grey-on-grey buttons that fail contrast. Morph's backend reasons about hue/saturation relationships your brand red stays red, your CTAs stay legible, and surfaces gain depth instead of getting washed out. See the example side-by-side on morphui.dev/showcase.

AppColors integration#

Most teams already have a class AppColors with static Color constants. Keep them as const for theme definitions, and add a BuildContext-aware accessor for widgets that should follow dark mode.

lib/core/theme/app_colors.dartdart
import 'package:flutter/material.dart';
import 'package:morphui/morphui.dart';

class AppColors {
  // Const fallbacks used in theme definitions, BorderSide, etc.
  static const Color background = Color(0xFFF5F7FA);
  static const Color primary = Color(0xFF4F46E5);
  static const Color text = Color(0xFF0E121B);

  // Adaptive accessors read inside widgets, follow Morph's adaptation.
  static Color backgroundOf(BuildContext ctx) =>
      ctx.morphPalette?.background ?? background;

  static Color primaryOf(BuildContext ctx) =>
      ctx.morphPalette?.primary ?? primary;

  static Color textOf(BuildContext ctx) =>
      ctx.morphPalette?.text ?? text;
}
dart
// Static never adapts (use in const widgets, theme definitions)
const Scaffold(backgroundColor: AppColors.background)

// Adaptive follows Morph in dark mode
Scaffold(backgroundColor: AppColors.backgroundOf(context))

Palette stops (s25 → s950)#

Design systems usually expose 12-stop palettes (s25, s50, s100…s950). Morph mirrors the scale in dark mode - so s25 (lightest in light mode) becomes s950 (darkest), preserving the visual hierarchy your designers built.

dart
class AppColorsNeutral {
  static const Color s25 = Color(0xFFFFFFFF);
  static const Color s50 = Color(0xFFF5F7FA);
  // ... s100..s900 ...
  static const Color s950 = Color(0xFF0E121B);

  static const _stops = MorphPaletteStops(
    s25: s25, s50: s50, s100: s100, s200: s200, s300: s300, s400: s400,
    s500: s500, s600: s600, s700: s700, s800: s800, s900: s900, s950: s950,
  );

  /// Adaptive accessor - mirrored stops in dark mode.
  static MorphPaletteStops of(BuildContext ctx) => _stops.adapt(ctx);
}

// Usage:
Container(color: AppColorsNeutral.of(context).s50)
// Light mode → #F5F7FA, dark mode → #181B25 (= original s900)

Zone reordering#

Wrap each top-level section of a screen in a MorphZone and the scorer learns which sections this user actually engages with. After ~20 interactions the engine surfaces a “Move to top?” suggestion. Always reversible via an Undo snackbar.

dart
MorphReorderableColumn(
  zones: [
    MorphZone(id: 'search', priority: 1, child: SearchSection()),
    MorphZone(id: 'feed', priority: 2, child: FeedSection()),
    MorphZone(id: 'trending', priority: 3, child: TrendingSection()),
  ],
)
Stable IDs are critical never derive them from translatable strings. The scorer keys on these IDs forever (they're part of the on-device behavioral model). A renamed ID resets that zone's history.

Suggestion system#

Morph never modifies the UI without an explicit user tap. The suggestion engine analyses local behavior, surfaces a card themed to your ColorScheme, and only acts on Accept. Place the overlay inside MaterialApp.builder so it inherits your typography and theme.

dart
MaterialApp(
  builder: (context, child) => MorphSuggestionOverlay(
    child: child ?? const SizedBox.shrink(),
  ),
  home: const HomeScreen(),
)

Built-in checks (each independently gated):

  • Navigation shortcut “You always go from Home to Profile after 3pm. Add a shortcut?”
  • Zone promotion “You spend 60% of your time in the Recent section. Move it to the top?”
  • Dark mode auto “Your eyes might appreciate dark mode at 11pm.”
  • Battery saver“Your battery is at 12%. Switch to essential view?”
  • Resume recovery “You were transferring 50€ to Alice. Continue?”

Cooldown rules

A suggestion that's been refused by the user is hidden for 7 days. Dismissed (without explicit refusal) for 3 days. After 3 consecutive refusals or 1 acceptance, that suggestion ID is retired permanently for that user they've told you what they want.

Interruption Recovery

Overview#

70% of mobile users get interrupted mid-flow phone call, notification, app switch to copy an IBAN, system dialog. Most apps drop them back at step 1 of a 7-step KYC. Morph remembers where they were, asks if they want to resume, and restores the chain.

Production-ready in 0.1.2

The recovery system landed five major upgrades in 0.1.2: per-bucket pause classification, debounced write-through persistence, three recovery strategies, multi-step workflow chains, per-context TTL, and on-device acceptance learning. Every behaviour is opt-in and falls back to the legacy single-snapshot path when not configured.

Pause buckets#

A 5-second pause (notification dismissed) and a 5 minutes pause (real interruption) shouldn't produce the same UX. Morph classifies the lifecycle pause into four buckets and adapts the recovery card's tone, confidence, and presentation.

BucketDurationBehaviour
brief< 30 sNo card surfaced
quick30 s – 2 min“Pick up where you left off”, confidence 0.95
real2 min – 10 min“Continue where you left off”, confidence 0.90
abandonment> 10 min“Want to resume what you started earlier?”, confidence 0.80

Recovery strategies#

Each snapshot declares how it should be restored. A saved address can come back silently; a half-typed transfer amount must be confirmed.

RecoveryStrategy.confirm (default)

The card surfaces, the user taps Resume, then the action runs. Pick this for high-stakes flows: payments, transfers, account changes anywhere a stale value could mislead.

RecoveryStrategy.auto

Restore values directly without showing a card. Use for values that can't surprise the user: last viewed page, saved scroll position, pre-filled fields the user already entered earlier in the session.

RecoveryStrategy.silent

Capture the snapshot for analytics or recovery-of-recovery purposes only no UI surface, ever. Useful when debugging the recovery flow without disrupting users.
dart
// Stakes-sensitive - confirm before applying
context.morphSetTransferContext(
  amount: '50.00',
  recipient: 'Alice',
  step: 1,
  // strategy defaults to confirm - explicit here for readability
);

// Low-stakes - restore silently on resume
context.morphSetCheckoutContext(
  cartData: {'total': cart.total},
  scrollDepth: scrollPct,
  strategy: RecoveryStrategy.auto,
);

Multi-step workflows#

KYC has 7 steps. The user gets to step 4, the app gets backgrounded for 3 minutes, then resumed. Morph doesn't just restore step 4 it restores the whole chain so the host app can rehydrate steps 1 → 4 in one go.

dart
// On every step, declare the snapshot with workflow metadata
context.morphSetKycContext(
  step: currentStep,
  totalSteps: 7,
  savedData: {'idType': 'passport', 'fullName': name},
  workflowId: 'kyc-2026-04-26-abc123',
  workflowStep: currentStep,
);

// On resume, read the chain from the recovery engine
final recovery = context.morphRecovery; // null when feature is off
final chain = recovery?.pendingChain ?? const [];
for (final snap in chain) {
  // Rehydrate your state per step
  controller.applySnapshot(snap);
}
The chain is fetched only when the snapshot belongs to a workflow. Plain-context snapshots (cart, transfer) keep the single-row recovery path. No regression for existing flows.

Per-context TTL#

A transfer amount from 30 minutes ago is more likely to mislead than help. KYC progress should still be valid 24 hours later. Each context type has a sensible default; you can override per snapshot.

ContextDefault TTLWhy
cart24 hInventory + price changes are slow
checkout30 minCart can shift, but not session-fast
transfer / payment2 minAmounts and recipients are volatile
kyc24 hDocuments don't change on you
basic (default)1 hAnything else
dart
// Use the default - 30 min for checkout
context.morphSetCheckoutContext(cartData: {...});

// Override - high-stakes payment, 90 second window
context.morphSetTransferContext(
  amount: '5000.00',
  recipient: 'Vendor',
  ttl: const Duration(seconds: 90),
);

Local learning#

If a user has rejected the “Resume?” card four times in a row at the 5 minutes pause bucket, stop showing it to them. Morph tracks accept/reject/ignore counts per bucket, on-device, and suppresses suggestions in buckets where the rejection rate crosses 70% (after a 5-sample minimum).

dart
// Wire the outcome from your suggestion overlay
final recovery = context.morphRecovery;

await recovery?.recordOutcome(
  bucket: snapshot.metadata['pauseBucket'] == 'real'
      ? InterruptionBucket.real
      : InterruptionBucket.quick,
  outcome: userTappedResume
      ? RecoveryOutcome.accepted
      : userTappedDismiss
          ? RecoveryOutcome.rejected
          : RecoveryOutcome.ignored,
);

On-device only

Outcome counts live in BehaviorDB next to the rest of the behavioral data. They never leave the device even when anonymous analytics is on, the reporter doesn't include them. The user's rejection patterns are theirs alone.

Ergonomics

Grip detection#

~10% of your users are left-handed. Their thumb stretches across the screen to reach a bottom-right CTA. Morph reads the accelerometer's X-axis tilt, infers the dominant hand, and re-floats your primary action on the matching side. Smoothed across a 20-sample window so micro-motion doesn't flicker the layout.

dart
Scaffold(
  body: GripAdaptiveLayout(
    child: ProductDetails(),
    primaryAction: FilledButton.icon(
      onPressed: _addToCart,
      icon: const Icon(Icons.shopping_cart),
      label: const Text('Add to Cart'),
    ),
    defaultAlignment: Alignment.bottomRight,
    transitionDuration: const Duration(milliseconds: 300), // 0.1.2 - tunable
    transitionCurve: Curves.easeOutCubic,
  ),
)

Persistence (new in 0.1.2)

Professional
The detector reads the previously stored hand from BehaviorDB on start() and seeds the initial state. The UI lands on the correct alignment on the second session no flash fromunknownboth left.

Eager-lock window (new in 0.1.2)

Professional
When a live signal matches the persisted prior, the detector locks in after 5 samples instead of 10. Mid-session hand-switch still uses the full 10-sample path so accidental tilts don't flip the UI.
When grip detection isn't enabled or the device has no accelerometer (web, some emulators), GripAdaptiveLayout falls back silently to defaultAlignment. Drop in safe.

Fatigue detection#

Filling a multi-step form on mobile after 25 minutes of use? Tap accuracy drops, typing slows, the user makes more typos. Morph reads the patterns never the content and serves a simplified field set when the fatigue score crosses a threshold. Production-ready in 0.1.2 with five additions.

Continuous score 0–100

Professional
The legacy three-bucket stream (none/medium/high) is kept for backward compatibility. New code reads FatigueDetector.scoreStream for a smooth 0–100 signal that drives gradual UI adaptation FatigueAdaptiveForm's smooth mode interpolates field scale continuously instead of snapping between three steps.

Per-user baseline

Professional
A 5% miss rate is “fatigued” for a careful user with a 1% baseline, and perfectly normal for someone naturally less precise. After 3 completed sessions, Morph grades the current session against the user's own averages instead of a universal threshold.

Cold-start guard

Professional
The first 30 seconds of the day's first session are excluded from scoring. Stiff fingers in the morning aren't fatigue.

Post-resume guard

Professional
For 30 seconds after returning from background, error counters don't accumulate. A user reorienting after a notification isn't a fatigue signal.

Auto-reset

Professional
When the app stays paused for 5+ minutes, the next resume rolls the buffers the user came back fresh, the previous session's fatigue shouldn't carry over.
dart
FatigueAdaptiveForm(
  // 0.1.2 default - interpolates scale 1.0 → 1.30 continuously,
  // banner fades in past score 40, simplified set kicks in past 70.
  adaptation: FatigueAdaptation.smooth,

  normalFields: [
    AmountField(),
    RecipientField(),
    DescriptionField(),
    DateField(),
    CategoryField(),
  ],
  simplifiedFields: [
    AmountField(),
    RecipientField(),
  ],
  submitButton: TransferButton(),
)

Wire taps from your app shell so the detector can measure accuracy. Each error type carries its own weight:

dart
final fatigue = MorphInheritedWidget.maybeOf(context)?.fatigueDetector;

// Wrong-zone tap (5% weight)
fatigue?.recordTapError();

// Backspace + retype on a field (5% weight)
fatigue?.recordTypingError();

// Back-then-forward in 3 seconds (15% weight - strong signal)
fatigue?.recordNavigationError();

Context awareness

Battery-aware UI#

Low battery, the user is stressed. Don't ship them six animated chart components. Morph emits a coarse BatteryMode stream and gives you two widgets to react: a tree-swap and a theme-swap.

dart
// 1. Swap entire widget trees per mode
BatteryAwareWidget(
  normal: FullDashboard(),
  medium: SimplifiedDashboard(),
  low: EssentialDashboard(),
  critical: TextOnlyDashboard(),
)

// 2. Adapt the active ThemeData (near-black on critical → OLED savings)
MaterialApp(
  builder: (ctx, child) => BatteryAwareTheme(
    adaptiveMode: BatteryAdaptiveMode.suggestion, // 0.1.2 - wait for opt-in
    isOLED: null, // null = auto-detect; pass true/false for manual override
    child: child ?? const SizedBox.shrink(),
  ),
)

Charging-aware

Free
When the device is plugged in, Morph keeps the UI in normal mode regardless of battery level. A phone charging at 12% behaves like one at 95% no surprise downgrades while the user is at their desk.

OLED-aware

Professional
Pure-black scaffolds save power on AMOLED displays but waste the same on LCD (the backlight is on either way). DeviceCapabilities.isLikelyOLED heuristically picks per platform; pass isOLED: true to opt into pure black explicitly.

Suggestion-first mode

Professional
Default imposed mode swaps the theme as soon as the bucket flips. suggestion mode waits for the user to accept a “Battery saver mode” suggestion before applying any adaptation “respectful UX” mode for apps that want to ask first.

Charge-pattern predictor

Professional
The adapter records charge-start events and learns the user's typical charge windows (24-hour histogram, ×2 weight on the same day-of-week, 7-day recency). When the user is approaching a known charge window without being plugged in, Morph proactively shifts to medium to extend the runway by the few minutes it takes to reach the charger.

Session drain stats

Professional
BatteryAdapter.getSessionStats(lookback: …) returns aggregated drain-per-minute over a configurable window. Filters out charged sessions automatically. Use it to credibly quote “our app lasts 18% longer than yours” in marketing material.

Battery-aware UI

ChargePatternPredictor#

The predictor learns when the user typically plugs their phone in. It pre-shifts to medium mode roughly 60 minutes before a habitual charge window saving battery without surprising the user when they unplug. The user never sees anything special; their phone just lasts longer on days they forget to charge in the morning.

How it learns

  • 24-hour histogram, bucketed at 30-minute resolution.
  • ×2 weight on the matching day-of-week.
  • ±60-minute window around each detected charge-start.
  • 7-day recency bias recent habits beat historical ones.

Opt-in

dart
MorphProvider(
  features: const MorphFeatures(
    batteryAware: true,
    chargePatternPrediction: true, // off by default
  ),
  child: const MyApp(),
)

Why it's opt-in

Some apps (banking, healthcare) have strict “the UI must look identical at all times” rules. The predictor is off by default so you opt in deliberately, after deciding the slight chrome-shift is acceptable for your product.

GPS context#

Walking → bigger text. Cycling → essential only. In a vehicle → larger CTAs, max contrast. Stationary → full UI. Morph never requests location permission itself. you pipe your existing GPS stream in.

dart
// In your existing GPS subscription:
geolocator.getPositionStream().listen((pos) {
  MorphInheritedWidget.maybeOf(ctx)?.gpsAdapter
    ?.onLocationUpdate(
      speedKmh: pos.speed * 3.6,
      accuracy: pos.accuracy,
    );
});

// On any screen - text scales + contrast boosts when moving
GpsAdaptiveScaffold(body: DashboardContent())

Hysteresis on transitions

Business
Walking → cycling kicks in at 7 km/h, but cycling → walking only at 5 km/h. A user steady at 7 km/h no longer flickers the UI on every fix.

Tunnel mode (30 s grace)

Business
Updates with accuracy > 50 m hold the last good context for 30 seconds instead of dropping to unknown. Going through a tunnel or under a bridge no longer pops the UI back to a default state.

Accelerometer fusion

Business
When GPS reports stationary but the accelerometer detects sustained train-like vibration for 5+ seconds, the emitted context upgrades to vehicle. Useful in metros and trains where GPS loses lock for minutes.

Zero new permissions

Because Morph never touches the OS location APIs, your permission prompts and privacy policy stay unchanged. The dev retains full control over when and whether to ask the user for location.

Privacy & Analytics

Privacy by default#

Out of the box, nothing behavioral leaves the device. Local Hive stores tap counts, navigation sequences, scroll patterns. The scorer runs on those rows and proposes UI changes locally. Only the theme-generation endpoint is contacted and only when an adaptation is needed.

To opt in to anonymous usage analytics, pass MorphAnalyticsConfig with both enabled: true and userConsent: true. Both flags are required one without the other is a no-op.

GDPR alignment

The 30-day retention floor in BehaviorDB is a hard cap you can configure shorter, never longer. Calling BehaviorDB.clearAll() wipes every box, including the suggestion history and learned baselines. Wire it to your “Delete my data” settings screen.

Anonymous reporting#

dart
MorphProvider(
  analytics: MorphAnalyticsConfig(
    enabled: true,
    userConsent: _hasUserConsent,
    onConsentRequired: () => showConsentDialog(context),
    uploadInterval: const Duration(hours: 24),
    minInteractions: 20, // skip noisy zero-data uploads
    retentionDays: 30,    // hard-capped at 30
  ),
  ...
)

What ships (anonymized aggregates only):

  • Zone scores (0..1) per UI section
  • Confirmed navigation sequences (≥ 5 occurrences)
  • Scroll behavior summary (avg depth, dominant pattern)
  • App hash (sha256 of license + platform non-reversible)
  • Month-only timestamp (never the exact date)

What is never sent: individual clicks, exact timestamps, user identity, device fingerprint, app content, location data, transferred amounts, recipient names, KYC documents, or any other PII.

Origin binding#

Professional, Business, and Enterprise licenses are bound to a specific appId (iOS bundle id / Android package name). The SDK reads it via package_info_plus on first run and includes it in every backend payload. The backend rejects calls whose appId isn't in the license's allowed_packages list.

What this prevents

A leaked license key can't be used in another app. The attacker would need to (a) steal the key AND (b) ship under your exact bundle id which the App Store / Play Store enforce against. The combination keeps your subscription revenue attached to your app.

Plans & Pricing

Tiers comparison#

FeatureFreeProfessional $29/moBusiness $99/moEnterprise (custom)
AI dark mode
AppColors / palette stops
Basic interruption recovery
Zone reordering + suggestion engine-
Grip detection-
Battery-aware UI + predictor-
Fatigue detection + per-user baseline-
Multi-step recovery workflows-
Anonymous analytics dashboard-
GPS context (hysteresis + fusion)--
Backend telemetry exports--
SSO + dedicated infrastructure---
SLA + priority support---

Read the resolved plan from any widget:

dart
final plan = context.morphPlan;
// MorphPlan: free | professional | business | enterprise

if (context.isMorphAgency) {
  // Equivalent to plan.isBusiness  true on Business + Enterprise.
  // (The shorthand keeps its legacy name; isMorphBusiness lands in v0.4.)
  // Show the AI insights tab.
}

// Imperative gate silently no-ops if the plan is too low.
// Wire onDenied: to your own upsell UI on YOUR admin surfaces.
context.requireMorphPro(() {
  Navigator.push(context, MaterialPageRoute(
    builder: (_) => const AdvancedSettings(),
  ));
});

PlanGate widget#

Your subscription, not theirs

The SDK runs inside YOUR customers' apps. If your Morph subscription lapses or downgrades, an auto-rendered “Upgrade to Professional” card surfacing on YOUR end users' screens would be a billing leak they don't know what Morph is and can't act on the prompt. So we never auto-render Morph branding to end users. Gated features silently disappear instead. We email YOU about the renewal.

Declarative paywall wraps a feature so it only renders when the resolved plan satisfies requiredPlan. Otherwise renders fallback, or when fallback is null nothing visible (a SizedBox.shrink()).

Pattern 1 silent degrade (default, end-user safe)

The gated feature simply disappears when the plan can't satisfy it. Use this on every screen that ships to your customers' end users.
dart
PlanGate(
  requiredPlan: MorphPlan.business,
  featureName: 'Analytics Dashboard',
  child: const FullAnalyticsDashboard(),
  // No fallback → renders nothing if plan insufficient.
)

Pattern 2 : your own banner

Render your own placeholder when you want something there typically a banner pointing to YOUR pricing page or a link to contact YOUR support, not Morph's.
dart
PlanGate(
  requiredPlan: MorphPlan.business,
  featureName: 'Analytics Dashboard',
  child: const FullAnalyticsDashboard(),
  fallback: const YourOwnUpgradeBanner(),
)

Pattern 3 : Morph upsell card on YOUR admin surface

When YOU (the SDK customer) are looking at YOUR admin / dev / settings dashboard and want to see the Morph-branded prompt, opt in explicitly via MorphUpsellCard. Never do this on a screen end users see.
dart
PlanGate(
  requiredPlan: MorphPlan.business,
  featureName: 'Analytics Dashboard',
  child: const FullAnalyticsDashboard(),
  fallback: MorphUpsellCard(
    featureName: 'Analytics Dashboard',
    requiredPlan: MorphPlan.business,
    onUpgrade: () => launchUrl(Uri.parse(MorphPlan.upgradeUrl)),
  ),
)

Imperative gating: requireMorphPro / requireMorphAgency

The same safety contract applies. The imperative variants run onAllowed when the plan is sufficient and call onDenied (or no-op) otherwise. They never auto-show a dialog. Wire onDenied to YOUR own UI a snackbar, a route push, or only on YOUR admin surface an explicit showDialog(builder: (_) => MorphUpgradeDialog(...)).

What happens when YOUR plan expires

The contract is end-to-end silent both the SDK and the backend cooperate so end users see nothing:
  • SDK side - gated features turn off quietly. PlanGate renders nothing,BatteryAwareTheme stops adapting,FatigueAdaptiveForm reverts to its default fields. Dark mode keeps working (free tier).
  • Backend side runtime endpoints (/license/validate,/theme/generate,/behavior/report,/impact/auto) always return HTTP 200, even when the subscription is expired or the daily quota is exhausted. No 401s, no 429s that could surface in your end users' crash reports or analytics. The wire stays clean.
  • Anti-abuse still active origin-binding (the appId mismatch path) still returns 403. That's an active attack signal, not subscription state. Legit apps on legit devices never trigger it.
  • You receive standard renewal reminder emails from billing@morphui.dev and dashboard banners on app.morphui.dev. YOUR billing relationship stays where it belongs.

Migration

Migration from manual ThemeData#

If you currently maintain lightTheme and darkTheme by hand, here's the safe path to letting Morph generate the dark side for you. Nothing forces you to delete your existing themes. Morph slots in alongside.

Step 1 : keep your existing themes

Don't delete anything yet:

dart
class AppTheme {
  static final lightTheme = ThemeData(/* ... */);
  static final darkTheme  = ThemeData(/* ... */); // hand-tuned, keep it
}

Step 2 : wrap with MorphProvider, pass your light theme as base

dart
void main() {
  runApp(
    MorphProvider(
      licenseKey: 'morph-free-demo',
      baseTheme: AppTheme.lightTheme, // Morph reads this to generate dark
      child: const MyApp(),
    ),
  );
}

Step 3 : use the adapted theme as the primary darkTheme, fall back to your hand-crafted one

dart
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final adapted = context.maybeMorph?.adaptedTheme;

    return MaterialApp(
      theme: AppTheme.lightTheme,
      darkTheme: adapted ?? AppTheme.darkTheme, // safe fallback
      themeMode: ThemeMode.system,
      home: const HomeScreen(),
    );
  }
}

Step 4 : flip the OS to dark and verify

Switch your test device to dark mode. Compare against your old hand-crafted dark theme. If something looks off:

  • Make sure your AppColors are passed via MorphColors, not just baked into the light ThemeData.
  • Confirm your light theme is internally consistent. Morph mirrors what it sees, so an inconsistent light theme produces an inconsistent dark one.

Step 5 : optional: delete your manual darkTheme

Once you're happy with the generated output, you can drop the hand-crafted AppTheme.darkTheme. Or keep it as the ?? fallback that's belt and braces, and it costs you nothing at runtime.

Don't migrate the AppColors slots blindly

The fastest wins come from passing MorphColors(background: ..., primary: ..., surface: ...) rather than letting Morph guess from ThemeData. Slot palettes give the AI exact anchor colors to mirror see the AppColors integration section for the full mapping.

Migration

0.1.1 → 0.1.2#

0.1.2 is a non-breaking minor every existing API call keeps working with its old defaults. The new behaviours are opt-in via additional named parameters.

Package name unchanged

Already on morphui ^0.1.1? Bump to ^0.1.2 and you're done.

Migrating from morph_flutter (pre-rename)

The package was published as morph_flutter for a brief beta window. If your pubspec.yaml still references it, replace with morphui: ^0.1.2 and run a global find/replace on imports package:morph_flutter/morph_flutter.dart package:morphui/morphui.dart. API surfaces are identical otherwise.

Fatigue: startSession is now async

FatigueDetector.startSession() returns Future<void> in 0.1.2 (it preloads the baseline from disk). Existing call sites that ignore the return value still work wrap with unawaited(detector.startSession()) if your analyzer flags it.

GPS: start() now exists and should be called

The new accelerometer-fusion path in 0.1.2 needs a start() call. MorphProvider calls it for you when features.gpsContext is on. If you instantiate GpsContextAdapter manually, add adapter.start() after construction.

Recovery: declareContext gained optional params

workflowId, workflowStep, workflowTotalSteps, strategy, and ttl are all optional. Existing calls that omit them get the legacy single-snapshot behaviour with a 1-hour default TTL.

Reference

Performance impact#

Numbers from internal benchmarks on a Pixel 6 (mid-range Android) and an iPhone 12. Treat as ballpark - your app's tree-depth and route topology dominate.

MetricImpact
App size (release)+180 KB (Android arm64), +200 KB (iOS)
Cold start+50–100 ms (provider boot + Hive open)
First frame render+0 ms (theme work runs post-frame)
Memory (steady state)+2–4 MB
Battery (8h foreground)< 0.5 % impact, measured
Sensor wake-ups (grip detection)≈ 1 batched read / 5 s, debounced
License validate+150–300 ms one-shot, then cached 24h

Off when off

Each Pro+ engine (grip, battery-predictor, fatigue, GPS) is opt-in via MorphFeatures and otherwise uninitialized there is no “sleeping” cost from features you didn't enable.

Reference

BuildContext extensions#

Morph attaches four extension groups to BuildContext so descendant widgets can read state and trigger flows without dragging a controller around. Every getter is null-safe when no MorphProvider is mounted (e.g. isolation tests) the helpers fall back to sensible defaults instead of throwing.

Core state : MorphContext

dart
final state          = context.morph;            // throws if no provider
final maybeState     = context.maybeMorph;       // null-safe variant

// Theme + appearance
final theme          = context.morphTheme;
final isDark         = context.isMorphDark;
final isHighContrast = context.isHighContrast;
final settings       = context.systemSettings;
final adapted        = context.adaptedTheme;     // ThemeData? - plug into MaterialApp
final palette        = context.morphPalette;     // MorphAdaptedColors?

// Behavioural
final scaled         = context.isFontScaleApplied;
final order          = context.zoneOrder;
final db             = context.morphDB;
final reorder        = context.morphReorder;
final navObserver    = context.morphNavigatorObserver;

// Analytics config (raw)
final analyticsCfg   = context.morphAnalyticsConfig;

Recovery contexts : MorphRecoveryContext

Each setter is a no-op when MorphFeatures.interruptionRecovery is off - safe to leave in your code permanently. Only four contexts ship today (checkout, product, transfer, KYC); the rest fall through to the default scroll-position recovery.

dart
// E-commerce
context.morphSetCheckoutContext(
  cartData: { 'total': cart.total, 'items': cart.itemCount },
  scrollDepth: scrollPosition,
);

context.morphSetProductContext(
  productName: product.name,
  productId: product.id,
  scrollDepth: scrollPosition,
);

// Fintech
context.morphSetTransferContext(
  amount: '50.00',
  recipient: 'Alice',
  step: 1,
);

context.morphSetKycContext(
  step: 2,
  totalSteps: 3,
  savedData: { 'documentType': 'id_card' },
);

Privacy + storage : MorphAnalyticsContext

dart
final enabled    = context.morphAnalyticsEnabled;  // dev opted in?
final consented  = context.morphUserConsented;     // user consented?
final sizeKb     = await context.morphStorageSize; // approx KB on disk

// GDPR-style erase - drops every row across every behavioural box.
await context.clearMorphData();

Plan + gating : MorphPlanContext

dart
final plan     = context.morphPlan;          // MorphPlan enum
final features = context.morphPlanFeatures;  // capability matrix

// Boolean shortcuts (legacy names  kept for backward compat).
// isMorphPro    → true on Professional, Business, Enterprise
// isMorphAgency → true on Business, Enterprise
if (context.isMorphAgency) {
  // Show the AI insights tab
}

// Imperative gates silently no-op if the plan is too low.
context.requireMorphPro(
  () => Navigator.push(context, route),
  onDenied: () => showSnackBar('Available on Professional'),
);

context.requireMorphAgency(
  () => openAnalyticsScreen(),
  onDenied: () => showSnackBar('Available on Business'),
);

Custom theme slots : MorphThemeExtensionContext

When you attach a MorphThemeExtension to your ThemeData, descendant widgets can read your custom slots back via:

dart
final brand = context.morphExt?.brandAccent
            ?? Theme.of(context).colorScheme.primary;

What's intentionally absent

A few helpers in older blog posts never shipped - don't wait for them:

  • context.morphSetCartContext / morphSetBasicContext fold into morphSetCheckoutContext with the relevant metadata instead.
  • context.requireMorphProfessional / requireMorphBusiness the canonical method names are still requireMorphPro / requireMorphAgency (rename lands in v0.4).

Troubleshooting#

MissingPluginException on first build

You forgot cd ios && pod install after adding the package. Run it once and rebuild.

Backend unreachable in dev

The SDK ships pointing at https://api.morphui.dev. SDK maintainers iterating on the backend can override at compile time: flutter run --dart-define=CHAMELEON_API_BASE_URL=http://192.168.0.126:3001. Customers should never need this flag.

License resolves to FREE despite a Professional key

(1) Confirm the device can reach api.morphui.dev. (2) Confirm your iOS bundle id / Android package name matches the allowed_packages list on the dashboard. (3) Wait 24 h for the local cache to refresh, or call BehaviorDB.clearAll() to force a re-validate.

Suggestions never surface

Did you mount MorphSuggestionOverlay inside MaterialApp.builder? Did you accumulate at least 20 interactions (the configured floor)? Check flutter logs for 🦎 Morph suggestion check entries they say why each check passed or skipped.

Adapted theme doesn't apply

context.maybeMorph?.adaptedTheme is null until the first generate succeeds. Use it as the right-hand side of a ?? against your hand-crafted dark theme so there's no flicker. The provider re-derives on every brightness change.

Dark theme doesn't match brand

You're likely using ThemeData.dark() instead of letting Morph generate from your real palette. Pass your AppColors via MorphColors on the provider. Morph reads your exact light palette and generates the dark from it, never a generic grayscale.

Theme doesn't update when system changes

MaterialApp must live inside MorphProvider, not the other way around. If MorphProvider is nested in a route below MaterialApp, the inherited widget never sees system brightness changes propagating from the root.

Grip detection isn't firing

Three checks: (1) sensors_plus is in pubspec.yaml (we don't bundle it). (2) MorphFeatures(gripDetection: true) is set on the provider. (3) The plan permits it (Professional or above). Test on a real device emulator accelerometers are unreliable.

Analytics aren't being sent

Both flags are required enabled: true AND userConsent: true. If only one is set, Morph silently drops the upload (privacy-first default). Check context.morphAnalyticsEnabled && context.morphUserConsented from any widget to confirm.

Recovery suggestion never appears

Three checks: (1) MorphSuggestionOverlay wraps MaterialApp.builder. (2) You called a morphSet…Context() setter on the active route. (3) The pause was actually 30 s+ briefer pauses are filtered out as ambient (notifications, quick app-switch).

Still stuck?

Open an issue at github.com/polarismorph-code/morph_flutter/issues with the flutter doctor output, your provider configuration, and the relevant flutter logs excerpts. Professional, Business, and Enterprise subscribers get priority via support@morphui.dev.