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
- The user clicks on the text field
- The app closes the keyboard (for the first time)
- The app sends a conversation request to the AI
- The app fades in the server response
- The user clicks on the text field to start writing the response
- The app fades out the server response
- The user clicks the send button from the keyboard
- The app sends the message to the AI
- 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;
}
}
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,
});
}
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,
});
...
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()}",
),
);
...
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});
}
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});
}
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()) {
...
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())),
...
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(),));
}
}
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(),
));
}
}
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");
},
),
),
),
),
);
}
}
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
},
),
),
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 |
---|---|---|
![]() |
![]() |
![]() |
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));
}
...
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 😎
Top comments (2)
Mixing
flutter_bloc
andflutter_hooks
in the same widget is an ineffective architectural choiceUsing
flutter_bloc
andflutter_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:
BlocListener
and dispatching events viabloc.add(...)
, which aligns with centralized state management.useState
,useTextEditingController
, anduseAnimationController
— 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 withflutter_hooks
(orhooks_riverpod
) if you prefer colocated logic for simpler use cases.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.