fix: repair the booking flow end to end
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:
co-authored by
Claude Opus 5
parent
8c6e2ae024
commit
389f876cfa
@@ -10,6 +10,10 @@ part 'chat_state.dart';
|
|||||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||||
final ApiChatRepository _chatRepository;
|
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({
|
ChatBloc({
|
||||||
required ApiChatRepository chatRepository,
|
required ApiChatRepository chatRepository,
|
||||||
}) : _chatRepository = chatRepository,
|
}) : _chatRepository = chatRepository,
|
||||||
@@ -19,32 +23,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async {
|
void _onLoadChatEvent(LoadChatEvent event, Emitter<ChatState> emit) async {
|
||||||
bool autoCreate = true;
|
emit(ChatLoading());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(ChatLoading());
|
final chat = await _chatRepository.loadConversation(event.professionalId);
|
||||||
|
_chatId = chat.id;
|
||||||
Stream<ChatEntity?> chatStream = _chatRepository.getChatById(
|
emit(ChatLoaded(chat: chat));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(ChatFailure());
|
emit(ChatFailure());
|
||||||
@@ -53,11 +36,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
|
|
||||||
void _onSendMessageEvent(
|
void _onSendMessageEvent(
|
||||||
SendMessageEvent event, Emitter<ChatState> emit) async {
|
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 {
|
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) {
|
} catch (e) {
|
||||||
log(e.toString());
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ class ChatLoaded extends ChatState {
|
|||||||
List<Object> get props => [chat];
|
List<Object> get props => [chat];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The conversation could not be opened — the screen has nothing to show.
|
||||||
class ChatFailure extends ChatState {}
|
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 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -24,6 +25,18 @@ class ProfessionalListBloc
|
|||||||
|
|
||||||
void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async {
|
void _onProfessionalListFetch(ProfessionalListFetch event, Emitter<ProfessionalListState> emit) async {
|
||||||
emit(ProfessionalListLoading());
|
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 users = await _userRepository.getUsersProfessionalActive();
|
||||||
|
|
||||||
final hasSearchParams = event.search != null ||
|
final hasSearchParams = event.search != null ||
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class ProfessionalListInitial extends ProfessionalListState {}
|
|||||||
|
|
||||||
class ProfessionalListLoading extends ProfessionalListState {}
|
class ProfessionalListLoading extends ProfessionalListState {}
|
||||||
|
|
||||||
|
/// The list could not be loaded — distinct from "nobody matched".
|
||||||
|
class ProfessionalListFailure extends ProfessionalListState {}
|
||||||
|
|
||||||
class ProfessionalListSuccess extends ProfessionalListState {
|
class ProfessionalListSuccess extends ProfessionalListState {
|
||||||
final List<UserProfessional> users;
|
final List<UserProfessional> users;
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ class ScoreBloc extends Bloc<ScoreEvent, ScoreState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async {
|
void _onSendScoreEvent(SendScoreEvent event, Emitter<ScoreState> emit) async {
|
||||||
|
emit(ScoreSending());
|
||||||
try {
|
try {
|
||||||
await _scoreRepository.addComment(event.comment);
|
await _scoreRepository.addComment(event.comment);
|
||||||
|
emit(ScoreSent());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
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 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(
|
return ScoreInfoUI(
|
||||||
score: e,
|
score: e,
|
||||||
user: usersDir[e.authorId]!,
|
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 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(
|
return ScoreInfoUI(
|
||||||
score: e,
|
score: e,
|
||||||
user: usersDir[e.authorId]!,
|
user: usersDir[e.authorId]!,
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ class ScoreFailure extends ScoreState {}
|
|||||||
|
|
||||||
class ScoreLoading 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 {
|
class ScoreSuccess extends ScoreState {
|
||||||
final ReputationEntity reputation;
|
final ReputationEntity reputation;
|
||||||
const ScoreSuccess(this.reputation);
|
const ScoreSuccess(this.reputation);
|
||||||
|
|||||||
@@ -34,19 +34,24 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
chatBloc = Injector.appInstance.get<ChatBloc>();
|
chatBloc = Injector.appInstance.get<ChatBloc>();
|
||||||
|
|
||||||
chatBloc.add(LoadChatEvent(
|
_loadChat();
|
||||||
serviceId: widget.service.id!,
|
|
||||||
userId: widget.service.userId,
|
|
||||||
professionalId: widget.service.professionalId,
|
|
||||||
));
|
|
||||||
|
|
||||||
_getUserAndProfessionalInfo(widget.service).then((userInfo) {
|
_getUserAndProfessionalInfo(widget.service).then((userInfo) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_userInfo = userInfo;
|
_userInfo = userInfo;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _loadChat() {
|
||||||
|
chatBloc.add(LoadChatEvent(
|
||||||
|
serviceId: widget.service.id ?? '',
|
||||||
|
userId: widget.service.userId,
|
||||||
|
professionalId: widget.service.professionalId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
@@ -62,7 +67,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
body: BlocProvider(
|
body: BlocProvider(
|
||||||
create: (context) => chatBloc,
|
create: (context) => chatBloc,
|
||||||
child: BlocBuilder<ChatBloc, ChatState>(
|
child: BlocConsumer<ChatBloc, ChatState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is SendMessageFailure) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('No se pudo enviar el mensaje')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is ChatLoaded) {
|
if (state is ChatLoaded) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -327,6 +341,29 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state is ChatFailure) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chat_bubble_outline,
|
||||||
|
size: 40, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No se pudo abrir la conversación',
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _loadChat,
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -114,11 +114,19 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
|||||||
|
|
||||||
void _loadProfessions() {
|
void _loadProfessions() {
|
||||||
professionRepository.getProfessions().then((Professions element) {
|
professionRepository.getProfessions().then((Professions element) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_professions = element.professions;
|
_professions = element.professions;
|
||||||
_filteredProfessions = _professions;
|
_filteredProfessions = _professions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
// Without this the shimmer never stopped and the dropdown stayed empty.
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No se pudieron cargar las profesiones')),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +246,33 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_body(ProfessionalListState state) {
|
_body(ProfessionalListState state) {
|
||||||
|
if (state is ProfessionalListFailure) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No se pudo cargar la lista', textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => bloc.add(const ProfessionalListFetch()),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state is ProfessionalListSuccess) {
|
if (state is ProfessionalListSuccess) {
|
||||||
final List<UserProfessional> filteredUsers;
|
final List<UserProfessional> filteredUsers;
|
||||||
|
|
||||||
// TODO: encaso de filtro dañado
|
// TODO: encaso de filtro dañado
|
||||||
// if (_searchController.text.isNotEmpty) {
|
// if (_searchController.text.isNotEmpty) {
|
||||||
filteredUsers = state.users
|
filteredUsers = state.users
|
||||||
.where((element) => removeDiacritics(element.myUser.name!)
|
.where((element) => removeDiacritics(element.myUser.name ?? '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.contains(removeDiacritics(_searchController.text.toLowerCase())))
|
.contains(removeDiacritics(_searchController.text.toLowerCase())))
|
||||||
.where((user) =>
|
.where((user) =>
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
|||||||
late Future<List<dynamic>> _userInfoFuture;
|
late Future<List<dynamic>> _userInfoFuture;
|
||||||
late bool isProfessional;
|
late bool isProfessional;
|
||||||
late String userId;
|
late String userId;
|
||||||
|
bool _isSending = false;
|
||||||
|
|
||||||
|
/// True when the person rating is the client (they rate the professional).
|
||||||
|
bool get isUser =>
|
||||||
|
widget.service.userId == (ApiUserRepository.currentUserId ?? '');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -52,7 +57,38 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (_) => Injector.appInstance.get<ScoreBloc>(),
|
create: (_) => Injector.appInstance.get<ScoreBloc>(),
|
||||||
child: Scaffold(
|
child: BlocListener<ScoreBloc, ScoreState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is ScoreSending) {
|
||||||
|
setState(() => _isSending = true);
|
||||||
|
} else if (state is ScoreSent) {
|
||||||
|
setState(() => _isSending = false);
|
||||||
|
// Only now is the rating actually stored, so only now do we mark
|
||||||
|
// the service as scored and leave the screen.
|
||||||
|
if (isUser) {
|
||||||
|
context
|
||||||
|
.read<ServiceBloc>()
|
||||||
|
.add(UpdateProfessionalScored(widget.service.id!));
|
||||||
|
} else {
|
||||||
|
context
|
||||||
|
.read<ServiceBloc>()
|
||||||
|
.add(UpdateUserScored(widget.service.id!));
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('¡Gracias por tu calificación!')),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else if (state is ScoreSendFailure) {
|
||||||
|
setState(() => _isSending = false);
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No se pudo enviar tu calificación. Inténtalo de nuevo.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
backgroundColor: context.bg,
|
backgroundColor: context.bg,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Calificar servicio'),
|
title: const Text('Calificar servicio'),
|
||||||
@@ -108,6 +144,7 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,9 +329,9 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _isSending
|
||||||
final isUser = widget.service.userId ==
|
? null
|
||||||
(ApiUserRepository.currentUserId ?? '');
|
: () {
|
||||||
final comment = CommentEntity(
|
final comment = CommentEntity(
|
||||||
serviceId: widget.service.id!,
|
serviceId: widget.service.id!,
|
||||||
authorId: ApiUserRepository.currentUserId ?? '',
|
authorId: ApiUserRepository.currentUserId ?? '',
|
||||||
@@ -306,18 +343,11 @@ class _ScoreScreenState extends State<ScoreScreen> {
|
|||||||
content: _commentController.text.trim(),
|
content: _commentController.text.trim(),
|
||||||
createdAt: DateTime.now().toIso8601String(),
|
createdAt: DateTime.now().toIso8601String(),
|
||||||
);
|
);
|
||||||
|
// No pop here: closing the screen used to kill the BlocProvider
|
||||||
|
// while the request was still in flight, so a failed rating looked
|
||||||
|
// exactly like a saved one. The listener closes it on success.
|
||||||
BlocProvider.of<ScoreBloc>(context)
|
BlocProvider.of<ScoreBloc>(context)
|
||||||
.add(SendScoreEvent(comment: comment));
|
.add(SendScoreEvent(comment: comment));
|
||||||
if (isUser) {
|
|
||||||
context
|
|
||||||
.read<ServiceBloc>()
|
|
||||||
.add(UpdateProfessionalScored(widget.service.id!));
|
|
||||||
} else {
|
|
||||||
context
|
|
||||||
.read<ServiceBloc>()
|
|
||||||
.add(UpdateUserScored(widget.service.id!));
|
|
||||||
}
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.send_rounded, size: 18),
|
icon: const Icon(Icons.send_rounded, size: 18),
|
||||||
label: const Text('Enviar calificación',
|
label: const Text('Enviar calificación',
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class _ProfessionalServiceScreenState
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ServiceBloc>(
|
return BlocProvider<ServiceBloc>(
|
||||||
create: (_) => Injector.appInstance.get<ServiceBloc>(),
|
create: (_) => Injector.appInstance.get<ServiceBloc>()
|
||||||
|
..add(LoadService(widget.serviceId)),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: context.bg,
|
backgroundColor: context.bg,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -123,9 +124,9 @@ class _ProfessionalServiceScreenState
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if (state is CreateServiceFailure) {
|
||||||
|
return _loadErrorState(context);
|
||||||
} else {
|
} else {
|
||||||
BlocProvider.of<ServiceBloc>(context)
|
|
||||||
.add(LoadService(widget.serviceId));
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -134,6 +135,32 @@ class _ProfessionalServiceScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatching LoadService from build() turned any failure into an endless
|
||||||
|
/// request loop: fail -> rebuild -> request -> fail. Errors now get an
|
||||||
|
/// explicit retry instead of a permanent spinner.
|
||||||
|
Widget _loadErrorState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No se pudo cargar el servicio',
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => BlocProvider.of<ServiceBloc>(context)
|
||||||
|
.add(LoadService(widget.serviceId)),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _headerCard(
|
Widget _headerCard(
|
||||||
BuildContext context, ServiceEntity service, MyUser user) {
|
BuildContext context, ServiceEntity service, MyUser user) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ServiceBloc>(
|
return BlocProvider<ServiceBloc>(
|
||||||
create: (_) => Injector.appInstance.get<ServiceBloc>(),
|
create: (_) => Injector.appInstance.get<ServiceBloc>()
|
||||||
|
..add(LoadService(widget.serviceId)),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: context.bg,
|
backgroundColor: context.bg,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -126,9 +127,9 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if (state is CreateServiceFailure) {
|
||||||
|
return _loadErrorState(context);
|
||||||
} else {
|
} else {
|
||||||
BlocProvider.of<ServiceBloc>(context)
|
|
||||||
.add(LoadService(widget.serviceId));
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -137,6 +138,32 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatching LoadService from build() turned any failure into an endless
|
||||||
|
/// request loop: fail -> rebuild -> request -> fail. Errors now get an
|
||||||
|
/// explicit retry instead of a permanent spinner.
|
||||||
|
Widget _loadErrorState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No se pudo cargar el servicio',
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => BlocProvider.of<ServiceBloc>(context)
|
||||||
|
.add(LoadService(widget.serviceId)),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _headerCard(BuildContext context, ServiceEntity service, MyUser user,
|
Widget _headerCard(BuildContext context, ServiceEntity service, MyUser user,
|
||||||
ProfessionalEntity professional) {
|
ProfessionalEntity professional) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -472,7 +499,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
subtitle: 'El servicio fue rechazado',
|
subtitle: 'El servicio fue rechazado',
|
||||||
);
|
);
|
||||||
case ServiceStatus.completed:
|
case ServiceStatus.completed:
|
||||||
if (service.userScored == false) {
|
if (service.professionalScored == false) {
|
||||||
return _StatusCard(
|
return _StatusCard(
|
||||||
color: const Color(0xFF16A34A),
|
color: const Color(0xFF16A34A),
|
||||||
icon: Icons.star_outline_rounded,
|
icon: Icons.star_outline_rounded,
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ServiceBloc>(
|
return BlocProvider<ServiceBloc>(
|
||||||
create: (context) => Injector.appInstance.get<ServiceBloc>(),
|
create: (context) => Injector.appInstance.get<ServiceBloc>()
|
||||||
|
..add(LoadService(widget.serviceId)),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Servicio'),
|
title: const Text('Servicio'),
|
||||||
@@ -275,12 +276,10 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if (state is CreateServiceFailure) {
|
||||||
|
return _loadErrorState(context);
|
||||||
} else {
|
} else {
|
||||||
BlocProvider.of<ServiceBloc>(context)
|
return const Center(child: CircularProgressIndicator());
|
||||||
.add(LoadService(widget.serviceId));
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -288,6 +287,32 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatching LoadService from build() turned any failure into an endless
|
||||||
|
/// request loop: fail -> rebuild -> request -> fail. Errors now get an
|
||||||
|
/// explicit retry instead of a permanent spinner.
|
||||||
|
Widget _loadErrorState(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('No se pudo cargar el servicio',
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => BlocProvider.of<ServiceBloc>(context)
|
||||||
|
.add(LoadService(widget.serviceId)),
|
||||||
|
child: const Text('Reintentar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget customMessageStatus(ServiceEntity service) {
|
Widget customMessageStatus(ServiceEntity service) {
|
||||||
DateTime serviceDate = DateTime.parse(service.day);
|
DateTime serviceDate = DateTime.parse(service.day);
|
||||||
DateTime now = DateTime.now();
|
DateTime now = DateTime.now();
|
||||||
@@ -404,7 +429,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (service.status == ServiceStatus.completed &&
|
if (service.status == ServiceStatus.completed &&
|
||||||
service.userScored == false) {
|
service.professionalScored == false) {
|
||||||
return Stack(
|
return Stack(
|
||||||
alignment: AlignmentDirectional.topCenter,
|
alignment: AlignmentDirectional.topCenter,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -479,7 +504,7 @@ class _UserServiceScreenState extends State<UserServiceScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (service.status == ServiceStatus.completed &&
|
if (service.status == ServiceStatus.completed &&
|
||||||
service.userScored == true) {
|
service.professionalScored == true) {
|
||||||
return Stack(
|
return Stack(
|
||||||
alignment: AlignmentDirectional.topCenter,
|
alignment: AlignmentDirectional.topCenter,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ class ApiChatRepository {
|
|||||||
|
|
||||||
ChatEntity _chatFromApi(Map<String, dynamic> json) {
|
ChatEntity _chatFromApi(Map<String, dynamic> json) {
|
||||||
final rawMessages = json['messages'] as List? ?? [];
|
final rawMessages = json['messages'] as List? ?? [];
|
||||||
|
// _msgFromApi, not MessageEntity.fromDocument: the backend sends
|
||||||
|
// `sender_id` while fromDocument hard-casts `owner_id`.
|
||||||
final messages = rawMessages
|
final messages = rawMessages
|
||||||
.map((m) => MessageEntity.fromDocument(m as Map<String, dynamic>))
|
.map((m) => _msgFromApi(m as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
return ChatEntity(
|
return ChatEntity(
|
||||||
id: json['id']?.toString(),
|
id: json['id']?.toString(),
|
||||||
@@ -52,59 +54,68 @@ class ApiChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or create a chat session. Maps to POST /chat/start/:professionalUserId.
|
/// Opens the conversation with a professional, creating it if needed.
|
||||||
/// [chatId] here is used as the professional's userId for the REST call.
|
///
|
||||||
Future<ChatEntity> createNewChat(
|
/// `POST /chat/start/:professionalUserId` is idempotent: calling it again
|
||||||
String chatId, String userId, String professionalId) async {
|
/// returns the same chat. The returned `id` is the **chat** id, which is what
|
||||||
final res = await http.post(
|
/// every other chat endpoint is keyed by — not the service id.
|
||||||
Uri.parse('$_base/chat/start/$professionalId'),
|
Future<ChatEntity> startChat(String professionalUserId) async {
|
||||||
headers: await _headers(),
|
final res = await http
|
||||||
).timeout(_kHttpTimeout);
|
.post(
|
||||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
Uri.parse('$_base/chat/start/$professionalUserId'),
|
||||||
return _chatFromApi(data);
|
headers: await _headers(),
|
||||||
}
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
/// Streams a single chat by its ID. Fetches once and emits.
|
if (res.statusCode >= 400) {
|
||||||
Stream<ChatEntity?> getChatById(String chatId) {
|
throw Exception('No se pudo abrir el chat (${res.statusCode})');
|
||||||
final controller = StreamController<ChatEntity?>();
|
|
||||||
_fetchChat(chatId).then((chat) {
|
|
||||||
controller.add(chat);
|
|
||||||
controller.close();
|
|
||||||
}).catchError((e) {
|
|
||||||
controller.add(null);
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
return controller.stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ChatEntity?> _fetchChat(String chatId) async {
|
|
||||||
try {
|
|
||||||
// Try to get messages for this chat — if the chat exists it'll succeed
|
|
||||||
final res = await http.get(
|
|
||||||
Uri.parse('$_base/chat/$chatId/messages'),
|
|
||||||
headers: await _headers(),
|
|
||||||
).timeout(_kHttpTimeout);
|
|
||||||
if (res.statusCode == 404) return null;
|
|
||||||
final messages = jsonDecode(res.body) as List? ?? [];
|
|
||||||
return ChatEntity(
|
|
||||||
id: chatId,
|
|
||||||
userId: '',
|
|
||||||
professionalId: '',
|
|
||||||
messages: messages
|
|
||||||
.map((m) => _msgFromApi(m as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return _chatFromApi(jsonDecode(res.body) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String chatId, MessageEntity message) async {
|
/// Loads the conversation plus its messages, ready to render.
|
||||||
await http.post(
|
Future<ChatEntity> loadConversation(String professionalUserId) async {
|
||||||
Uri.parse('$_base/chat/$chatId/message'),
|
final chat = await startChat(professionalUserId);
|
||||||
headers: await _headers(),
|
final messages = await fetchMessages(chat.id ?? '');
|
||||||
body: jsonEncode({'content': message.content}),
|
return ChatEntity(
|
||||||
).timeout(_kHttpTimeout);
|
id: chat.id,
|
||||||
|
userId: chat.userId,
|
||||||
|
professionalId: chat.professionalId,
|
||||||
|
messages: messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages come back wrapped as `{ "data": [...], "meta": {...} }`.
|
||||||
|
/// Reading the body as a bare list threw and surfaced as an empty chat.
|
||||||
|
Future<List<MessageEntity>> fetchMessages(String chatId) async {
|
||||||
|
final res = await http
|
||||||
|
.get(
|
||||||
|
Uri.parse('$_base/chat/$chatId/messages'),
|
||||||
|
headers: await _headers(),
|
||||||
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw Exception('No se pudieron cargar los mensajes (${res.statusCode})');
|
||||||
|
}
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
final raw = body is Map ? (body['data'] as List? ?? []) : (body as List? ?? []);
|
||||||
|
return raw
|
||||||
|
.map((m) => _msgFromApi(m as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts a message and returns it as stored by the backend.
|
||||||
|
Future<MessageEntity> sendMessage(String chatId, MessageEntity message) async {
|
||||||
|
final res = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse('$_base/chat/$chatId/message'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode({'content': message.content}),
|
||||||
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw Exception('No se pudo enviar el mensaje (${res.statusCode})');
|
||||||
|
}
|
||||||
|
return _msgFromApi(jsonDecode(res.body) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all chats for the current user.
|
/// Get all chats for the current user.
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ class CommentEntity extends Equatable {
|
|||||||
|
|
||||||
static CommentEntity fromDocument(Map<String, dynamic> doc) {
|
static CommentEntity fromDocument(Map<String, dynamic> doc) {
|
||||||
return CommentEntity(
|
return CommentEntity(
|
||||||
authorId: doc['author_id'] as String,
|
// Tolerant on purpose: a whole-number score arrives as `5`, and
|
||||||
destinationId: doc['destination_id'] as String,
|
// Postgres numerics can arrive as strings. Hard casts here emptied the
|
||||||
serviceId: doc['service_id'] as String,
|
// whole review list, which then read as "no reviews yet".
|
||||||
content: doc['content'] as String,
|
authorId: doc['author_id']?.toString() ?? '',
|
||||||
score: doc['score'] as double,
|
destinationId: doc['destination_id']?.toString() ?? '',
|
||||||
isFromUser: doc['is_from_user'] as bool,
|
serviceId: doc['service_id']?.toString() ?? '',
|
||||||
|
content: doc['content']?.toString() ?? '',
|
||||||
|
score: double.tryParse(doc['score']?.toString() ?? '') ?? 0.0,
|
||||||
|
isFromUser: doc['is_from_user'] == true ||
|
||||||
|
doc['is_from_user']?.toString() == 'true',
|
||||||
createdAt: doc['created_at']?.toString() ?? DateTime.now().toIso8601String(),
|
createdAt: doc['created_at']?.toString() ?? DateTime.now().toIso8601String(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ class ReputationEntity extends Equatable {
|
|||||||
final averagePro = doc['average_pro'] ?? 0.0;
|
final averagePro = doc['average_pro'] ?? 0.0;
|
||||||
|
|
||||||
return ReputationEntity(
|
return ReputationEntity(
|
||||||
total: int.parse(total.toString()),
|
// COUNT can come back as "5.0000"; parse as double then truncate so a
|
||||||
average: double.parse(average.toString()),
|
// reputation of 0.0 never gets shown as if it were real.
|
||||||
totalPro: int.parse(totalPro.toString()),
|
total: (double.tryParse(total.toString()) ?? 0).toInt(),
|
||||||
averagePro: double.parse(averagePro.toString()),
|
average: double.tryParse(average.toString()) ?? 0.0,
|
||||||
|
totalPro: (double.tryParse(totalPro.toString()) ?? 0).toInt(),
|
||||||
|
averagePro: double.tryParse(averagePro.toString()) ?? 0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,19 +81,29 @@ class ApiScoreRepository {
|
|||||||
return raw.map((e) => CommentEntity.fromDocument(e as Map<String, dynamic>)).toList();
|
return raw.map((e) => CommentEntity.fromDocument(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Throws when the rating could not be stored.
|
||||||
|
///
|
||||||
|
/// This used to swallow every error in an empty catch, so a lost rating was
|
||||||
|
/// indistinguishable from a saved one — for the user *and* for us.
|
||||||
Future<void> addComment(CommentEntity comment) async {
|
Future<void> addComment(CommentEntity comment) async {
|
||||||
|
final res = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse('$_base/comments'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode({
|
||||||
|
'destination_id': comment.destinationId,
|
||||||
|
'service_id': comment.serviceId,
|
||||||
|
'content': comment.content,
|
||||||
|
'score': comment.score,
|
||||||
|
'is_from_user': comment.isFromUser,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw Exception('No se pudo guardar la calificación (${res.statusCode})');
|
||||||
|
}
|
||||||
|
// Refreshing reputation is a nicety; never fail the rating over it.
|
||||||
try {
|
try {
|
||||||
await http.post(
|
|
||||||
Uri.parse('$_base/comments'),
|
|
||||||
headers: await _headers(),
|
|
||||||
body: jsonEncode({
|
|
||||||
'destination_id': comment.destinationId,
|
|
||||||
'service_id': comment.serviceId,
|
|
||||||
'content': comment.content,
|
|
||||||
'score': comment.score,
|
|
||||||
'is_from_user': comment.isFromUser,
|
|
||||||
}),
|
|
||||||
).timeout(_kHttpTimeout);
|
|
||||||
await getReputationByUserId(comment.destinationId);
|
await getReputationByUserId(comment.destinationId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,27 +51,55 @@ class ApiServiceRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _get(String path) async {
|
/// Rejects error responses and tolerates an empty body.
|
||||||
final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_kHttpTimeout);
|
///
|
||||||
|
/// Neither used to happen: a 4xx was parsed as if it had succeeded, and a
|
||||||
|
/// 204 with no body made `jsonDecode` throw, so a change that *did* apply
|
||||||
|
/// was reported to the user as a failure.
|
||||||
|
dynamic _decode(http.Response res, String action) {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
String detail = '';
|
||||||
|
try {
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
if (body is Map && body['message'] != null) {
|
||||||
|
final m = body['message'];
|
||||||
|
detail = m is List ? m.join(', ') : m.toString();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
throw Exception(
|
||||||
|
'$action falló (${res.statusCode})${detail.isEmpty ? '' : ': $detail'}');
|
||||||
|
}
|
||||||
|
if (res.body.isEmpty) return null;
|
||||||
return jsonDecode(res.body);
|
return jsonDecode(res.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _get(String path) async {
|
||||||
|
final res = await http
|
||||||
|
.get(Uri.parse('$_base$path'), headers: await _headers())
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
return _decode(res, 'La consulta');
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> _post(String path, Map<String, dynamic> body) async {
|
Future<dynamic> _post(String path, Map<String, dynamic> body) async {
|
||||||
final res = await http.post(
|
final res = await http
|
||||||
Uri.parse('$_base$path'),
|
.post(
|
||||||
headers: await _headers(),
|
Uri.parse('$_base$path'),
|
||||||
body: jsonEncode(body),
|
headers: await _headers(),
|
||||||
).timeout(_kHttpTimeout);
|
body: jsonEncode(body),
|
||||||
return jsonDecode(res.body);
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
return _decode(res, 'La operación');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
|
Future<dynamic> _patch(String path, Map<String, dynamic> body) async {
|
||||||
final res = await http.patch(
|
final res = await http
|
||||||
Uri.parse('$_base$path'),
|
.patch(
|
||||||
headers: await _headers(),
|
Uri.parse('$_base$path'),
|
||||||
body: jsonEncode(body),
|
headers: await _headers(),
|
||||||
).timeout(_kHttpTimeout);
|
body: jsonEncode(body),
|
||||||
return jsonDecode(res.body);
|
)
|
||||||
|
.timeout(_kHttpTimeout);
|
||||||
|
return _decode(res, 'La actualización');
|
||||||
}
|
}
|
||||||
|
|
||||||
ServiceEntity _fromApi(Map<String, dynamic> json) {
|
ServiceEntity _fromApi(Map<String, dynamic> json) {
|
||||||
@@ -114,7 +142,13 @@ class ApiServiceRepository {
|
|||||||
createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(),
|
createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(),
|
||||||
description: json['description']?.toString() ?? '',
|
description: json['description']?.toString() ?? '',
|
||||||
range1Hour1: parseTime(json['range1_hour1']?.toString()),
|
range1Hour1: parseTime(json['range1_hour1']?.toString()),
|
||||||
range1Hour2: parseTime(json['range1_hour2']?.toString()),
|
// The backend often leaves range1_hour2 null. Defaulting it to 00:00
|
||||||
|
// made every service look like it had ended at midnight, so the UI
|
||||||
|
// marked brand new appointments as "Caducado" and hid every action.
|
||||||
|
// Falling back to the start time keeps the comparison meaningful.
|
||||||
|
range1Hour2: json['range1_hour2'] != null
|
||||||
|
? parseTime(json['range1_hour2'].toString())
|
||||||
|
: parseTime(json['range1_hour1']?.toString()),
|
||||||
rate: json['rate']?.toString() ?? '0',
|
rate: json['rate']?.toString() ?? '0',
|
||||||
status: intToEnumService(statusIndex),
|
status: intToEnumService(statusIndex),
|
||||||
location: intToEnum(locationIndex),
|
location: intToEnum(locationIndex),
|
||||||
@@ -142,7 +176,13 @@ class ApiServiceRepository {
|
|||||||
'longitude': entity.longitude,
|
'longitude': entity.longitude,
|
||||||
'location_preference': _locationToString[entity.location.index],
|
'location_preference': _locationToString[entity.location.index],
|
||||||
});
|
});
|
||||||
return data['id']?.toString() ?? '';
|
final id = data is Map ? data['id']?.toString() : null;
|
||||||
|
if (id == null || id.isEmpty) {
|
||||||
|
// Returning '' here used to be reported as success, and the app then
|
||||||
|
// navigated to a service that did not exist.
|
||||||
|
throw Exception('El servidor no devolvió la cita creada');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateServiceStatus(String serviceId, ServiceStatus newStatus) async {
|
Future<void> updateServiceStatus(String serviceId, ServiceStatus newStatus) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user