diff --git a/lib/blocs/chat_bloc/chat_bloc.dart b/lib/blocs/chat_bloc/chat_bloc.dart index 06253bc..2f4908d 100644 --- a/lib/blocs/chat_bloc/chat_bloc.dart +++ b/lib/blocs/chat_bloc/chat_bloc.dart @@ -10,6 +10,10 @@ part 'chat_state.dart'; class ChatBloc extends Bloc { 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 { } void _onLoadChatEvent(LoadChatEvent event, Emitter emit) async { - bool autoCreate = true; - + emit(ChatLoading()); try { - emit(ChatLoading()); - - Stream 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 { void _onSendMessageEvent( SendMessageEvent event, Emitter 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()); } } } diff --git a/lib/blocs/chat_bloc/chat_state.dart b/lib/blocs/chat_bloc/chat_state.dart index 340bbff..d0f371a 100644 --- a/lib/blocs/chat_bloc/chat_state.dart +++ b/lib/blocs/chat_bloc/chat_state.dart @@ -20,4 +20,8 @@ class ChatLoaded extends ChatState { List 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 {} diff --git a/lib/blocs/professional_list_bloc/professional_list_bloc.dart b/lib/blocs/professional_list_bloc/professional_list_bloc.dart index 6265a86..5f9cfc1 100644 --- a/lib/blocs/professional_list_bloc/professional_list_bloc.dart +++ b/lib/blocs/professional_list_bloc/professional_list_bloc.dart @@ -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 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 _fetch(ProfessionalListFetch event, + Emitter emit) async { final users = await _userRepository.getUsersProfessionalActive(); final hasSearchParams = event.search != null || diff --git a/lib/blocs/professional_list_bloc/professional_list_state.dart b/lib/blocs/professional_list_bloc/professional_list_state.dart index 8fdb140..7c29513 100644 --- a/lib/blocs/professional_list_bloc/professional_list_state.dart +++ b/lib/blocs/professional_list_bloc/professional_list_state.dart @@ -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 users; diff --git a/lib/blocs/score_bloc/score_bloc.dart b/lib/blocs/score_bloc/score_bloc.dart index 232e466..0095f30 100644 --- a/lib/blocs/score_bloc/score_bloc.dart +++ b/lib/blocs/score_bloc/score_bloc.dart @@ -38,10 +38,13 @@ class ScoreBloc extends Bloc { } void _onSendScoreEvent(SendScoreEvent event, Emitter 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 { 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 { 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]!, diff --git a/lib/blocs/score_bloc/score_state.dart b/lib/blocs/score_bloc/score_state.dart index 71c7c84..eb516eb 100644 --- a/lib/blocs/score_bloc/score_state.dart +++ b/lib/blocs/score_bloc/score_state.dart @@ -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); diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index cb5f0be..3f35608 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -34,19 +34,24 @@ class _ChatScreenState extends State { chatBloc = Injector.appInstance.get(); - chatBloc.add(LoadChatEvent( - serviceId: widget.service.id!, - userId: widget.service.userId, - professionalId: widget.service.professionalId, - )); + _loadChat(); _getUserAndProfessionalInfo(widget.service).then((userInfo) { + if (!mounted) return; setState(() { _userInfo = userInfo; }); }); } + void _loadChat() { + chatBloc.add(LoadChatEvent( + serviceId: widget.service.id ?? '', + userId: widget.service.userId, + professionalId: widget.service.professionalId, + )); + } + @override void dispose() { _messageController.dispose(); @@ -62,7 +67,16 @@ class _ChatScreenState extends State { ), body: BlocProvider( create: (context) => chatBloc, - child: BlocBuilder( + child: BlocConsumer( + 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) { if (state is ChatLoaded) { return Column( @@ -327,6 +341,29 @@ class _ChatScreenState extends State { ); } + 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( child: CircularProgressIndicator(), ); diff --git a/lib/screens/lists/professional_list_screen.dart b/lib/screens/lists/professional_list_screen.dart index f3c71e1..64ff98b 100644 --- a/lib/screens/lists/professional_list_screen.dart +++ b/lib/screens/lists/professional_list_screen.dart @@ -114,11 +114,19 @@ class _ProfessionalListScreenState extends State { void _loadProfessions() { professionRepository.getProfessions().then((Professions element) { + if (!mounted) return; setState(() { _professions = element.professions; _filteredProfessions = _professions; _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 { } _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) { final List filteredUsers; // TODO: encaso de filtro dañado // if (_searchController.text.isNotEmpty) { filteredUsers = state.users - .where((element) => removeDiacritics(element.myUser.name!) + .where((element) => removeDiacritics(element.myUser.name ?? '') .toLowerCase() .contains(removeDiacritics(_searchController.text.toLowerCase()))) .where((user) => diff --git a/lib/screens/score/score_screen.dart b/lib/screens/score/score_screen.dart index 9476f36..680d5a3 100644 --- a/lib/screens/score/score_screen.dart +++ b/lib/screens/score/score_screen.dart @@ -37,6 +37,11 @@ class _ScoreScreenState extends State { late Future> _userInfoFuture; late bool isProfessional; 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 void initState() { @@ -52,7 +57,38 @@ class _ScoreScreenState extends State { Widget build(BuildContext context) { return BlocProvider( create: (_) => Injector.appInstance.get(), - child: Scaffold( + child: BlocListener( + 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() + .add(UpdateProfessionalScored(widget.service.id!)); + } else { + context + .read() + .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, appBar: AppBar( title: const Text('Calificar servicio'), @@ -108,6 +144,7 @@ class _ScoreScreenState extends State { ), ], ), + ), ), ); } @@ -292,9 +329,9 @@ class _ScoreScreenState extends State { return SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () { - final isUser = widget.service.userId == - (ApiUserRepository.currentUserId ?? ''); + onPressed: _isSending + ? null + : () { final comment = CommentEntity( serviceId: widget.service.id!, authorId: ApiUserRepository.currentUserId ?? '', @@ -306,18 +343,11 @@ class _ScoreScreenState extends State { content: _commentController.text.trim(), 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(context) .add(SendScoreEvent(comment: comment)); - if (isUser) { - context - .read() - .add(UpdateProfessionalScored(widget.service.id!)); - } else { - context - .read() - .add(UpdateUserScored(widget.service.id!)); - } - Navigator.pop(context); }, icon: const Icon(Icons.send_rounded, size: 18), label: const Text('Enviar calificación', diff --git a/lib/screens/service/professional_service_screen.dart b/lib/screens/service/professional_service_screen.dart index d5afac2..069edd8 100644 --- a/lib/screens/service/professional_service_screen.dart +++ b/lib/screens/service/professional_service_screen.dart @@ -72,7 +72,8 @@ class _ProfessionalServiceScreenState @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get() + ..add(LoadService(widget.serviceId)), child: Scaffold( backgroundColor: context.bg, appBar: AppBar( @@ -123,9 +124,9 @@ class _ProfessionalServiceScreenState ); }, ); + } else if (state is CreateServiceFailure) { + return _loadErrorState(context); } else { - BlocProvider.of(context) - .add(LoadService(widget.serviceId)); 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(context) + .add(LoadService(widget.serviceId)), + child: const Text('Reintentar'), + ), + ], + ), + ), + ); + } + Widget _headerCard( BuildContext context, ServiceEntity service, MyUser user) { return Container( diff --git a/lib/screens/service/user_service_screen.dart b/lib/screens/service/user_service_screen.dart index c553829..495abcb 100644 --- a/lib/screens/service/user_service_screen.dart +++ b/lib/screens/service/user_service_screen.dart @@ -71,7 +71,8 @@ class _UserServiceScreenState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => Injector.appInstance.get(), + create: (_) => Injector.appInstance.get() + ..add(LoadService(widget.serviceId)), child: Scaffold( backgroundColor: context.bg, appBar: AppBar( @@ -126,9 +127,9 @@ class _UserServiceScreenState extends State { ); }, ); + } else if (state is CreateServiceFailure) { + return _loadErrorState(context); } else { - BlocProvider.of(context) - .add(LoadService(widget.serviceId)); return const Center(child: CircularProgressIndicator()); } }, @@ -137,6 +138,32 @@ class _UserServiceScreenState extends State { ); } + /// 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(context) + .add(LoadService(widget.serviceId)), + child: const Text('Reintentar'), + ), + ], + ), + ), + ); + } + Widget _headerCard(BuildContext context, ServiceEntity service, MyUser user, ProfessionalEntity professional) { return Container( @@ -472,7 +499,7 @@ class _UserServiceScreenState extends State { subtitle: 'El servicio fue rechazado', ); case ServiceStatus.completed: - if (service.userScored == false) { + if (service.professionalScored == false) { return _StatusCard( color: const Color(0xFF16A34A), icon: Icons.star_outline_rounded, diff --git a/lib/screens/user/user_service_screen.dart b/lib/screens/user/user_service_screen.dart index f3e1c38..49446b3 100644 --- a/lib/screens/user/user_service_screen.dart +++ b/lib/screens/user/user_service_screen.dart @@ -57,7 +57,8 @@ class _UserServiceScreenState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => Injector.appInstance.get(), + create: (context) => Injector.appInstance.get() + ..add(LoadService(widget.serviceId)), child: Scaffold( appBar: AppBar( title: const Text('Servicio'), @@ -275,12 +276,10 @@ class _UserServiceScreenState extends State { } }, ); + } else if (state is CreateServiceFailure) { + return _loadErrorState(context); } else { - BlocProvider.of(context) - .add(LoadService(widget.serviceId)); - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } }, ), @@ -288,6 +287,32 @@ class _UserServiceScreenState extends State { ); } + /// 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(context) + .add(LoadService(widget.serviceId)), + child: const Text('Reintentar'), + ), + ], + ), + ), + ); + } + Widget customMessageStatus(ServiceEntity service) { DateTime serviceDate = DateTime.parse(service.day); DateTime now = DateTime.now(); @@ -404,7 +429,7 @@ class _UserServiceScreenState extends State { } if (service.status == ServiceStatus.completed && - service.userScored == false) { + service.professionalScored == false) { return Stack( alignment: AlignmentDirectional.topCenter, clipBehavior: Clip.none, @@ -479,7 +504,7 @@ class _UserServiceScreenState extends State { } if (service.status == ServiceStatus.completed && - service.userScored == true) { + service.professionalScored == true) { return Stack( alignment: AlignmentDirectional.topCenter, clipBehavior: Clip.none, diff --git a/packages/chat_repository/lib/src/repositories/api_chat_repository.dart b/packages/chat_repository/lib/src/repositories/api_chat_repository.dart index 9641b38..debd1c6 100644 --- a/packages/chat_repository/lib/src/repositories/api_chat_repository.dart +++ b/packages/chat_repository/lib/src/repositories/api_chat_repository.dart @@ -29,8 +29,10 @@ class ApiChatRepository { ChatEntity _chatFromApi(Map json) { final rawMessages = json['messages'] as List? ?? []; + // _msgFromApi, not MessageEntity.fromDocument: the backend sends + // `sender_id` while fromDocument hard-casts `owner_id`. final messages = rawMessages - .map((m) => MessageEntity.fromDocument(m as Map)) + .map((m) => _msgFromApi(m as Map)) .toList(); return ChatEntity( id: json['id']?.toString(), @@ -52,59 +54,68 @@ class ApiChatRepository { ); } - /// Get or create a chat session. Maps to POST /chat/start/:professionalUserId. - /// [chatId] here is used as the professional's userId for the REST call. - Future createNewChat( - String chatId, String userId, String professionalId) async { - final res = await http.post( - Uri.parse('$_base/chat/start/$professionalId'), - headers: await _headers(), - ).timeout(_kHttpTimeout); - final data = jsonDecode(res.body) as Map; - return _chatFromApi(data); - } - - /// Streams a single chat by its ID. Fetches once and emits. - Stream getChatById(String chatId) { - final controller = StreamController(); - _fetchChat(chatId).then((chat) { - controller.add(chat); - controller.close(); - }).catchError((e) { - controller.add(null); - controller.close(); - }); - return controller.stream; - } - - Future _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)) - .toList(), - ); - } catch (_) { - return null; + /// Opens the conversation with a professional, creating it if needed. + /// + /// `POST /chat/start/:professionalUserId` is idempotent: calling it again + /// returns the same chat. The returned `id` is the **chat** id, which is what + /// every other chat endpoint is keyed by — not the service id. + Future startChat(String professionalUserId) async { + final res = await http + .post( + Uri.parse('$_base/chat/start/$professionalUserId'), + headers: await _headers(), + ) + .timeout(_kHttpTimeout); + if (res.statusCode >= 400) { + throw Exception('No se pudo abrir el chat (${res.statusCode})'); } + return _chatFromApi(jsonDecode(res.body) as Map); } - Future sendMessage(String chatId, MessageEntity message) async { - await http.post( - Uri.parse('$_base/chat/$chatId/message'), - headers: await _headers(), - body: jsonEncode({'content': message.content}), - ).timeout(_kHttpTimeout); + /// Loads the conversation plus its messages, ready to render. + Future loadConversation(String professionalUserId) async { + final chat = await startChat(professionalUserId); + final messages = await fetchMessages(chat.id ?? ''); + return ChatEntity( + 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> 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)) + .toList(); + } + + /// Posts a message and returns it as stored by the backend. + Future 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); } /// Get all chats for the current user. diff --git a/packages/score_repository/lib/src/entities/comment_entity.dart b/packages/score_repository/lib/src/entities/comment_entity.dart index 75787e2..0e3d2b1 100644 --- a/packages/score_repository/lib/src/entities/comment_entity.dart +++ b/packages/score_repository/lib/src/entities/comment_entity.dart @@ -21,12 +21,16 @@ class CommentEntity extends Equatable { static CommentEntity fromDocument(Map doc) { return CommentEntity( - authorId: doc['author_id'] as String, - destinationId: doc['destination_id'] as String, - serviceId: doc['service_id'] as String, - content: doc['content'] as String, - score: doc['score'] as double, - isFromUser: doc['is_from_user'] as bool, + // Tolerant on purpose: a whole-number score arrives as `5`, and + // Postgres numerics can arrive as strings. Hard casts here emptied the + // whole review list, which then read as "no reviews yet". + authorId: doc['author_id']?.toString() ?? '', + destinationId: doc['destination_id']?.toString() ?? '', + 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(), ); } diff --git a/packages/score_repository/lib/src/entities/score_entity.dart b/packages/score_repository/lib/src/entities/score_entity.dart index fc3cf0c..197b341 100644 --- a/packages/score_repository/lib/src/entities/score_entity.dart +++ b/packages/score_repository/lib/src/entities/score_entity.dart @@ -23,10 +23,12 @@ class ReputationEntity extends Equatable { final averagePro = doc['average_pro'] ?? 0.0; return ReputationEntity( - total: int.parse(total.toString()), - average: double.parse(average.toString()), - totalPro: int.parse(totalPro.toString()), - averagePro: double.parse(averagePro.toString()), + // COUNT can come back as "5.0000"; parse as double then truncate so a + // reputation of 0.0 never gets shown as if it were real. + total: (double.tryParse(total.toString()) ?? 0).toInt(), + average: double.tryParse(average.toString()) ?? 0.0, + totalPro: (double.tryParse(totalPro.toString()) ?? 0).toInt(), + averagePro: double.tryParse(averagePro.toString()) ?? 0.0, ); } diff --git a/packages/score_repository/lib/src/repositories/api_score_repository.dart b/packages/score_repository/lib/src/repositories/api_score_repository.dart index 50975dc..09bfb60 100644 --- a/packages/score_repository/lib/src/repositories/api_score_repository.dart +++ b/packages/score_repository/lib/src/repositories/api_score_repository.dart @@ -81,19 +81,29 @@ class ApiScoreRepository { return raw.map((e) => CommentEntity.fromDocument(e as Map)).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 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 { - 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); } catch (_) {} } diff --git a/packages/service_repository/lib/src/repositories/api_service_repository.dart b/packages/service_repository/lib/src/repositories/api_service_repository.dart index 7be970e..2e85e9d 100644 --- a/packages/service_repository/lib/src/repositories/api_service_repository.dart +++ b/packages/service_repository/lib/src/repositories/api_service_repository.dart @@ -51,27 +51,55 @@ class ApiServiceRepository { }; } - Future _get(String path) async { - final res = await http.get(Uri.parse('$_base$path'), headers: await _headers()).timeout(_kHttpTimeout); + /// Rejects error responses and tolerates an empty body. + /// + /// 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); } + Future _get(String path) async { + final res = await http + .get(Uri.parse('$_base$path'), headers: await _headers()) + .timeout(_kHttpTimeout); + return _decode(res, 'La consulta'); + } + Future _post(String path, Map body) async { - final res = await http.post( - Uri.parse('$_base$path'), - headers: await _headers(), - body: jsonEncode(body), - ).timeout(_kHttpTimeout); - return jsonDecode(res.body); + final res = await http + .post( + Uri.parse('$_base$path'), + headers: await _headers(), + body: jsonEncode(body), + ) + .timeout(_kHttpTimeout); + return _decode(res, 'La operación'); } Future _patch(String path, Map body) async { - final res = await http.patch( - Uri.parse('$_base$path'), - headers: await _headers(), - body: jsonEncode(body), - ).timeout(_kHttpTimeout); - return jsonDecode(res.body); + final res = await http + .patch( + Uri.parse('$_base$path'), + headers: await _headers(), + body: jsonEncode(body), + ) + .timeout(_kHttpTimeout); + return _decode(res, 'La actualización'); } ServiceEntity _fromApi(Map json) { @@ -114,7 +142,13 @@ class ApiServiceRepository { createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(), description: json['description']?.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', status: intToEnumService(statusIndex), location: intToEnum(locationIndex), @@ -142,7 +176,13 @@ class ApiServiceRepository { 'longitude': entity.longitude, '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 updateServiceStatus(String serviceId, ServiceStatus newStatus) async {