DEV Community

Cover image for Flutter app, fading chat logic with bloc and hooks
Saad Alkentar
Saad Alkentar

Posted on

Flutter app, fading chat logic with bloc and hooks

What can you expect from this article?

We finished the fading transition implementation in the previous article and will use it to finish our home chat page.
It is a specific scenario we are trying to build, so it is a good practice for bloc with hooks usage. Yet, it might be too specific, so feel free to skip this article.
PS. we are working on our already built code base.

The scenario we are trying to build is

  1. The user clicks on the text field
  2. The app closes the keyboard (for the first time)
  3. The app sends a conversation request to the AI
  4. The app fades in the server response
  5. The user clicks on the text field to start writing the response
  6. The app fades out the server response
  7. The user clicks the send button from the keyboard
  8. The app sends the message to the AI
  9. The app fades out the user text and fades in the server response

The app should read the server response, and the user can use STT (speech to text) instead of writing. Please refer to Flutter App, Speech to Text and Text to Speech for detailed implementation of TTS and STT.

API calls and models (optional)

We will try to concentrate on the home screen UI since we have already detailed how to implement any API in our clean architecture project, but it doesn't hurt to repeat. So feel free to skip this part of the article if you are familiar with the process.

Start at the domain layer

We always start by creating the models in the domain layer

class MessageModel {
  MessageModel({
      this.created, 
      this.active, 
      this.text, 
      this.isUser, 
      this.conversation, 
      this.id,});

  MessageModel.fromJson(dynamic json) {
    created = json['created'];
    active = json['active'];
    text = json['text'];
    isUser = json['is_user'];
    conversation = json['conversation'];
    id = json['id'];
  }

  String? created;
  bool? active;
  String? text;
  bool? isUser;
  int? conversation;
  int? id;

  Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    map['created'] = created;
    map['active'] = active;
    map['text'] = text;
    map['is_user'] = isUser;
    map['conversation'] = conversation;
    map['id'] = id;
    return map;
  }
}
Enter fullscreen mode Exit fullscreen mode

lib/domain/models/entities/message_model.dart

Next is to update the repository schema in the domain layer

import '../models/entities/message_model.dart';

...
abstract class RemoteRepository {

  ...

  Future<DataState<GenericResponse<MessageModel>>> conversationStart();

  Future<DataState<GenericResponse<MessageModel>>> conversationSend({
    required String? text,
    required int? conversation,
  });
}
Enter fullscreen mode Exit fullscreen mode

lib/domain/repositories/remote_repository.dart

Data layer

The first step in the data layer is to add the requests to the data source

...
  @GET('/conversation/')
  Future<HttpResponse<GenericResponse<MessageModel>>> conversationStart({
    @Header("Authorization") String? token,
    @Header("Accept-Language") String? lang,
  });

  @POST('/conversation/')
  Future<HttpResponse<GenericResponse<MessageModel>>> conversationSend({
    @Body() MessageRequest? request,
    @Header("Authorization") String? token,
    @Header("Accept-Language") String? lang,
  });
...
Enter fullscreen mode Exit fullscreen mode

lib/data/sources/remote_datasource.dart

PS. Don't forget to issue dart run build_runner build to generate the data source code.

Next is to implement the repository schema

...
  @override
  Future<DataState<GenericResponse<MessageModel>>> conversationSend({
    String? text,
    int? conversation
  }) => getStateOf(
    request: () => remoteDatasource.conversationSend(
      request: MessageRequest(
        text: text,
        conversation: conversation
      ),
      lang: "en",
      token: "Bearer ${preferencesRepository.getToken()}",
    ),
  );

  @override
  Future<DataState<GenericResponse<MessageModel>>> conversationStart() => getStateOf(
    request: () => remoteDatasource.conversationStart(
      lang: "en",
      token: "Bearer ${preferencesRepository.getToken()}",
    ),
  );
...
Enter fullscreen mode Exit fullscreen mode

lib/data/repositories/remote_repository_impl.dart

nice and easy, let's begin with the serious work

Chat screen events and states

Back to our home screen, let's start working on the bloc logic, as always, when working with bloc, we start with the events, then the states and finally the bloc logic itself

The events

We already have two events, one for TTS and another for STT, we need two extra events, one for starting a conversation, and another one for sending a text.

part of 'home_bloc.dart';

@immutable
sealed class HomeEvent {
  final String? text;
  const HomeEvent({this.text});
}

class HomeSTTEvent extends HomeEvent {}

class HomeTTSEvent extends HomeEvent {
  const HomeTTSEvent({super.text});
}

class HomeStartEvent extends HomeEvent {}
class HomeSendEvent extends HomeEvent {
  const HomeSendEvent({super.text});
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_event.dart

The States

With the events ready, we move to the states, we already have states for listening, reading, and errors. but we also need states for loading and receiving text.

part of 'home_bloc.dart';

@immutable
sealed class HomeState {
  final String? text;
  final String? error;
  const HomeState({
    this.text,
    this.error,
  });
}

final class HomeInitial extends HomeState {}
final class HomeListeningState extends HomeState {}
final class HomeReadingState extends HomeState {}
final class HomeErrorState extends HomeState {
  const HomeErrorState({super.error});
}
final class HomeSTTState extends HomeState {
  const HomeSTTState({super.text});
}

final class HomeLoadingState extends HomeState {}
final class HomeReceiveState extends HomeState {
  const HomeReceiveState({super.text});
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_state.dart

With our states and events ready, it is time to connect them in the bloc file

Bloc logic

In order to make the conversation requests, we are going to need access to the remote_repository, so let's add it to the bloc dependencies

...
  final RemoteRepository repository;

  HomeBloc(this.stt, this.tts, this.repository) : super(HomeInitial()) {
...
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_bloc.dart

We need to pass the new dependency from the main app file, just update the current bloc provider to provide it

...
-          BlocProvider(create: (context)=>HomeBloc(locator(), locator())),
+          BlocProvider(create: (context)=>HomeBloc(locator(), locator(), locator())),
...
Enter fullscreen mode Exit fullscreen mode

lib/main.dart

Next step is to handle our newly added events, let's start with HomeStartEvent, when we receive it, we should make a start conversation request

import '../../../utils/data_state.dart';
import '../../../utils/dio_exception_extension.dart';
...
  HomeBloc(this.stt, this.tts, this.repository) : super(HomeInitial()) {
  int? conversationId = 0;
...
  HomeBloc(this.stt, this.tts, this.repository) : super(HomeInitial()) {
    ...
    on<HomeStartEvent>(handleStartEvent);
  }
...
  FutureOr<void> handleStartEvent(
      HomeStartEvent event,
      Emitter<HomeState> emit,
  ) async {
    emit(HomeLoadingState());

    final response = await repository.conversationStart();

    if (response is DataSuccess) {
      conversationId = response.data?.data?.conversation;

      emit(HomeReceiveState(
        text: response.data?.data?.text,
      ));
    } else {
      emit(HomeErrorState(error: response.error?.getErrorMessage(),));
    }
  }
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_bloc.dart

when receiving the event, we are making the start conversation request, the conversation id is saved in the bloc instance, then we emit the response text through HomeReceiveState

now for handling the send event

...
  HomeBloc(this.stt, this.tts, this.repository) : super(HomeInitial()) {
...
    on<HomeSendEvent>(handleSendEvent);
  }
...
  FutureOr<void> handleSendEvent(
      HomeSendEvent event,
      Emitter<HomeState> emit,
  ) async {
    emit(HomeLoadingState());

    recognizedText = "";

    final response = await repository.conversationSend(
      text: event.text ?? "",
      conversation: conversationId ?? 0,
    );

    if (response is DataSuccess) {
      emit(HomeReceiveState(
        text: response.data?.data?.text,
      ));

    } else if (response is DataFailed) {
      emit(HomeErrorState(
        error: response.error?.getErrorMessage(),
      ));
    }

  }

Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_bloc.dart

We are cleaning the recognized text (in case of using STT by the user) then making the conversation send request, and passing the response text through HomeReceiveState to the UI
it is time to move the home screen itself

Home screen

We are working on the last version of Home screen after the TTS and STT article and the hooks article. We already have text field, microphone button, and a bloc listener, the full code look like

import 'package:alive_diary_app/config/dependencies.dart';
import 'package:alive_diary_app/config/router/app_router.dart';
import 'package:alive_diary_app/domain/repositories/preferences_repository.dart';
import 'package:alive_diary_app/presentation/widgets/layout_widget.dart';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'home_bloc.dart';


@RoutePage()
class HomeScreen extends HookWidget {
  const HomeScreen({super.key, this.title});

  final String? title;

  @override
  Widget build(BuildContext context) {
    final bloc = BlocProvider.of<HomeBloc>(context);

    final isLoading = useState(false);
    final speechText = useState("");
    final canWrite = useState<bool?>(null);
    final textController = useTextEditingController();

    AnimationController animationController = useAnimationController(
        duration: const Duration(seconds: 4),
        initialValue: 0,
    );

    void showText(String? text) async {
      await Future.delayed(const Duration(milliseconds: 100));
      SystemChannels.textInput.invokeMethod('TextInput.hide');
      animationController.animateBack(0, duration: const Duration(seconds: 1));
      await Future.delayed(const Duration(seconds: 2));
      textController.text = text ?? "";
      animationController.forward();
    }

    void clearText() async {
      animationController.animateBack(0, duration: const Duration(seconds: 1));
      await Future.delayed(const Duration(seconds: 2));
      textController.text = "";
      animationController.forward();
    }

    return BlocListener<HomeBloc, HomeState>(
      listener: (context, state) {
        isLoading.value = state is HomeListeningState;

        if (state is HomeSTTState) {
          speechText.value = state.text ?? "";
        }

      },
      child: LayoutWidget(
        title: 'home'.tr(),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout, color: Colors.black,),
            onPressed: (){
              locator<PreferencesRepository>().logout();
              appRouter.replaceAll([LoginRoute()]);
            },
          )
        ],
        floatingActionButton: FloatingActionButton(
          tooltip: 'Listen',
          child: Icon(
            Icons.keyboard_voice_outlined,
            color: isLoading.value ? Colors.green : Colors.blue,
          ),
          onPressed: () => bloc.add(HomeSTTEvent()),
        ),
        child:Container(
          height: double.infinity,
          padding: const EdgeInsets.symmetric(horizontal: 15),
          decoration: const BoxDecoration(
            image: DecorationImage(
              image: AssetImage("assets/images/paper_bg.jpg"),
              fit: BoxFit.cover,
            ),
          ),
          child: FadeTransition(
            opacity: animationController,
            child: TextField(
              decoration: const InputDecoration(border: InputBorder.none),
              // focusNode: textNode,
              cursorHeight: 35,
              style: GoogleFonts.caveat(
                fontSize: 30,
                color: Colors.black,
              ),
              keyboardType: TextInputType.multiline,
              textInputAction: TextInputAction.send,
              controller: textController,
              maxLines: null,
              onTap: () async {

                if (canWrite.value == null) {
                  showText("How was your day?");
                  canWrite.value = true;
                } else if (canWrite.value == true) {
                  clearText();
                }

              },
              onSubmitted: (text) async {
                showText("Tell me more");
              },
            ),
          ),
        ),
      ),

    );
  }
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_screen.dart

when clicking on the text field for the first time, a conversation start request should be made, and when receiving the response, it should be shown using the HomeReceiveState

...
    return BlocListener<HomeBloc, HomeState>(
      listener: (context, state) {

        if (state is HomeReceiveState) {
          showText(state.text);
          canWrite.value = true;
        }

...

          child: FadeTransition(
            opacity: animationController,
            child: TextField(
...
              onTap: () async {

                if (canWrite.value == null) {
                  bloc.add(HomeStartEvent()); // new
                } else if (canWrite.value == true) {
                  clearText();
                }

              },
              onSubmitted: (text) async {
                bloc.add(HomeSendEvent(text: text)); // new
              },
            ),
          ),
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_screen.dart

We should be able to test it now, clicking on the text box should show the server response, clicking on it again should fade away the text and allow user to type the message, hitting keyboard submit should send the message back to the server and show its response

first response typing message receiving a response
first response typing message receiving response

Now for the reader, it is kinda easy from now, when receiving the server response, all we need to do is to add a TTS event

...
    void showText(String? text) async {
      await Future.delayed(const Duration(milliseconds: 100));
      SystemChannels.textInput.invokeMethod('TextInput.hide');
      animationController.animateBack(0, duration: const Duration(seconds: 1));
      await Future.delayed(const Duration(seconds: 2));
      textController.text = text ?? "";
      animationController.forward();
      bloc.add(HomeTTSEvent(text: text));
    }
...
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/home/home_screen.dart

That is it for this article. I know it is too much to handle, and too many details to follow, just treat it as an optional if you didn't follow previous ones.

This app is finished now, we have fully implemented the main feature with this article. I'll start working on apps I want to publish on the store next, so, as always

Stay tuned 😎

ACI image

ACI.dev: Best Open-Source Composio Alternative (AI Agent Tooling)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Star our GitHub!

Top comments (2)

Collapse
 
khang_lngc profile image
Khang Lương Đức

Mixing flutter_bloc and flutter_hooks in the same widget is an ineffective architectural choice

Using flutter_bloc and flutter_hooks together in the same widget — as shown in the example — results in a hybrid and inconsistent architecture that can lead to increased maintenance costs, testing difficulties, and reduced code clarity.

At their core, these two libraries follow fundamentally opposing state management philosophies:

  • flutter_bloc embraces externalized state management, where all logic and state are separated from the UI and handled through Bloc, ensuring a clear and maintainable data flow.
  • flutter_hooks, on the other hand, promotes collocated state management, where UI logic is kept close to the widget tree, favoring brevity and simplicity — often more suitable for small, self-contained widgets.

Mixing both leads to a conceptual conflict:

  • You're using BlocListener and dispatching events via bloc.add(...), which aligns with centralized state management.
  • At the same time, you're relying on useState, useTextEditingController, and useAnimationController — managing state and controllers locally within the widget.

This results in two parallel sources of truth: one from the Bloc and another from the widget’s internal state via Hooks. The outcome is a confusing mix of centralized and decentralized state, which undermines architectural clarity, increases the risk of hidden bugs, and complicates testing.

If you're building a production-level app or aiming for scalable architecture, it's best to commit to a single, consistent approach. Stick with flutter_bloc if you want structured, testable state management — or go fully with flutter_hooks (or hooks_riverpod) if you prefer colocated logic for simpler use cases.

Collapse
 
saad4software profile image
Saad Alkentar

Thanks for your feedback Khang, I do agree with you that relying on two sources of truth is not a good approach, and can be confusing sometimes. But on the other hand, micro state management with bloc can complicate the logic and code, especially with tiny states like animation. Bloc builder can render screen widgets unreadable if heavily used.

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping