sorisdk_flutter 0.3.4
sorisdk_flutter: ^0.3.4 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;
SORIAudioMarkerIdentity? latestAudioMarker;
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 (event.audioMarkerIdentity case final marker?) {
latestAudioMarker = marker;
statusMessage = null;
}
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,
audioMarker: latestAudioMarker,
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.audioMarker,
this.statusMessage,
super.key,
});
final List<RecognizedCampaign> campaigns;
final SORIAudioMarkerIdentity? audioMarker;
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 (audioMarker case final marker?) ...[
AudioMarkerCard(marker: marker),
const SizedBox(height: 12),
],
if (campaigns.isEmpty)
EmptyState(configured: configured, message: statusMessage)
else
for (final recognized in campaigns)
CampaignCard(
campaign: recognized.campaign,
onTap: () => onTapCampaign(recognized.campaign),
),
],
);
}
}
class AudioMarkerCard extends StatelessWidget {
const AudioMarkerCard({required this.marker, super.key});
final SORIAudioMarkerIdentity marker;
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(marker.name),
subtitle: Text('Audio marker ID: ${marker.id}'),
),
);
}
}
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,
),
],
),
),
],
),
),
);
}
}
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;
bool _currentSegmentHasResolvedMarker = false;
List<RecognizedCampaign> get campaigns =>
List<RecognizedCampaign>.unmodifiable(_campaigns);
void closeCurrentSegment() {
_currentSegment = null;
_currentSegmentHasResolvedMarker = false;
}
bool add(SORIRecognitionEvent event) {
if (event.audioMarkerIdentity != null) {
if (_currentSegment != null) {
_currentSegmentHasResolvedMarker = true;
}
return false;
}
final incoming = recognizedCampaignFromEvent(event);
if (incoming == null) {
_sealCurrentSegmentFor(observedMaterialIdFromEvent(event));
return false;
}
final incomingHasResolvedMarker = _hasResolvedMarkerResponse(event);
final current = _currentSegment;
if (current == null) {
if (_isKnownActivity(incoming.activityId)) {
return true;
}
_insert(incoming, hasResolvedMarker: incomingHasResolvedMarker);
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 &&
(!_currentSegmentHasResolvedMarker || incomingHasResolvedMarker)) {
_replaceCurrent(_mergeCurrent(current, incoming));
}
_currentSegmentHasResolvedMarker |= incomingHasResolvedMarker;
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)) {
if (!_currentSegmentHasResolvedMarker || incomingHasResolvedMarker) {
_replaceCurrent(_mergeCurrent(current, incoming));
}
_currentSegmentHasResolvedMarker |= incomingHasResolvedMarker;
return true;
}
_insert(incoming, hasResolvedMarker: incomingHasResolvedMarker);
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;
_currentSegmentHasResolvedMarker = false;
}
}
void _insert(RecognizedCampaign campaign, {required bool hasResolvedMarker}) {
_campaigns.insert(0, campaign);
_currentSegment = campaign;
_currentSegmentHasResolvedMarker = hasResolvedMarker;
}
void _replaceCurrent(RecognizedCampaign campaign) {
_campaigns[0] = campaign;
_currentSegment = campaign;
}
}
bool _hasResolvedMarkerResponse(SORIRecognitionEvent event) {
for (final candidate in <Map<String, Object?>?>[
event.payload,
stringKeyedMap(event.payload['raw']),
]) {
final id = candidate?['audioMarkerId'] ?? candidate?['audio_marker_id'];
final name =
candidate?['audioMarkerName'] ?? candidate?['audio_marker_name'];
if (id is String &&
id.trim().isNotEmpty &&
name is String &&
name.trim().isNotEmpty) {
return true;
}
}
return false;
}
RecognizedCampaign _mergeCurrent(
RecognizedCampaign current,
RecognizedCampaign incoming,
) {
final activityId = current.activityId ?? incoming.activityId;
final 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,
activityId: activityId,
),
);
}
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,
);
}