Skip to content

AI Implementation Guide - Flutter

How to use this page: Copy everything below the horizontal line and paste it into your AI coding assistant (Claude, ChatGPT, GitHub Copilot, Cursor, etc.) to get accurate MovableInk SDK implementation help.

Sample Prompts

After pasting the context below into your AI assistant, try these prompts:

  • "Implement MovableInk deeplinking in my Flutter app using the context I provided"
  • "Help me configure Universal Links and App Links for MovableInk in Flutter"
  • "Implement behavior events to capture when users view products"
  • "Add orderCompleted event tracking to my checkout flow"
  • "Help me test my MovableInk integration"
  • "Debug why my MovableInk deeplinks aren't working in Flutter"
  • "Set up both iOS and Android deeplinking for Flutter"

Context for AI Assistant

Copy everything inside the code block below and paste into your AI assistant:

# MovableInk Flutter SDK Implementation Reference

## Overview

### What is MovableInk?
MovableInk is a marketing personalization platform that helps brands create dynamic, personalized content for email, mobile, and web. The Flutter SDK enables two key capabilities:

1. **Deeplinking**: Users tap links in marketing emails → your app opens to specific content
2. **Behavior Events**: Capture user interactions (product views, purchases) to power personalization

### SDK Information
- **Current Version**: 2.3.0
- **Minimum Flutter**: 2.5.0
- **Minimum iOS**: 13.0
- **Minimum Android**: Android 7.0 (API 24)
- **Language**: Dart
- **Package**: `movable_ink_plugin`

## Key Terminology

- **MIU (MovableInk User ID)**: Unique, non-PII identifier linking app users to marketing profiles (typically a UUID, NOT an email)
- **MovableInk Link**: URL format `https://mi.yourcompany.com/p/...` in marketing emails
- **Universal Links** (iOS): Deep linking using https URLs (requires Associated Domains capability)
- **App Links** (Android): Deep linking using https URLs (requires Digital Asset Links file)
- **AASA**: Apple App Site Association file at `https://mi.yourcompany.com/.well-known/apple-app-site-association`
- **assetlinks.json**: Digital Asset Links file at `https://mi.yourcompany.com/.well-known/assetlinks.json`

## Installation

Add the MovableInk plugin to your `pubspec.yaml`:

```yaml
dependencies:
  movable_ink_plugin: ^2.3.0
```

Then run:

```bash
flutter pub get
```

Import in your Dart code:

```dart
import 'package:movable_ink_plugin/movable_ink_plugin.dart';
```

## Configuration for Deeplinking

### iOS Configuration

#### Step 1: Add Associated Domains in Xcode

1. Open your Flutter project in Xcode: `File > Open > ios/Runner.xcworkspace`
2. Select your app target in Xcode
3. Go to **Signing & Capabilities** tab
4. Click **+ Capability** → Add **Associated Domains**
5. Add entry: `applinks:mi.yourcompany.com`

**Important**: Replace `mi.yourcompany.com` with actual MovableInk subdomain (no `https://`)

#### Step 2: Update Info.plist

Add to `ios/Runner/Info.plist`:

```xml
<key>movable_ink_universal_link_domains</key>
<array>
    <string>mi.yourcompany.com</string>
</array>
```

**For Flutter 3.27.0+**: Also disable Flutter's built-in deep linking:

```xml
<key>FlutterDeepLinkingEnabled</key>
<false/>
```

**Important**: Replace `mi.yourcompany.com` with actual MovableInk subdomain

### Android Configuration

#### Step 1: Add Intent Filters to AndroidManifest.xml

Add these intent filters to your main activity in `android/app/src/main/AndroidManifest.xml`:

```xml
<activity
    android:name=".MainActivity"
    android:exported="true">

    <!-- Existing intent filters -->
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <!-- Add these intent filters for MovableInk App Links -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="mi.yourcompany.com"
            android:pathPrefix="/p/cpm" />
    </intent-filter>

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="mi.yourcompany.com"
            android:pathPrefix="/p/rpm" />
    </intent-filter>

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="mi.yourcompany.com"
            android:pathPrefix="/p/gom" />
    </intent-filter>
</activity>
```

**For Flutter 3.27.0+**: Also disable Flutter's built-in deep linking:

```xml
<activity android:name=".MainActivity">
    <meta-data 
        android:name="flutter_deeplinking_enabled" 
        android:value="false" />
</activity>
```

**Important**: 
- Replace `mi.yourcompany.com` with actual MovableInk subdomain
- `android:autoVerify="true"` is required for App Links verification
- `android:exported="true"` is required for Android 12+

## Deeplinking Implementation

### Basic Implementation with Stream Subscription

```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:movable_ink_plugin/movable_ink_plugin.dart';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final MovableInkPlugin _movableInkPlugin = MovableInkPlugin();
  StreamSubscription<String>? _linkSubscription;

  @override
  void initState() {
    super.initState();
    _initDeepLinking();
  }

  void _initDeepLinking() {
    // Subscribe to resolved URLs from MovableInk
    _linkSubscription = _movableInkPlugin.start().listen(
      (String resolvedUrl) {
        print('Deeplink resolved to: $resolvedUrl');
        _handleDeeplink(resolvedUrl);
      },
      onError: (error) {
        print('Failed to resolve link: $error');
      },
    );
  }

  void _handleDeeplink(String url) {
    // Parse URL and navigate to appropriate screen
    final uri = Uri.parse(url);

    if (uri.path.contains('/products/')) {
      final productId = uri.pathSegments.last;
      // Navigate to product detail
      // Navigator.pushNamed(context, '/product', arguments: productId);
    } else if (uri.path.contains('/categories/')) {
      // Navigate to category screen
    }
  }

  @override
  void dispose() {
    _linkSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Your App',
      home: HomePage(),
    );
  }
}
```

### Alternative: Using subscribeToResolvedURL

```dart
void _initDeepLinking() {
  _linkSubscription = _movableInkPlugin.subscribeToResolvedURL((String? url) {
    if (url != null) {
      print('Deeplink resolved to: $url');
      _handleDeeplink(url);
    }
  });
}
```

### Manual URL Resolution

You can also manually resolve URLs:

```dart
Future<void> resolveUrl(String url) async {
  final resolvedUrl = await _movableInkPlugin.resolveUrl(url);
  if (resolvedUrl != null) {
    print('Resolved to: $resolvedUrl');
    _handleDeeplink(resolvedUrl);
  }
}
```

### Get Last Resolved URL

If you need to retrieve the last resolved URL (e.g., after login):

```dart
Future<void> getLastResolvedUrl() async {
  final lastUrl = await _movableInkPlugin.lastResolvedURL();
  if (lastUrl != null) {
    print('Last resolved URL: $lastUrl');
    _handleDeeplink(lastUrl);
  }
}
```

**Note**: It's safer to use the stream subscription instead of polling for the last URL.

## Behavior Events Implementation (Optional)

### iOS Setup

#### Step 1: Add API Key to Info.plist

Add to `ios/Runner/Info.plist`:

```xml
<key>movable_ink_api_key</key>
<string>YOUR_API_KEY</string>
<key>movable_ink_region</key>
<string>us</string>
```

**Regions**: `us` or `eu` (default is `us` if not specified)

### Android Setup

#### Step 1: Add API Key to local.properties

In `android/local.properties`:

```properties
MOVABLE_INK_SDK_API_KEY=YOUR_API_KEY
```

#### Step 2: Update AndroidManifest.xml

Add to `android/app/src/main/AndroidManifest.xml` within `<application>`:

```xml
<application>
    <meta-data
        android:name="com.movableink.inked.API_KEY"
        android:value="${MOVABLE_INK_SDK_API_KEY}" />
</application>
```

#### Step 3: Update build.gradle

In `android/app/build.gradle`:

```groovy
android {
    defaultConfig {
        // Load API key from local.properties
        manifestPlaceholders["MOVABLE_INK_SDK_API_KEY"] = 
            project.properties['MOVABLE_INK_SDK_API_KEY'] ?: ""
    }
}
```

#### Step 4: Configure Region (Optional)

For EU compliance:

```groovy
android {
    defaultConfig {
        buildConfigField("String", "MOVABLE_INK_SDK_REGION", "\"EU\"")
    }
}
```

### Set User ID (MIU)

Set the MIU as soon as user is identified (typically after login):

```dart
_movableInkPlugin.setMIU("USER_UNIQUE_ID");
```

**Important**: 
- MIU must be non-PII (use UUID, NOT email address)
- Must match what marketing team uses in email campaigns
- Should be URL-friendly string

### Identify User (Guest to Logged In)

If user makes actions as guest then logs in:

```dart
_movableInkPlugin.setMIU("USER_UNIQUE_ID");
_movableInkPlugin.identifyUser();
```

### Product Searched

```dart
final Map<String, dynamic> properties = {
  "query": "running shoes",
  "url": "https://yourapp.com/search?q=running+shoes",
  "meta": {
    "resultsCount": 42
  }
};

_movableInkPlugin.productSearched(properties);
```

### Product Viewed

```dart
final Map<String, dynamic> properties = {
  "id": "PROD-12345",
  "title": "Running Shoes",
  "price": "89.99",
  "currency": "USD",
  "url": "https://yourapp.com/products/12345",
  "categories": [
    {
      "id": "shoes",
      "url": "https://yourapp.com/categories/shoes"
    },
    {
      "id": "running",
      "url": "https://yourapp.com/categories/running"
    }
  ],
  "meta": {
    "inStock": true,
    "rating": 4.5
  }
};

_movableInkPlugin.productViewed(properties);
```

**Important**: Product ID must not be empty string

### Using Currency Enum

```dart
import 'package:movable_ink_plugin/currency.dart';

final Map<String, dynamic> properties = {
  "id": "PROD-12345",
  "title": "Running Shoes",
  "price": "89.99",
  "currency": Currency.USD.value,
  "url": "https://yourapp.com/products/12345"
};

_movableInkPlugin.productViewed(properties);
```

### Product Added (to Cart)

```dart
final Map<String, dynamic> properties = {
  "id": "PROD-12345",
  "title": "Running Shoes",
  "price": "89.99",
  "currency": "USD",
  "url": "https://yourapp.com/products/12345",
  "categories": [
    {"id": "shoes"},
    {"id": "running"}
  ],
  "meta": {
    "size": "10",
    "color": "blue"
  }
};

_movableInkPlugin.productAdded(properties);
```

### Product Removed (from Cart)

```dart
final Map<String, dynamic> properties = {
  "id": "PROD-12345",
  "title": "Running Shoes",
  "price": "89.99",
  "currency": "USD",
  "url": "https://yourapp.com/products/12345"
};

_movableInkPlugin.productRemoved(properties);
```

### Category Viewed

```dart
final Map<String, dynamic> properties = {
  "id": "shoes",
  "title": "Shoes",
  "url": "https://yourapp.com/categories/shoes",
  "meta": {
    "itemCount": 156
  }
};

_movableInkPlugin.categoryViewed(properties);
```

### Order Completed

```dart
final Map<String, dynamic> properties = {
  "id": "ORDER-789",
  "revenue": "179.98",
  "currency": "USD",
  "products": [
    {
      "id": "PROD-12345",
      "title": "Running Shoes",
      "price": "89.99",
      "url": "https://yourapp.com/products/12345",
      "quantity": 2,
      "meta": {
        "size": "10",
        "color": "blue"
      }
    }
  ]
};

_movableInkPlugin.orderCompleted(properties);
```

**Important**: 
- Order ID and Product IDs must not be empty strings
- Revenue and price should be in dollars and cents (e.g., "15.99")
- Can include up to 100 products per order

### Custom Events

```dart
final Map<String, dynamic> properties = {
  "title": "Cat Bloopers 1000",
  "tags": ["comedy", "cats"],
  "total": 14
};

_movableInkPlugin.logEvent("video_started", properties);
```

**Note**: Custom events must be configured with MovableInk team first (max 10 custom events).

### Currency Support

Available via `Currency` enum:
- `Currency.USD`, `Currency.EUR`, `Currency.GBP`, `Currency.CAD`, `Currency.AUD`, `Currency.JPY`, etc.
- Default is `USD` if not specified
- Introduced in SDK v2

## Testing

### Test Deeplinking

#### iOS Testing

1. Build and run app on iOS device or simulator (iOS 13+)
2. Put app in background or close it
3. Open a MovableInk test link in Safari: `https://mi.yourcompany.com/p/rp/test`
4. App should open automatically
5. Check Xcode console for: `Deeplink resolved to: <final URL>`

#### Android Testing

1. Build and run app on Android device or emulator (API 24+)
2. Put app in background or close it
3. Open a MovableInk test link in Chrome: `https://mi.yourcompany.com/p/rp/test`
4. App should open automatically
5. Check Logcat for: `Deeplink resolved to: <final URL>`

You can also test Android with ADB:

```bash
adb shell am start -a android.intent.action.VIEW \
  -d "https://mi.yourcompany.com/p/rp/test" \
  com.yourcompany.yourapp
```

### Test Behavior Events

#### iOS Testing

1. Set MIU to test value: `_movableInkPlugin.setMIU("00000000-0000-0000-0000-000000000000")`
2. Trigger an event (e.g., view a product)
3. Open Console app on Mac, select device, search for "MI SDK"
4. You should see success message with event name
5. Coordinate with MovableInk team to verify events are received

#### Android Testing

1. Set MIU to test value: `_movableInkPlugin.setMIU("00000000-0000-0000-0000-000000000000")`
2. Trigger an event (e.g., view a product)
3. Check Logcat for event logs (filter by "MovableInk" or "MI SDK")
4. You should see success message with event name
5. Coordinate with MovableInk team to verify events are received

## Common Issues and Solutions

### Deeplinks Not Opening App (iOS)

**Issue**: Clicking MovableInk links doesn't open the iOS app

**Solutions**:
- Verify AASA file is accessible: `https://mi.yourcompany.com/.well-known/apple-app-site-association`
- Check Associated Domains match exactly (no wildcards, no `https://`)
- Ensure testing on iOS 13+
- Try uninstalling and reinstalling app (clears AASA cache)
- Verify Bundle ID matches what's in AASA file
- Wait 24-48 hours after AASA changes for Apple CDN to update
- Check `movable_ink_universal_link_domains` in Info.plist matches Associated Domains

### Deeplinks Not Opening App (Android)

**Issue**: Clicking MovableInk links doesn't open the Android app

**Solutions**:
- Verify assetlinks.json is accessible: `https://mi.yourcompany.com/.well-known/assetlinks.json`
- Check package name and SHA-256 fingerprint match what's in assetlinks.json
- Ensure `android:autoVerify="true"` is set on at least one intent filter
- Verify testing on Android 7.0 (API 24) or later
- Check intent filters match the URL pattern exactly (scheme, host, pathPrefix)
- Try clearing app data and reinstalling
- Wait 24-48 hours after assetlinks.json changes for Google to re-verify

### Flutter 3.27.0+ Deep Linking Conflict

**Issue**: Deep links not working after upgrading to Flutter 3.27.0+

**Solution**: Disable Flutter's built-in deep linking:

iOS (`Info.plist`):
```xml
<key>FlutterDeepLinkingEnabled</key>
<false/>
```

Android (`AndroidManifest.xml`):
```xml
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
```

### Events Not Sending

**Issue**: Behavior events not appearing in MovableInk platform

**Solutions**:
- **iOS**: Verify API key is set in Info.plist (`movable_ink_api_key`)
- **Android**: Verify API key is set in local.properties and AndroidManifest.xml
- Ensure MIU is set before sending events
- Check console/Logcat for SDK logs showing success/failure
- Verify region is correct (us or eu)
- Confirm API key is for correct environment (staging/production)

### Stream Not Receiving Links

**Issue**: Stream subscription not being called when deeplink is tapped

**Solutions**:
- Ensure `_movableInkPlugin.start()` is called in `initState()`
- Verify stream subscription is active (not disposed)
- Check that native configuration (iOS/Android) is correct
- Try both `start()` and `subscribeToResolvedURL()` methods

### MIU Mismatch

**Issue**: Events not associating with correct users in campaigns

**Solutions**:
- Verify MIU matches exactly what marketing team uses in ESP
- Ensure MIU is set after user logs in
- MIU should be UUID format, not email address
- Call `identifyUser()` after setting MIU for guest users who log in

## Important Notes

- **Replace placeholders**: Always replace `mi.yourcompany.com` with actual MovableInk subdomain
- **MIU requirements**: Must be non-PII, URL-friendly, match marketing team's identifier
- **API Key**: Required only for behavior events, NOT for deeplinking
- **Deeplinking works without API key**: Basic deeplinking functionality requires only Universal Links/App Links configuration
- **Region compliance**: Use `eu` region for European users if required for data compliance
- **Price format**: Always use string format for prices ("15.99", not 15.99)
- **Product IDs**: Must not be empty strings - will cause event to fail
- **Testing**: Use UUID `00000000-0000-0000-0000-000000000000` for testing with MovableInk team
- **Guest users**: Call `identifyUser()` after setting MIU for users who made actions while logged out
- **Platform-specific config**: Must configure both iOS and Android native projects separately
- **Flutter 3.27.0+**: Must disable Flutter's built-in deep linking on both platforms
- **Stream management**: Always cancel stream subscriptions in `dispose()`

## Full Example App

Minimal working example:

```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:movable_ink_plugin/movable_ink_plugin.dart';
import 'package:movable_ink_plugin/currency.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final MovableInkPlugin _movableInkPlugin = MovableInkPlugin();
  StreamSubscription<String>? _linkSubscription;

  @override
  void initState() {
    super.initState();
    _initDeepLinking();
    _setUserIfLoggedIn();
  }

  void _initDeepLinking() {
    _linkSubscription = _movableInkPlugin.start().listen(
      (String resolvedUrl) {
        print('Deeplink resolved to: $resolvedUrl');
        _handleDeeplink(resolvedUrl);
      },
      onError: (error) {
        print('Failed to resolve link: $error');
      },
    );
  }

  void _setUserIfLoggedIn() {
    // Set MIU when user is identified
    final userId = getUserId(); // Your method to get user ID
    if (userId != null) {
      _movableInkPlugin.setMIU(userId);
    }
  }

  void _handleDeeplink(String url) {
    final uri = Uri.parse(url);

    if (uri.path.contains('/products/')) {
      final productId = uri.pathSegments.last;
      Navigator.pushNamed(context, '/product', arguments: productId);
    } else if (uri.path.contains('/categories/')) {
      final categoryId = uri.pathSegments.last;
      Navigator.pushNamed(context, '/category', arguments: categoryId);
    }
  }

  @override
  void dispose() {
    _linkSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shopping App',
      home: HomePage(),
      routes: {
        '/product': (context) => ProductDetailPage(),
        '/category': (context) => CategoryPage(),
      },
    );
  }

  String? getUserId() {
    // Your logic to get user ID
    return null;
  }
}

class ProductDetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final productId = ModalRoute.of(context)!.settings.arguments as String;

    // Capture product view
    final MovableInkPlugin _movableInkPlugin = MovableInkPlugin();
    _movableInkPlugin.productViewed({
      "id": productId,
      "title": "Product Name",
      "price": "89.99",
      "currency": Currency.USD.value,
      "url": "https://yourapp.com/products/$productId"
    });

    return Scaffold(
      appBar: AppBar(title: Text('Product Detail')),
      body: Center(child: Text('Product: $productId')),
    );
  }
}

class CategoryPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final categoryId = ModalRoute.of(context)!.settings.arguments as String;

    return Scaffold(
      appBar: AppBar(title: Text('Category')),
      body: Center(child: Text('Category: $categoryId')),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(child: Text('Welcome')),
    );
  }
}
```

## Push Notification Analytics (Optional)

### Overview

Track when users open MovableInk push notifications for campaign attribution and conversion measurement. This feature is particularly useful for DaVinci customers.

### Prerequisites

This assumes you have already set up push notifications in your Flutter app using a package like `push` or `firebase_messaging`.

### Implementation with Push Package

Using the `push` package to handle notifications:

```dart
import 'package:movable_ink_plugin/movable_ink_plugin.dart';
import 'package:push/push.dart';

class _MyAppState extends State<MyApp> {
  final MovableInkPlugin _movableInkPlugin = MovableInkPlugin();
  VoidCallback? unsubscribeOnNotificationTap;

  @override
  void initState() {
    super.initState();
    initPush();
  }

  @override
  void dispose() {
    unsubscribeOnNotificationTap?.call();
    super.dispose();
  }

  void initPush() async {
    await Push.instance.requestPermission();
    Push.instance.registerForRemoteNotifications();

    // Handle notification tap
    unsubscribeOnNotificationTap = Push.instance.addOnNotificationTap((data) {
      _movableInkPlugin.handlePushNotificationOpenedWithContent(data);
    });

    // Handle notification that launched app from terminated state
    Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) {
      if (data != null) {
        _movableInkPlugin.handlePushNotificationOpenedWithContent(data);
      }
    });
  }
}
```

### Manual Tracking Without SDK

Extract the tracking URL from the notification payload and make a request:

```dart
import 'package:http/http.dart' as http;

void handleMIPush(Map<String?, Object?> data) async {
  String? url;

  // Check for nested mi object
  if (data['mi'] is Map) {
    final mi = data['mi'] as Map;

    if (mi['url'] is String) {
      url = mi['url'] as String;
    }
  }
  // Check for flattened mi_url key
  else if (data['mi_url'] is String) {
    url = data['mi_url'] as String;
  }

  if (url != null && url.isNotEmpty) {
    await http.get(Uri.parse(url));
  }
}
```

### Payload Formats

**iOS Payload (Standard):**
```json
{
  "aps": {
    "alert": {
      "title": "Special Offer Just for You!",
      "body": "Open to unlock exclusive savings."
    },
    "sound": "default"
  },
  "mi": {
    "url": "https://mi.example.com/p/push/abcd12345"
  }
}
```

**iOS Payload (DaVinci):**
```json
{
  "aps": {
    "alert": {
      "title": "Special Offer Just for You!",
      "body": "Open to unlock exclusive savings."
    },
    "sound": "default"
  },
  "data": {
    "mi_url": "https://mi.example.com/deeplink/abc123",
    "mi_source": "davinci"
  }
}
```

**Android Payload:**
```json
{
  "notification": {
    "title": "Special Offer Just for You!",
    "body": "Open to unlock exclusive savings."
  },
  "data": {
    "mi_url": "https://mi.example.com/deeplink/abc123",
    "mi_source": "davinci"
  }
}
```

## Additional Resources

- Full Documentation: https://sdk-mobile.movableink.com/
- pub.dev Package: https://pub.dev/packages/movable_ink_plugin
- iOS Native SDK: https://github.com/movableink/ios-sdk
- Android Native SDK: https://github.com/movableink/android-sdk
- Contact MovableInk team for: API keys, AASA/assetlinks.json configuration, MIU strategy, testing support