sorisdk_flutter 0.4.0 copy "sorisdk_flutter: ^0.4.0" to clipboard
sorisdk_flutter: ^0.4.0 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,
    this.applicationId = const String.fromEnvironment('SORI_APP_ID'),
    this.secretKey = const String.fromEnvironment('SORI_SECRET_KEY'),
  });

  final String applicationId;
  final String secretKey;

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

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

  final messengerKey = GlobalKey<ScaffoldMessengerState>();
  late final SORIAudioRecognizer recognizer;
  StreamSubscription<SORIRecognitionEvent>? subscription;
  final recognitionState = SORIRecognitionState();
  final legacyCampaigns = <SORICampaign>[];
  var isStarting = false;
  var isRefreshing = 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 (recognitionState.accept(event)) {
        statusMessage = null;
        return;
      }

      // Older servers can report campaigns without an activity identity.
      // Display those responses without inventing an ID or marker association.
      final campaign = event.campaign;
      if (event.activityResult == null &&
          (event.type == SORIRecognitionEventType.campaignFound ||
              event.type == SORIRecognitionEventType.recognitionResult) &&
          campaign != null &&
          (campaign.id.isNotEmpty || campaign.name.isNotEmpty)) {
        legacyCampaigns.insert(0, campaign);
        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;
      });
      return;
    }

    setState(() {
      isStarting = true;
      statusMessage = null;
    });
    try {
      await recognizer.configure(
        config: const SORIRecognitionConfig(audiomarker: true),
      );
      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> refreshAudioPack() async {
    if (!configured || isRefreshing) return;
    setState(() => isRefreshing = true);
    try {
      final result = await recognizer.updateDatabase();
      if (!mounted) return;
      final message =
          result.errorMessage ??
          (result.success
              ? 'Refresh check completed. An update may be cached for the next start.'
              : 'Refresh check did not report success.');
      messengerKey.currentState?.showSnackBar(SnackBar(content: Text(message)));
    } catch (error) {
      if (!mounted) return;
      messengerKey.currentState?.showSnackBar(
        SnackBar(content: Text(error.toString())),
      );
    } finally {
      if (mounted) setState(() => isRefreshing = false);
    }
  }

  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(
      scaffoldMessengerKey: messengerKey,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('SORI SDK Flutter'),
          actions: [
            if (!kIsWeb)
              IconButton(
                tooltip: 'Refresh AudioPack',
                onPressed: configured && !isStarting && !isRefreshing
                    ? refreshAudioPack
                    : null,
                icon: const Icon(Icons.refresh),
              ),
          ],
        ),
        body: RecognitionResults(
          activities: recognitionState.activities,
          legacyCampaigns: legacyCampaigns,
          markerState: recognitionState.currentAudioMarker,
          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 RecognitionResults extends StatelessWidget {
  const RecognitionResults({
    required this.activities,
    required this.configured,
    required this.onTapCampaign,
    this.legacyCampaigns = const [],
    this.markerState,
    this.statusMessage,
    super.key,
  });

  final List<SORIActivityResult> activities;
  final List<SORICampaign> legacyCampaigns;
  final SORIAudioMarkerState? markerState;
  final bool configured;
  final String? statusMessage;
  final ValueChanged<SORICampaign> onTapCampaign;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
      children: <Widget>[
        if (markerState case final state?) ...[
          Card(
            child: ListTile(
              leading: const Icon(Icons.graphic_eq),
              title: Text(switch (state.status) {
                SORIAudioMarkerStatus.inactive => 'Marker recognition inactive',
                SORIAudioMarkerStatus.listening => 'Listening for a marker',
                SORIAudioMarkerStatus.detected =>
                  'Marker detected; identifying',
                SORIAudioMarkerStatus.identified =>
                  state.marker?.name ?? 'Marker detected; identifying',
                SORIAudioMarkerStatus.cleared => 'Current marker cleared',
                SORIAudioMarkerStatus.unknown => 'Marker state unavailable',
              }),
              subtitle: state.marker == null
                  ? null
                  : Text('Current marker ID: ${state.marker!.id}'),
            ),
          ),
          const SizedBox(height: 12),
        ],
        if (activities.isEmpty && legacyCampaigns.isEmpty)
          EmptyState(configured: configured, message: statusMessage)
        else ...[
          for (final result in activities)
            ActivityCard(
              result: result,
              onTap: result.campaign == null
                  ? null
                  : () => onTapCampaign(result.campaign!),
            ),
          for (final campaign in legacyCampaigns)
            ActivityCard.legacy(
              campaign: campaign,
              onTap: () => onTapCampaign(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 ActivityCard extends StatelessWidget {
  const ActivityCard({
    required SORIActivityResult this.result,
    this.onTap,
    super.key,
  }) : _legacyCampaign = null;

  const ActivityCard.legacy({
    required SORICampaign campaign,
    this.onTap,
    super.key,
  }) : result = null,
       _legacyCampaign = campaign;

  final SORIActivityResult? result;
  final SORICampaign? _legacyCampaign;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final result = this.result;
    final campaign = result?.campaign ?? _legacyCampaign;
    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: [
            if (campaign?.imageUrl != null)
              CampaignImage(url: campaign!.imageUrl),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    campaign == null
                        ? 'Material ${result?.materialId ?? result?.activityId}'
                        : campaign.name.isEmpty
                        ? campaign.id
                        : campaign.name,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  if (result != null) Text('Activity: ${result.activityId}'),
                  if (result?.audioMarkerIdentity case final marker?)
                    Chip(
                      label: Text(marker.name),
                      avatar: const Icon(Icons.graphic_eq),
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

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),
        ),
      ),
    );
  }
}
0
likes
130
points
408
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