Search code examples
flutterformsdartinputspeech-to-text

Flutter: how to manage speech_to_text with a Form


In my Flutter application I have a form that saves data to Firestore. The user must be able to enter data by writing or speaking. To do this, I have attached the speech_to_text plugin to the form.

The problem is that I haven't found a way to manage speaking and writing together: for example, if the user speaks, then modifies the text, then continues speaking, how can I keep the text properly updated in the TextFormField?

For example, I cannot manage these sequences:

  1. microphone-on, speak, edit, speak
  2. mic-on, speak, mic-off, edit, mic-on, speak

Here is my code:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'package:speech_to_text/speech_recognition_result.dart';

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

  @override
  State<Speech> createState() => _SpeechState();
}

class _SpeechState extends State<Speech> {
  bool _hasSpeech = false;
  String lastWords = '';
  String lastStatus = '';
  final SpeechToText speech = SpeechToText();

  bool textChanged = false;
  final TextEditingController _descriptionController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _initSpeechState();
  }

  Future<void> _initSpeechState() async {
    try {
      bool hasSpeech = await speech.initialize();

      if (!mounted) return;

      setState(() {
        _hasSpeech = hasSpeech;
      });
    } catch (e) {
      setState(() {
        _hasSpeech = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      // ApplicationState is the widget with the state of my app
      Consumer<ApplicationState>(builder: (context, appState, _) {
        return FutureBuilder<Baby>(
            future: appState.getBabyData(), // I recover the data from Firestore
            builder: (BuildContext context, AsyncSnapshot<Baby> snapshot) {
              if (!snapshot.hasData) {
                return const Center(
                  child: CircularProgressIndicator(),
                );
              } else {
                return MyForm(
                  baby: snapshot.requireData,
                  lastWords: lastWords,
                  descriptionController: _descriptionController,
                  stopListening: stopListening,
                  textChanged: textChanged,
                  setTextChanged: setTextChanged,
                );
              }
            });
      }),
      MicrophoneWidget(speech.isNotListening, startListening, stopListening),
    ]);
  }

  void setTextChanged(changed) {
    setState(() {
      textChanged = changed;
    });
  }

  void startListening() {
    lastWords = '';
    speech.listen(
      onResult: resultListener,
    );
    setState(() {
      textChanged = false;
    });
  }

  void stopListening() {
    speech.stop();
    setState(() {
      textChanged = false;
    });
  }

  void resultListener(SpeechRecognitionResult result) {
    setState(() {
      lastWords = result.recognizedWords;
      _descriptionController.text = lastWords;
    });
  }
}

class MicrophoneWidget extends StatelessWidget {
  const MicrophoneWidget(this.isNotListening, this.startListening, this.stopListening, {super.key});

  final bool isNotListening;
  final void Function() startListening;
  final void Function() stopListening;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      child: FloatingActionButton(
        onPressed: isNotListening ? startListening : stopListening,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(80.0)),
        ),
        child: Icon(isNotListening ? Icons.mic_off : Icons.mic),
      ),
    );
  }
}

class MyForm extends StatefulWidget {
  const MyForm({
    super.key,
    required this.baby,
    required this.lastWords,
    required this.textChanged,
    required this.setTextChanged,
    required this.descriptionController,
    required this.stopListening,
  });

  final Baby baby;
  final String lastWords;
  final bool textChanged;
  final TextEditingController descriptionController;
  final void Function() stopListening;
  final void Function(bool) setTextChanged;

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  final _formKey = GlobalKey<FormState>(debugLabel: 'MyFormState');

  void _saveBabyProfile(appState) async {
    widget.stopListening();

    if (_formKey.currentState!.validate()) {
      widget.baby.description = widget.descriptionController.text;

      // I save the data in Firestore
      appState.setBabyData(widget.baby);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (!widget.textChanged) {
      widget.descriptionController.text = widget.lastWords;
      if (widget.baby.description != null) {
        widget.descriptionController.text = '${widget.baby.description} ${widget.lastWords}';
      }
    }

    return Form(
      key: _formKey,
      child: Column(
        children: <Widget>[
          TextFormField(
            controller: widget.descriptionController,
            onChanged: (value) {
              widget.setTextChanged(true);
            },
          ),
          Consumer<ApplicationState>(
            builder: (context, appState, _) => ElevatedButton(
              onPressed: () => _saveBabyProfile(appState),
              child: const Text("Save"),
            ),
          ),
        ],
      ),
    );
  }
}

Thanks for your help in advance!


Solution

  • Finally I found how to integrate speech_to_text with a Form. Here is the updated code:

    import 'dart:async';
    
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import 'package:speech_to_text/speech_to_text.dart';
    import 'package:speech_to_text/speech_recognition_result.dart';
    
    class Speech extends StatefulWidget {
      const Speech({super.key});
    
      @override
      State<Speech> createState() => _SpeechState();
    }
    
    class _SpeechState extends State<Speech> {
      bool _hasSpeech = false;
      String lastWords = '';
      final SpeechToText speech = SpeechToText();
    
      // I managed `speech_to_text` with the Form through this new variable:
      String lastTextChange = '';
    
      final TextEditingController _descriptionController = TextEditingController();
    
      @override
      void initState() {
        super.initState();
        _initSpeechState();
      }
    
      Future<void> _initSpeechState() async {
        try {
          bool hasSpeech = await speech.initialize();
    
          if (!mounted) return;
    
          setState(() {
            _hasSpeech = hasSpeech;
          });
        } catch (e) {
          setState(() {
            _hasSpeech = false;
          });
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(children: [
          // ApplicationState is the widget with the state of my app
          Consumer<ApplicationState>(builder: (context, appState, _) {
            return FutureBuilder<Baby>(
                future: appState.getBabyData(), // I recover the data from Firestore
                builder: (BuildContext context, AsyncSnapshot<Baby> snapshot) {
                  if (!snapshot.hasData) {
                    return const Center(
                      child: CircularProgressIndicator(),
                    );
                  } else {
                    return MyForm(
                      baby: snapshot.requireData,
                      lastWords: lastWords,
                      descriptionController: _descriptionController,
                      stopListening: stopListening,
                      setLastTextChange: setLastTextChange,
                    );
                  }
                });
          }),
          MicrophoneWidget(speech.isNotListening, startListening, stopListening),
        ]);
      }
    
      void setLastTextChange(lastText) {
        lastTextChange = lastText;
      }
    
      void startListening() {
        lastWords = '';
        speech.listen(
          onResult: resultListener,
        );
        setState(() {});
      }
    
      void stopListening() {
        speech.stop();
        setState(() {
          lastTextChange = _descriptionController.text;
        });
      }
    
      void resultListener(SpeechRecognitionResult result) {
        if (result.finalResult) {
          setState(() {
            lastTextChange = _descriptionController.text;
          });
        } else {
          setState(() {
            lastWords = result.recognizedWords;
            _descriptionController.text = '$lastTextChange $lastWords';
          });
        }
      }
    }
    
    class MicrophoneWidget extends StatelessWidget {
      const MicrophoneWidget(this.isNotListening, this.startListening, this.stopListening, {super.key});
    
      final bool isNotListening;
      final void Function() startListening;
      final void Function() stopListening;
    
      @override
      Widget build(BuildContext context) {
        return SizedBox(
          child: FloatingActionButton(
            onPressed: isNotListening ? startListening : stopListening,
            shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(80.0)),
            ),
            child: Icon(isNotListening ? Icons.mic_off : Icons.mic),
          ),
        );
      }
    }
    
    class MyForm extends StatefulWidget {
      const MyForm({
        super.key,
        required this.baby,
        required this.lastWords,
        required this.descriptionController,
        required this.stopListening,
        required this.setLastTextChange,
      });
    
      final Baby baby;
      final String lastWords;
      final TextEditingController descriptionController;
      final void Function() stopListening;
      final void Function(String) setLastTextChange;
    
      @override
      State<MyForm> createState() => _MyFormState();
    }
    
    class _MyFormState extends State<MyForm> {
      final _formKey = GlobalKey<FormState>(debugLabel: 'BabyDescriptionFormState');
    
      void _saveBabyProfile(appState) async {
        widget.stopListening();
    
        if (_formKey.currentState!.validate()) {
          widget.baby.description = widget.descriptionController.text;
    
          // I save the data in Firebase
          appState.setBabyData(widget.baby);
        }
      }
    
      @override
      void initState() {
        super.initState();
        widget.descriptionController.text = widget.baby.description ?? '';
        widget.setLastTextChange(widget.descriptionController.text);
      }
    
      @override
      Widget build(BuildContext context) {
        return Form(
          key: _formKey,
          child: Column(
            children: <Widget>[
              TextFormField(
                controller: widget.descriptionController,
                onChanged: (value) {
                  widget.stopListening();
                  widget.setLastTextChange(value);
                },
              ),
              Consumer<ApplicationState>(
                builder: (context, appState, _) => Align(
                  child: ElevatedButton(
                    onPressed: () => _saveBabyProfile(appState),
                    child: const Text("Save"),
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }