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 (0)