Flutter - Chat Screen built with a StreamBuilder showing messages multiple times
Asked Answered
H

1

0

I am struggling with this Chat Screen. The app is meant to ask questions (not part of the below code) and the user either selects answers or types them. When the user types a first answer everything goes according to the plan and a first message is displayed. However the app then goes on displaying the second answer twice, the third one three times and so on.

I have been facing this issue for a few days and I cannot figure out why the app behaves the way it does. Could you please take a look at the code and suggest a way to fix this?

To give you some background information, this Chat Screen is part of a larger application. It should subscribe to a stream when the user opens the app. Then each message is pushed to the stream, whether it is a question asked by the bot or an answer given by the User. The system listens to the stream and displays a new message each time the stream broadcasts something, in our case the latest user input.

I am using a list of message models built from the stream to display the messages. For the purpose of asking this question I simplified the model to the extreme but in practice it has 23 fields. Creating this list of messages is the best solution I managed to think of but there may be a better way to handle this situation. Feel free to let me know if you know of any.

Here is the code that I am running.

import 'package:flutter/material.dart';
import 'dart:async';


StreamController<ChatMessageModel> _chatMessagesStreamController = StreamController<ChatMessageModel>.broadcast();
Stream _chatMessagesStream = _chatMessagesStreamController.stream;

const Color primaryColor = Color(0xff6DA7B9);
const Color secondaryColor = Color(0xffF0F0F0);


void main() {

  runApp(MyApp());
}


class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Chat Screen',
      home: ChatScreen(),
    );
  }
}


class ChatMessageModel {

  final String message;

  const ChatMessageModel({
    this.message,
    }
  );

  factory ChatMessageModel.turnSnapshotIntoListRecord(Map data) {

    return ChatMessageModel(
      message: data['message'],
    );
  }

  @override
  List<Object> get props => [
    message,
  ];
}



class ChatScreen extends StatefulWidget {

  static const String id = 'chat_screen9';

  @override
  _ChatScreenState createState() => _ChatScreenState();
}


class _ChatScreenState extends State<ChatScreen> {

  final _messageTextController = TextEditingController();

  String _userInput;

  @override
  Widget build(BuildContext context) {

    return Scaffold(

      backgroundColor: secondaryColor,

      appBar: AppBar(

        title: Row(
          children: [

            Container(
              padding: EdgeInsets.all(8.0),
              child: Text('Chat Screen',
                style: TextStyle(color: Colors.white,),
              ),
            )
          ],
        ),

        backgroundColor: primaryColor,
      ),

      body: SafeArea(

        child: Column(

          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[

            MessagesStream(),

            Container(
              decoration: BoxDecoration(
                border: Border(
                  top: BorderSide(
                      color: primaryColor,
                      width: 1.0,
                  ),
                ),
              ),

              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,

                children: <Widget>[

                  Expanded(
                    child: TextField(
                      controller: _messageTextController,
                      onChanged: (value) {

                        _userInput = value;
                      },
                      decoration: InputDecoration(
                        contentPadding: EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0),
                        hintText: 'Type your answer here',
                        // border: InputBorder.none,
                      ),
                    ),
                  ),

                  TextButton(
                    onPressed: () {

                      _messageTextController.clear();

                      debugPrint('Adding a ChatMessageModel with the message $_userInput to the Stream');

                      ChatMessageModel chatMessageModelRecord = ChatMessageModel(message: _userInput);

                      _chatMessagesStreamController.add(chatMessageModelRecord,);
                    },

                    child: Text(
                      'OK',
                      style: TextStyle(
                        color: primaryColor,
                        fontWeight: FontWeight.bold,
                        fontSize: 18.0,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}


class MessagesStream extends StatelessWidget {

  List<ChatMessageModel> _allMessagesContainedInTheStream = [];

  @override
  Widget build(BuildContext context) {

    return StreamBuilder<ChatMessageModel>(
      stream: _chatMessagesStream,
      builder: (context, snapshot) {

        _chatMessagesStream.listen((streamedMessages) {

          // _allMessagesContainedInTheStream.clear();

          debugPrint('Value from controller: $streamedMessages');

          _allMessagesContainedInTheStream.add(streamedMessages);
        }
        );

        return Expanded(

          child: ListView.builder(
            // reverse: true,
            padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 20.0),
            itemCount: _allMessagesContainedInTheStream.length,
            itemBuilder: (BuildContext context, int index) {

              if (snapshot.hasData) {

                return UserChatBubble(chatMessageModelRecord: _allMessagesContainedInTheStream[index]);
              }
            },
          ),
        );
      },
    );
  }
}


class UserChatBubble extends StatelessWidget {

  final ChatMessageModel chatMessageModelRecord;

  const UserChatBubble({
    Key key,
    @required this.chatMessageModelRecord,
  }) : super(key: key);


  @override
  Widget build(BuildContext context) {

    return Row(
      mainAxisAlignment: MainAxisAlignment.end,

      children: [

        Padding(
          padding: EdgeInsets.symmetric(vertical: 5, horizontal: 5,),

          child: Container(
            constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 7 / 10,),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.only(
                bottomLeft: Radius.circular(15.0),
                bottomRight: Radius.circular(15.0),
                topLeft: Radius.circular(15.0),
              ),
              color: primaryColor,
            ),
            padding: EdgeInsets.symmetric(vertical: 8, horizontal: 20,),

            child: Text(chatMessageModelRecord.message,
              style: TextStyle(
                fontSize: 17,
                // fontWeight: FontWeight.w500,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    );
  }
}
Hyetology answered 20/7, 2021 at 13:34 Comment(0)
B
1

First of all, thank you for the interesting problem and functioning example provided. I had to do some small changes to convert it to "null-safety", but my code should work on your computer too.

The only problem you had initialization of _chatMessagesStream listener. You should do it only once and ideally in initState, to call it only once.

So here is the fix for you:

class MessagesStream extends StatefulWidget {
  @override
  _MessagesStreamState createState() => _MessagesStreamState();
}

class _MessagesStreamState extends State<MessagesStream> {
  final List<ChatMessageModel> _allMessagesContainedInTheStream = [];

  @override
  void initState() {
    _chatMessagesStream.listen((streamedMessages) {
      // _allMessagesContainedInTheStream.clear();

      debugPrint('Value from controller: $streamedMessages');

      _allMessagesContainedInTheStream.add(streamedMessages);
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<ChatMessageModel>(
      stream: _chatMessagesStream,
      builder: (context, snapshot) {
        return Expanded(
          child: ListView.builder(
            // reverse: true,
            padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 20.0),
            itemCount: _allMessagesContainedInTheStream.length,
            itemBuilder: (BuildContext context, int index) {
              if (snapshot.hasData) {
                return UserChatBubble(
                  chatMessageModelRecord:
                      _allMessagesContainedInTheStream[index],
                );
              } else {
                print(snapshot.connectionState);
                return Container();
              }
            },
          ),
        );
      },
    );
  }
}

enter image description here

Also providing full code for null-safety just in case!

import 'package:flutter/material.dart';
import 'dart:async';

final StreamController<ChatMessageModel> _chatMessagesStreamController =
    StreamController<ChatMessageModel>.broadcast();
final Stream<ChatMessageModel> _chatMessagesStream =
    _chatMessagesStreamController.stream;

const Color primaryColor = Color(0xff6DA7B9);
const Color secondaryColor = Color(0xffF0F0F0);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Chat Screen',
      home: ChatScreen(),
    );
  }
}

class ChatMessageModel {
  final String? message;

  const ChatMessageModel({
    this.message,
  });

  factory ChatMessageModel.turnSnapshotIntoListRecord(Map data) {
    return ChatMessageModel(
      message: data['message'],
    );
  }

  List<Object> get props => [
        message!,
      ];
}

class ChatScreen extends StatefulWidget {
  static const String id = 'chat_screen9';

  @override
  _ChatScreenState createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _messageTextController = TextEditingController();

  String? _userInput;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: secondaryColor,
      appBar: AppBar(
        title: Row(
          children: [
            Container(
              padding: EdgeInsets.all(8.0),
              child: Text(
                'Chat Screen',
                style: TextStyle(
                  color: Colors.white,
                ),
              ),
            )
          ],
        ),
        backgroundColor: primaryColor,
      ),
      body: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            MessagesStream(),
            Container(
              decoration: BoxDecoration(
                border: Border(
                  top: BorderSide(
                    color: primaryColor,
                    width: 1.0,
                  ),
                ),
              ),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  Expanded(
                    child: TextField(
                      controller: _messageTextController,
                      onChanged: (value) {
                        _userInput = value;
                      },
                      decoration: InputDecoration(
                        contentPadding: EdgeInsets.symmetric(
                            vertical: 10.0, horizontal: 20.0),
                        hintText: 'Type your answer here',
                        // border: InputBorder.none,
                      ),
                    ),
                  ),
                  TextButton(
                    onPressed: () {
                      _messageTextController.clear();

                      debugPrint(
                          'Adding a ChatMessageModel with the message $_userInput to the Stream');

                      ChatMessageModel chatMessageModelRecord =
                          ChatMessageModel(message: _userInput);

                      _chatMessagesStreamController.add(
                        chatMessageModelRecord,
                      );
                    },
                    child: Text(
                      'OK',
                      style: TextStyle(
                        color: primaryColor,
                        fontWeight: FontWeight.bold,
                        fontSize: 18.0,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class MessagesStream extends StatefulWidget {
  @override
  _MessagesStreamState createState() => _MessagesStreamState();
}

class _MessagesStreamState extends State<MessagesStream> {
  final List<ChatMessageModel> _allMessagesContainedInTheStream = [];

  @override
  void initState() {
    _chatMessagesStream.listen((streamedMessages) {
      // _allMessagesContainedInTheStream.clear();

      debugPrint('Value from controller: $streamedMessages');

      _allMessagesContainedInTheStream.add(streamedMessages);
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<ChatMessageModel>(
      stream: _chatMessagesStream,
      builder: (context, snapshot) {
        return Expanded(
          child: ListView.builder(
            // reverse: true,
            padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 20.0),
            itemCount: _allMessagesContainedInTheStream.length,
            itemBuilder: (BuildContext context, int index) {
              if (snapshot.hasData) {
                return UserChatBubble(
                  chatMessageModelRecord:
                      _allMessagesContainedInTheStream[index],
                );
              } else {
                print(snapshot.connectionState);
                return Container();
              }
            },
          ),
        );
      },
    );
  }
}

class UserChatBubble extends StatelessWidget {
  final ChatMessageModel chatMessageModelRecord;

  const UserChatBubble({
    Key? key,
    required this.chatMessageModelRecord,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.end,
      children: [
        Padding(
          padding: EdgeInsets.symmetric(
            vertical: 5,
            horizontal: 5,
          ),
          child: Container(
            constraints: BoxConstraints(
              maxWidth: MediaQuery.of(context).size.width * 7 / 10,
            ),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.only(
                bottomLeft: Radius.circular(15.0),
                bottomRight: Radius.circular(15.0),
                topLeft: Radius.circular(15.0),
              ),
              color: primaryColor,
            ),
            padding: EdgeInsets.symmetric(
              vertical: 8,
              horizontal: 20,
            ),
            child: Text(
              "${chatMessageModelRecord.message}",
              style: TextStyle(
                fontSize: 17,
                // fontWeight: FontWeight.w500,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    );
  }
}
Bristling answered 20/7, 2021 at 14:2 Comment(2)
Thank you for your quick answer and your explanations. The main take away is that was subscribing to the stream in the wrong place and by doing so I was in fact resubscribing each time a new event occured. I completely missed this but it makes sense with hindsight. Thank you, this is very helpful. Thank you also for pointing out the null safety issue and how to fix it.Hyetology
Great post @Maksim ! - thank you for sharing this comprehensive solutionGuadalupeguadeloupe

© 2022 - 2024 — McMap. All rights reserved.