sorisdk_flutter 0.3.8
sorisdk_flutter: ^0.3.8 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 recognitionState = SORIRecognitionState();
final legacyCampaigns = <SORICampaign>[];
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 (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> 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: 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),
),
),
);
}
}