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.
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
FreeWhat you unlock on Professional
ProfessionalWhat Business adds on top
BusinessHow it works#
Three layers - each one independently useful, each one able to run without the others.
1. Detect
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
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
5-min Quick Start#
From flutter pub add morphui to a working adaptive theme in three steps.
flutter pub add morphuiimport '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?
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#
dependencies:
flutter:
sdk: flutter
morphui: ^0.1.2flutter pub getsensors_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.
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
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)
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.
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
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.
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;
}// 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.
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.
MorphReorderableColumn(
zones: [
MorphZone(id: 'search', priority: 1, child: SearchSection()),
MorphZone(id: 'feed', priority: 2, child: FeedSection()),
MorphZone(id: 'trending', priority: 3, child: TrendingSection()),
],
)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.
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
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
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.
| Bucket | Duration | Behaviour |
|---|---|---|
| brief | < 30 s | No card surfaced |
| quick | 30 s – 2 min | “Pick up where you left off”, confidence 0.95 |
| real | 2 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)
RecoveryStrategy.auto
RecoveryStrategy.silent
// 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.
// 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);
}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.
| Context | Default TTL | Why |
|---|---|---|
| cart | 24 h | Inventory + price changes are slow |
| checkout | 30 min | Cart can shift, but not session-fast |
| transfer / payment | 2 min | Amounts and recipients are volatile |
| kyc | 24 h | Documents don't change on you |
| basic (default) | 1 h | Anything else |
// 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).
// 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
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.
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)
ProfessionalBehaviorDB on start() and seeds the initial state. The UI lands on the correct alignment on the second session no flash fromunknown → both → left.Eager-lock window (new in 0.1.2)
ProfessionalGripAdaptiveLayout 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
ProfessionalFatigueDetector.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
ProfessionalCold-start guard
ProfessionalPost-resume guard
ProfessionalAuto-reset
ProfessionalFatigueAdaptiveForm(
// 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:
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.
// 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
Freenormal 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
ProfessionalDeviceCapabilities.isLikelyOLED heuristically picks per platform; pass isOLED: true to opt into pure black explicitly.Suggestion-first mode
Professionalimposed 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
Professionalmedium to extend the runway by the few minutes it takes to reach the charger.Session drain stats
ProfessionalBatteryAdapter.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
MorphProvider(
features: const MorphFeatures(
batteryAware: true,
chargePatternPrediction: true, // off by default
),
child: const MyApp(),
)Why it's opt-in
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.
// 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
BusinessTunnel mode (30 s grace)
Businessaccuracy > 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
Businessstationary 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
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
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#
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
Plans & Pricing
Tiers comparison#
| Feature | Free | Professional $29/mo | Business $99/mo | Enterprise (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:
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
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)
PlanGate(
requiredPlan: MorphPlan.business,
featureName: 'Analytics Dashboard',
child: const FullAnalyticsDashboard(),
// No fallback → renders nothing if plan insufficient.
)Pattern 2 : your own banner
PlanGate(
requiredPlan: MorphPlan.business,
featureName: 'Analytics Dashboard',
child: const FullAnalyticsDashboard(),
fallback: const YourOwnUpgradeBanner(),
)Pattern 3 : Morph upsell card on YOUR admin surface
MorphUpsellCard. Never do this on a screen end users see.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
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
- SDK side - gated features turn off quietly.
PlanGaterenders nothing,BatteryAwareThemestops adapting,FatigueAdaptiveFormreverts 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
appIdmismatch 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:
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
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
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
AppColorsare passed viaMorphColors, not just baked into the lightThemeData. - 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
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
morphui ^0.1.1? Bump to ^0.1.2 and you're done.Migrating from morph_flutter (pre-rename)
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
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.
| Metric | Impact |
|---|---|
| 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
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
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.
// 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
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
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:
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/morphSetBasicContextfold intomorphSetCheckoutContextwith the relevant metadata instead.context.requireMorphProfessional/requireMorphBusinessthe canonical method names are stillrequireMorphPro/requireMorphAgency(rename lands in v0.4).
Troubleshooting#
MissingPluginException on first build
cd ios && pod install after adding the package. Run it once and rebuild.Backend unreachable in dev
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
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
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
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
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
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
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?
flutter doctor output, your provider configuration, and the relevant flutter logs excerpts. Professional, Business, and Enterprise subscribers get priority via support@morphui.dev.