sorisdk_flutter 0.3.2 copy "sorisdk_flutter: ^0.3.2" to clipboard
sorisdk_flutter: ^0.3.2 copied to clipboard

Add SORI-powered audio recognition, campaign discovery, and action-link handling to Flutter apps on Android, iOS, and Web.

example/lib/main.dart

import 'dart:async';

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sorisdk_flutter/sorisdk_flutter.dart';

void main() {
  runApp(const ExampleApp());
}

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  static const applicationId = String.fromEnvironment('SORI_APP_ID');
  static const secretKey = String.fromEnvironment('SORI_SECRET_KEY');
  static const ephemeralKeyEndpoint = String.fromEnvironment(
    'SORI_EPHEMERAL_KEY_ENDPOINT',
  );

  late final SORIAudioRecognizer recognizer;
  StreamSubscription<SORIRecognitionEvent>? subscription;
  final campaignTimeline = CampaignTimelineReconciler();
  var isStarting = false;
  var isRunning = false;
  String? statusMessage;

  bool get configured =>
      applicationId.isNotEmpty &&
      (kIsWeb ? ephemeralKeyEndpoint.isNotEmpty : secretKey.isNotEmpty);

  @override
  void initState() {
    super.initState();
    recognizer = kIsWeb
        ? SORIAudioRecognizer.web(
            applicationId: applicationId,
            webAuth: SORIWebAuthOptions.ephemeralKeyEndpoint(
              Uri.parse(ephemeralKeyEndpoint),
            ),
          )
        : SORIAudioRecognizer(
            applicationId: applicationId,
            secretKey: secretKey,
          );
    subscription = recognizer.events.listen(handleEvent);
  }

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

  void handleEvent(SORIRecognitionEvent event) {
    setState(() {
      if (event.type == SORIRecognitionEventType.stateChanged) {
        final state = event.payload['state'];
        isStarting = state == 'STARTING';
        isRunning = state == 'STARTING' || state == 'STARTED';
        if (state == 'STOPPED' || state == 'DESTROYED') {
          campaignTimeline.closeCurrentSegment();
        }
      }

      if (campaignTimeline.add(event)) {
        statusMessage = null;
        return;
      }

      if (event.type == SORIRecognitionEventType.error ||
          event.type == SORIRecognitionEventType.networkError) {
        statusMessage = event.message ?? 'Recognition failed.';
      }
    });
  }

  Future<void> toggleRecognition() async {
    if (!configured || isStarting) {
      return;
    }

    if (isRunning) {
      await recognizer.stopRecognition();
      setState(() {
        isRunning = false;
        campaignTimeline.closeCurrentSegment();
      });
      return;
    }

    setState(() {
      isStarting = true;
      statusMessage = null;
    });
    try {
      await recognizer.configure();
      await recognizer.startRecognition(
        notification: const SORIAndroidNotificationOptions(
          title: 'SORI recognition',
          body: 'Listening for SORI audio signals',
        ),
      );
      setState(() {
        isStarting = false;
        isRunning = true;
      });
    } on PlatformException catch (error) {
      setState(() {
        isStarting = false;
        statusMessage = error.message ?? error.code;
      });
    } catch (error) {
      setState(() {
        isStarting = false;
        statusMessage = error.toString();
      });
    }
  }

  Future<void> openCampaign(SORICampaign campaign) async {
    final url = campaign.actionUrl;
    if (url == null || url.isEmpty) {
      return;
    }

    try {
      await recognizer.handleActionUrl(url);
    } on PlatformException catch (error) {
      if (!mounted) {
        return;
      }
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text(error.message ?? error.code)));
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('SORI SDK Flutter')),
        body: CampaignList(
          campaigns: campaignTimeline.campaigns,
          configured: configured,
          statusMessage: statusMessage,
          onTapCampaign: openCampaign,
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: configured && !isStarting ? toggleRecognition : null,
          tooltip: isRunning ? 'Stop recognition' : 'Start recognition',
          child: Icon(isRunning ? Icons.mic_off : Icons.mic),
        ),
      ),
    );
  }
}

class CampaignList extends StatelessWidget {
  const CampaignList({
    required this.campaigns,
    required this.configured,
    required this.onTapCampaign,
    this.statusMessage,
    super.key,
  });

  final List<RecognizedCampaign> campaigns;
  final bool configured;
  final String? statusMessage;
  final ValueChanged<SORICampaign> onTapCampaign;

  @override
  Widget build(BuildContext context) {
    final itemCount = campaigns.isEmpty ? 1 : campaigns.length;

    return ListView.builder(
      padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
      itemCount: itemCount,
      itemBuilder: (context, index) {
        if (campaigns.isEmpty) {
          return EmptyState(configured: configured, message: statusMessage);
        }
        return CampaignCard(
          campaign: campaigns[index].campaign,
          onTap: () => onTapCampaign(campaigns[index].campaign),
        );
      },
    );
  }
}

class EmptyState extends StatelessWidget {
  const EmptyState({required this.configured, this.message, super.key});

  final bool configured;
  final String? message;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 48),
      child: Center(
        child: Text(
          message ??
              (configured
                  ? 'No recognition results yet.'
                  : kIsWeb
                  ? 'Missing SORI_APP_ID or '
                        'SORI_EPHEMERAL_KEY_ENDPOINT.'
                  : 'Missing SORI_APP_ID or SORI_SECRET_KEY.'),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

class CampaignCard extends StatelessWidget {
  const CampaignCard({required this.campaign, required this.onTap, super.key});

  final SORICampaign campaign;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      margin: const EdgeInsets.only(bottom: 12),
      child: InkWell(
        onTap: campaign.actionUrl == null ? null : onTap,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            CampaignImage(url: campaign.imageUrl),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    campaign.name.isEmpty ? campaign.id : campaign.name,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  if (campaign.trait?.marker case final marker?) ...[
                    const SizedBox(height: 4),
                    Text('Marker: $marker'),
                  ],
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class CampaignImage extends StatelessWidget {
  const CampaignImage({required this.url, super.key});

  final String? url;

  @override
  Widget build(BuildContext context) {
    if (url == null || url!.isEmpty) {
      return const SizedBox(
        height: 180,
        child: ColoredBox(
          color: Color(0xFFE0E0E0),
          child: Icon(Icons.image_not_supported_outlined, size: 40),
        ),
      );
    }

    return CachedNetworkImage(
      imageUrl: url!,
      height: 180,
      fit: BoxFit.cover,
      placeholder: (context, url) => const SizedBox(
        height: 180,
        child: Center(child: CircularProgressIndicator()),
      ),
      errorWidget: (context, url, error) => const SizedBox(
        height: 180,
        child: ColoredBox(
          color: Color(0xFFE0E0E0),
          child: Icon(Icons.broken_image_outlined, size: 40),
        ),
      ),
    );
  }
}

Map<String, Object?>? stringKeyedMap(Object? value) {
  if (value is Map<String, Object?>) {
    return value;
  }
  if (value is Map) {
    return value.map((key, value) => MapEntry(key.toString(), value));
  }
  return null;
}

class RecognizedCampaign {
  const RecognizedCampaign({required this.campaign, this.activityId});

  final SORICampaign campaign;
  final String? activityId;
}

/// Reconciles campaign callbacks against only the latest ordered material
/// segment. Exact activity refinements never search and mutate older rows.
class CampaignTimelineReconciler {
  final _campaigns = <RecognizedCampaign>[];
  RecognizedCampaign? _currentSegment;

  List<RecognizedCampaign> get campaigns =>
      List<RecognizedCampaign>.unmodifiable(_campaigns);

  void closeCurrentSegment() {
    _currentSegment = null;
  }

  bool add(SORIRecognitionEvent event) {
    final incoming = recognizedCampaignFromEvent(event);
    if (incoming == null) {
      _sealCurrentSegmentFor(observedMaterialIdFromEvent(event));
      return false;
    }

    final current = _currentSegment;
    if (current == null) {
      if (_isKnownActivity(incoming.activityId)) {
        return true;
      }
      _insert(incoming);
      return true;
    }

    final currentActivityId = current.activityId;
    final incomingActivityId = incoming.activityId;
    final currentMaterialId = current.campaign.materialId;
    final incomingMaterialId = incoming.campaign.materialId;
    final materialsConflict =
        currentMaterialId != null &&
        incomingMaterialId != null &&
        currentMaterialId != incomingMaterialId;
    final sameMaterial =
        currentMaterialId != null && currentMaterialId == incomingMaterialId;

    if (currentActivityId != null && currentActivityId == incomingActivityId) {
      if (!materialsConflict) {
        _replaceCurrent(_mergeCurrent(current, incoming));
      }
      return true;
    }

    // A delayed exact-id refinement for an older segment is stale once another
    // material (or an explicitly closed recognition run) is current. Apply
    // this before the legacy fallback so an older identity cannot be attached
    // to a newer identity-less segment of the same material.
    if (_isKnownActivity(incomingActivityId)) {
      return true;
    }

    // Older payloads may omit activity identity. Their fallback is deliberately
    // bounded to the current material segment and never searches history.
    if (sameMaterial &&
        (currentActivityId == null || incomingActivityId == null)) {
      _replaceCurrent(_mergeCurrent(current, incoming));
      return true;
    }

    _insert(incoming);
    return true;
  }

  bool _isKnownActivity(String? activityId) =>
      activityId != null &&
      _campaigns.any((campaign) => campaign.activityId == activityId);

  void _sealCurrentSegmentFor(String? materialId) {
    final currentMaterialId = _currentSegment?.campaign.materialId;
    if (currentMaterialId != null &&
        materialId != null &&
        currentMaterialId != materialId) {
      _currentSegment = null;
    }
  }

  void _insert(RecognizedCampaign campaign) {
    _campaigns.insert(0, campaign);
    _currentSegment = campaign;
  }

  void _replaceCurrent(RecognizedCampaign campaign) {
    _campaigns[0] = campaign;
    _currentSegment = campaign;
  }
}

RecognizedCampaign _mergeCurrent(
  RecognizedCampaign current,
  RecognizedCampaign incoming,
) {
  final currentMarker = _nonEmptyMarker(current.campaign.trait?.marker);
  final incomingMarker = _nonEmptyMarker(incoming.campaign.trait?.marker);
  final marker = currentMarker ?? incomingMarker;
  final activityId = current.activityId ?? incoming.activityId;
  // Once a marker-bearing refinement has supplied the authoritative campaign
  // row, a delayed marker-miss duplicate must not regress its other fields.
  final campaign = currentMarker != null && incomingMarker == null
      ? current.campaign
      : incoming.campaign;

  return RecognizedCampaign(
    activityId: activityId,
    campaign: SORICampaign(
      id: campaign.id,
      name: campaign.name,
      description: campaign.description,
      imageUrl: campaign.imageUrl,
      actionUrl: campaign.actionUrl,
      createdAt: campaign.createdAt,
      materialId: campaign.materialId ?? current.campaign.materialId,
      trait: marker == null ? null : SORIActivityTrait(marker: marker),
      activityId: activityId,
    ),
  );
}

String? _nonEmptyMarker(String? marker) =>
    marker == null || marker.isEmpty ? null : marker;

String? observedMaterialIdFromEvent(SORIRecognitionEvent event) {
  if (event.type != SORIRecognitionEventType.campaignFound &&
      event.type != SORIRecognitionEventType.recognitionResult) {
    return null;
  }

  final campaignMaterialId = event.campaign?.materialId;
  if (campaignMaterialId != null && campaignMaterialId.isNotEmpty) {
    return campaignMaterialId;
  }

  final payload = event.payload;
  for (final value in <Object?>[
    payload['materialId'],
    payload['material_id'],
  ]) {
    if (value is String && value.isNotEmpty) {
      return value;
    }
  }

  final match = stringKeyedMap(payload['match']);
  for (final value in <Object?>[
    match?['materialId'],
    match?['material_id'],
    match?['name'],
  ]) {
    if (value is String && value.isNotEmpty) {
      return value;
    }
  }
  return null;
}

RecognizedCampaign? recognizedCampaignFromEvent(SORIRecognitionEvent event) {
  if (event.type != SORIRecognitionEventType.campaignFound &&
      event.type != SORIRecognitionEventType.recognitionResult) {
    return null;
  }

  var campaign = event.campaign;
  final payloadCampaign = stringKeyedMap(event.payload['campaign']);
  campaign ??= payloadCampaign == null
      ? null
      : SORICampaign.fromMap(payloadCampaign);
  campaign ??= SORICampaign.fromMap(event.payload);
  if (campaign.id.isEmpty && campaign.name.isEmpty) {
    return null;
  }

  return RecognizedCampaign(
    campaign: campaign,
    activityId: event.activityId ?? campaign.activityId,
  );
}
0
likes
130
points
234
downloads

Documentation

API reference

Publisher

verified publisheriplateia.com

Weekly Downloads

Add SORI-powered audio recognition, campaign discovery, and action-link handling to Flutter apps on Android, iOS, and Web.

Homepage

License

unknown (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface

More

Packages that depend on sorisdk_flutter

Packages that implement sorisdk_flutter