local_voice 0.1.0
local_voice: ^0.1.0 copied to clipboard
Privacy-first, on-device voice commands for Flutter. No cloud, no API keys, works in airplane mode.
import 'package:flutter/material.dart';
import 'package:local_voice/local_voice.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'local_voice demo',
theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
home: const VoiceDemoPage(),
);
}
}
class VoiceDemoPage extends StatefulWidget {
const VoiceDemoPage({super.key});
@override
State<VoiceDemoPage> createState() => _VoiceDemoPageState();
}
class _VoiceDemoPageState extends State<VoiceDemoPage> {
static const _localVoice = LocalVoice();
final _controller = VoiceListenerController();
late final VoiceCommandRegistry _registry;
Color _background = Colors.white;
String _transcript = '';
String _lastCommand = 'Say a command, or press and hold the mic.';
List<VoiceCommandMatch> _ambiguousCandidates = const [];
bool _permissionGranted = false;
bool _available = false;
bool _isListening = false;
@override
void initState() {
super.initState();
_registry = VoiceCommandRegistry(
onAmbiguous: (candidates) {
setState(() => _ambiguousCandidates = candidates);
},
commands: [
VoiceCommand(
phrases: const ['turn background red', 'make it red', 'red please'],
intent: const ToggleIntent('background', value: true),
onMatch: () => _setBackground(Colors.red.shade200, 'red'),
),
VoiceCommand(
phrases: const [
'turn background blue',
'make it blue',
'blue please',
],
onMatch: () => _setBackground(Colors.blue.shade200, 'blue'),
),
VoiceCommand(
phrases: const [
'turn background green',
'make it green',
'green please',
],
onMatch: () => _setBackground(Colors.green.shade200, 'green'),
),
VoiceCommand(
phrases: const ['reset background', 'clear background', 'reset'],
onMatch: () => _setBackground(Colors.white, 'reset'),
),
// Two intentionally close phrases to demonstrate disambiguation.
VoiceCommand(
phrases: const ['open settings'],
onMatch: () => _announce('Opened settings'),
),
VoiceCommand(
phrases: const ['open sittings'],
onMatch: () => _announce('Opened "sittings" (demo command)'),
),
// Built-in localized "back"/"next" phrases, English + Spanish at once.
VoiceCommand(
phrases: CommonPhrases.merge(
[CommonPhrases.en, CommonPhrases.es],
(locale) => locale.back,
),
onMatch: () => _announce('Back'),
),
],
);
_checkAvailability();
}
Future<void> _checkAvailability() async {
final available = await _localVoice.isAvailable();
final granted = await _localVoice.hasPermission();
if (!mounted) return;
setState(() {
_available = available;
_permissionGranted = granted;
});
}
Future<void> _requestPermission() async {
final granted = await _localVoice.requestPermission();
if (!mounted) return;
setState(() => _permissionGranted = granted);
}
void _setBackground(Color color, String name) {
setState(() {
_background = color;
_lastCommand = 'Matched: background -> $name';
});
}
void _announce(String message) {
setState(() => _lastCommand = message);
}
void _resolveAmbiguous(VoiceCommandMatch match) {
_registry.resolve(match);
setState(() => _ambiguousCandidates = const []);
}
@override
Widget build(BuildContext context) {
return VoiceListener(
registry: _registry,
controller: _controller,
mode: VoiceListeningMode.pushToTalk,
onTranscript: (result) => setState(() => _transcript = result.transcript),
onStatusChanged: (status) => setState(
() => _isListening = status != VoiceListeningStatus.idle,
),
onError: (error) => setState(
() => _lastCommand = 'Error: ${error.code} — ${error.message}',
),
child: Scaffold(
appBar: AppBar(title: const Text('local_voice demo')),
backgroundColor: _background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_available)
const _InfoBanner(
'On-device speech recognition is not available on this '
'device/emulator.',
),
if (_available && !_permissionGranted)
_InfoBanner(
'Microphone permission is required.',
action: TextButton(
onPressed: _requestPermission,
child: const Text('Grant permission'),
),
),
const SizedBox(height: 16),
Text('Live transcript', style: Theme.of(context).textTheme.labelLarge),
Text(
_transcript.isEmpty ? '…' : _transcript,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text(_lastCommand, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 24),
if (_ambiguousCandidates.isNotEmpty) _DisambiguationPrompt(
candidates: _ambiguousCandidates,
onPick: _resolveAmbiguous,
),
const Spacer(),
_MicButton(controller: _controller, isListening: _isListening),
],
),
),
),
),
);
}
}
class _MicButton extends StatelessWidget {
const _MicButton({required this.controller, required this.isListening});
final VoiceListenerController controller;
final bool isListening;
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onLongPressStart: (_) => controller.start(),
onLongPressEnd: (_) => controller.stop(),
child: CircleAvatar(
radius: 40,
backgroundColor: isListening ? Colors.redAccent : null,
child: Icon(isListening ? Icons.mic : Icons.mic_none, size: 32),
),
),
);
}
}
class _InfoBanner extends StatelessWidget {
const _InfoBanner(this.message, {this.action});
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(child: Text(message)),
?action,
],
),
),
);
}
}
class _DisambiguationPrompt extends StatelessWidget {
const _DisambiguationPrompt({required this.candidates, required this.onPick});
final List<VoiceCommandMatch> candidates;
final void Function(VoiceCommandMatch match) onPick;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Did you mean:'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final candidate in candidates)
ActionChip(
label: Text(candidate.command.label),
onPressed: () => onPick(candidate),
),
],
),
],
),
),
);
}
}