fix: repair the booking flow end to end
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run

Verified the real contracts against the backend before changing anything.

Chat (three defects, one root cause):
- every chat endpoint is keyed by the chat id, not the service id. The app
  called POST /chat/start, threw away the id it returned and kept using the
  service id, so every later request 404'd.
- messages arrive as {data, meta}; reading the body as a bare list threw and
  surfaced as an empty conversation.
- the bloc created the chat and then never emitted ChatLoaded (the else hung
  off `if (chat == null)`), leaving a permanent spinner. Sending a message
  emitted nothing at all, so it vanished until reopening.
Messages now render optimistically and roll back if the send fails, and the
screen distinguishes "loading" from "could not open" with a retry.

Appointments:
- a null range1_hour2 parsed as 00:00, so new appointments were born
  "Caducado" and every action was hidden. It now falls back to the start time.
- service requests validate the HTTP status and tolerate an empty body: a 4xx
  was treated as success and a 204 as failure.
- creating a service with no id in the response no longer reports success and
  navigates to a service that does not exist.
- dispatching LoadService from build() looped forever on failure; the three
  detail screens now load once and offer a retry.

Ratings:
- both sides read userScored, so only one of the two could ever rate. The
  client side now reads professionalScored.
- the screen closed before the request finished, killing the provider mid
  flight while addComment swallowed every error. It now waits for confirmation.
- score/reputation parsing tolerates integers and numeric strings instead of
  emptying the review list.

Also: guarded map lookups in ScoreBloc, a nullable name in the search list,
and error states with retry where a failure used to shimmer forever.

Verified against the backend: `accepted` is the status the API expects, so the
suspected spelling bug was a false alarm and was left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 20:47:54 -05:00
co-authored by Claude Opus 5
parent 8c6e2ae024
commit 389f876cfa
17 changed files with 444 additions and 153 deletions
+41 -27
View File
@@ -10,6 +10,10 @@ part 'chat_state.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ApiChatRepository _chatRepository;
/// Resolved once per conversation. Every chat endpoint is keyed by the chat
/// id, which the app used to confuse with the service id.
String? _chatId;
ChatBloc({
required ApiChatRepository chatRepository,
}) : _chatRepository = chatRepository,
@@ -19,32 +23,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async {
bool autoCreate = true;
emit(ChatLoading());
try {
emit(ChatLoading());
Stream<ChatEntity?> chatStream = _chatRepository.getChatById(
event.serviceId,
);
await for (var chat in chatStream) {
if (chat == null) {
if (autoCreate) {
chat = await _chatRepository.createNewChat(
event.serviceId,
event.userId,
event.professionalId,
);
autoCreate = false;
} else {
emit(ChatFailure());
}
} else {
emit(ChatLoaded(chat: chat));
}
}
final chat = await _chatRepository.loadConversation(event.professionalId);
_chatId = chat.id;
emit(ChatLoaded(chat: chat));
} catch (e) {
log(e.toString());
emit(ChatFailure());
@@ -53,11 +36,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _onSendMessageEvent(
SendMessageEvent event, Emitter<ChatState> emit) async {
final current = state;
// Show the message immediately; the reload below reconciles with the
// backend. Previously nothing was emitted at all and the message vanished.
if (current is ChatLoaded) {
emit(ChatLoaded(
chat: ChatEntity(
id: current.chat.id,
userId: current.chat.userId,
professionalId: current.chat.professionalId,
messages: [...current.chat.messages, event.message],
),
));
}
try {
await _chatRepository.sendMessage(event.serviceId, event.message);
final chatId = _chatId;
if (chatId == null || chatId.isEmpty) {
throw Exception('El chat aún no está abierto');
}
await _chatRepository.sendMessage(chatId, event.message);
final messages = await _chatRepository.fetchMessages(chatId);
emit(ChatLoaded(
chat: ChatEntity(
id: chatId,
userId: current is ChatLoaded ? current.chat.userId : '',
professionalId:
current is ChatLoaded ? current.chat.professionalId : '',
messages: messages,
),
));
} catch (e) {
log(e.toString());
emit(ChatFailure());
// Roll the optimistic message back so nobody believes it was delivered.
if (current is ChatLoaded) emit(ChatLoaded(chat: current.chat));
emit(SendMessageFailure());
}
}
}
+4
View File
@@ -20,4 +20,8 @@ class ChatLoaded extends ChatState {
List<Object> get props => [chat];
}
/// The conversation could not be opened — the screen has nothing to show.
class ChatFailure extends ChatState {}
/// The conversation is on screen but one message failed to send.
class SendMessageFailure extends ChatState {}
@@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,6 +25,18 @@ class ProfessionalListBloc
void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async {
emit(ProfessionalListLoading());
try {
await _fetch(event, emit);
} catch (e) {
// Without this the bloc stayed in Loading and the screen shimmered
// forever on any network hiccup.
log(e.toString());
emit(ProfessionalListFailure());
}
}
Future<void> _fetch(ProfessionalListFetch event,
Emitter<ProfessionalListState> emit) async {
final users = await _userRepository.getUsersProfessionalActive();
final hasSearchParams = event.search != null ||
@@ -11,6 +11,9 @@ class ProfessionalListInitial extends ProfessionalListState {}
class ProfessionalListLoading extends ProfessionalListState {}
/// The list could not be loaded — distinct from "nobody matched".
class ProfessionalListFailure extends ProfessionalListState {}
class ProfessionalListSuccess extends ProfessionalListState {
final List<UserProfessional> users;
+9 -2
View File
@@ -38,10 +38,13 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
}
void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async {
emit(ScoreSending());
try {
await _scoreRepository.addComment(event.comment);
emit(ScoreSent());
} catch (e) {
log(e.toString());
emit(ScoreSendFailure());
}
}
@@ -62,7 +65,9 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
final usersDir = {for (var e in users) e.id: e};
final scoresInfo = scores.map((e) {
final scoresInfo = scores
.where((e) => usersDir.containsKey(e.authorId))
.map((e) {
return ScoreInfoUI(
score: e,
user: usersDir[e.authorId]!,
@@ -99,7 +104,9 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
final usersDir = {for (var e in users) e.id: e};
final scoresInfo = scores.map((e) {
final scoresInfo = scores
.where((e) => usersDir.containsKey(e.authorId))
.map((e) {
return ScoreInfoUI(
score: e,
user: usersDir[e.authorId]!,
+9
View File
@@ -13,6 +13,15 @@ class ScoreFailure extends ScoreState {}
class ScoreLoading extends ScoreState {}
/// Rating submission is in flight.
class ScoreSending extends ScoreState {}
/// Rating stored by the backend.
class ScoreSent extends ScoreState {}
/// Rating could not be stored — it must not be reported as sent.
class ScoreSendFailure extends ScoreState {}
class ScoreSuccess extends ScoreState {
final ReputationEntity reputation;
const ScoreSuccess(this.reputation);