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>
509 lines
21 KiB
Dart
509 lines
21 KiB
Dart
import 'package:chat_repository/chat_repository.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:prosappco/blocs/chat_bloc/chat_bloc.dart';
|
|
import 'package:prosappco/components/general_reputation.dart';
|
|
import 'package:prosappco/local_notifications/local_notifications.dart';
|
|
import 'package:service_repository/service_repository.dart';
|
|
import 'package:user_repository/user_repository.dart';
|
|
|
|
class ChatScreen extends StatefulWidget {
|
|
final ServiceEntity service;
|
|
|
|
const ChatScreen({super.key, required this.service});
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
late final ChatBloc chatBloc;
|
|
final TextEditingController _messageController = TextEditingController();
|
|
final FocusNode _messageFocusNode = FocusNode();
|
|
|
|
List<dynamic>? _userInfo;
|
|
DateTime? _lastNotificationTime;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
chatBloc = Injector.appInstance.get<ChatBloc>();
|
|
|
|
_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();
|
|
_messageFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Chat'),
|
|
),
|
|
body: BlocProvider(
|
|
create: (context) => chatBloc,
|
|
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) {
|
|
if (state is ChatLoaded) {
|
|
return Column(
|
|
children: [
|
|
FutureBuilder(
|
|
future: _userInfo != null ? Future.value(_userInfo) : null,
|
|
builder: (BuildContext context,
|
|
AsyncSnapshot<List<dynamic>?> snapshot) {
|
|
if (_userInfo != null) {
|
|
final userInfo = _userInfo![0] as MyUser;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
color: const Color(0xFFD6F4FF),
|
|
alignment: Alignment.topCenter,
|
|
child: ListTile(
|
|
leading: Container(
|
|
width: 60,
|
|
height: 60,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
shape: BoxShape.circle,
|
|
image: userInfo.picture == null
|
|
? null
|
|
: DecorationImage(
|
|
image: NetworkImage(
|
|
userInfo.picture ?? ''),
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
child: userInfo.picture == null
|
|
? Icon(
|
|
CupertinoIcons.person,
|
|
color: Colors.grey.shade400,
|
|
size: 40,
|
|
)
|
|
: null,
|
|
),
|
|
title: Text(
|
|
userInfo.name ?? '',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
subtitle: widget.service.userId ==
|
|
(ApiUserRepository.currentUserId ?? '')
|
|
? GeneralReputation(
|
|
userId: widget.service.professionalId,
|
|
builder: (context, reputation) {
|
|
final average = reputation.averagePro;
|
|
final total = reputation.totalPro;
|
|
|
|
return Row(
|
|
children: [
|
|
RatingBar.builder(
|
|
initialRating:
|
|
calculoRating(average),
|
|
minRating: 1,
|
|
direction: Axis.horizontal,
|
|
allowHalfRating: true,
|
|
itemCount: 5,
|
|
itemSize: 25,
|
|
maxRating: 5,
|
|
itemBuilder: (context, _) =>
|
|
const Icon(
|
|
Icons.star,
|
|
color: Color(0xFF2BA4EC),
|
|
),
|
|
onRatingUpdate: (rating) {},
|
|
ignoreGestures: true,
|
|
),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
'${average.toStringAsFixed(1)} (${total.toString()})',
|
|
),
|
|
],
|
|
);
|
|
})
|
|
: GeneralReputation(
|
|
userId: widget.service.userId,
|
|
builder: (context, reputation) {
|
|
final average = reputation.average;
|
|
final total = reputation.total;
|
|
|
|
return Row(
|
|
children: [
|
|
RatingBar.builder(
|
|
initialRating:
|
|
calculoRating(average),
|
|
minRating: 1,
|
|
direction: Axis.horizontal,
|
|
allowHalfRating: true,
|
|
itemCount: 5,
|
|
itemSize: 25,
|
|
maxRating: 5,
|
|
itemBuilder: (context, _) =>
|
|
const Icon(
|
|
Icons.star,
|
|
color: Color(0xFF2BA4EC),
|
|
),
|
|
onRatingUpdate: (rating) {},
|
|
ignoreGestures: true,
|
|
),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
'${average.toStringAsFixed(1)} (${total.toString()})',
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
},
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
reverse: true,
|
|
child: Column(
|
|
children: _messagesList(state.chat.messages),
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 15, vertical: 15),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
focusNode: _messageFocusNode,
|
|
controller: _messageController,
|
|
style: const TextStyle(color: Colors.black),
|
|
decoration: InputDecoration(
|
|
hintText: 'Mensaje',
|
|
hintStyle: TextStyle(
|
|
color: Colors.grey[600], fontSize: 16),
|
|
border: OutlineInputBorder(
|
|
borderSide: const BorderSide(
|
|
color: Colors.grey, width: 1.0),
|
|
borderRadius: BorderRadius.circular(50)),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderSide: const BorderSide(
|
|
color: Colors.grey, width: 1.0),
|
|
borderRadius: BorderRadius.circular(50)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 20, vertical: 15),
|
|
filled: true,
|
|
fillColor: Colors.grey[200],
|
|
),
|
|
onFieldSubmitted: (value) async {
|
|
if (_messageController.text.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
MessageEntity message = MessageEntity(
|
|
ownerId:
|
|
ApiUserRepository.currentUserId ?? '',
|
|
content: _messageController.text.trim(),
|
|
createdAt: DateTime.now(),
|
|
);
|
|
|
|
chatBloc.add(SendMessageEvent(
|
|
serviceId: widget.service.id!,
|
|
message: message,
|
|
));
|
|
|
|
if (_userInfo != null) {
|
|
final userInfo = _userInfo![0] as MyUser;
|
|
final myInfo = _userInfo![1] as MyUser;
|
|
|
|
if (userInfo.token == null) return;
|
|
|
|
if (_lastNotificationTime == null ||
|
|
DateTime.now().difference(
|
|
_lastNotificationTime!) >
|
|
const Duration(minutes: 5)) {
|
|
LocalNotifications.sendPushNotification(
|
|
userInfo.token!,
|
|
'Nuevo mensaje',
|
|
'Tienes un nuevo mensaje de ${myInfo.name}',
|
|
);
|
|
|
|
_lastNotificationTime = DateTime.now();
|
|
}
|
|
}
|
|
|
|
_messageController.clear();
|
|
_messageFocusNode.requestFocus();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
GestureDetector(
|
|
onTap: () async {
|
|
if (_messageController.text.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
MessageEntity message = MessageEntity(
|
|
ownerId: ApiUserRepository.currentUserId ?? '',
|
|
content: _messageController.text.trim(),
|
|
createdAt: DateTime.now(),
|
|
);
|
|
|
|
chatBloc.add(SendMessageEvent(
|
|
serviceId: widget.service.id!,
|
|
message: message,
|
|
));
|
|
|
|
if (_userInfo != null) {
|
|
final userInfo = _userInfo![0] as MyUser;
|
|
final myInfo = _userInfo![1] as MyUser;
|
|
|
|
if (userInfo.token == null) return;
|
|
|
|
if (_lastNotificationTime == null ||
|
|
DateTime.now().difference(
|
|
_lastNotificationTime!) >
|
|
const Duration(minutes: 5)) {
|
|
LocalNotifications.sendPushNotification(
|
|
userInfo.token!,
|
|
'Nuevo mensaje',
|
|
'Tienes un nuevo mensaje de ${myInfo.name}',
|
|
);
|
|
|
|
_lastNotificationTime = DateTime.now();
|
|
}
|
|
}
|
|
|
|
_messageController.clear();
|
|
_messageFocusNode.requestFocus();
|
|
},
|
|
child: Container(
|
|
height: 50,
|
|
width: 50,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).primaryColor,
|
|
borderRadius: BorderRadius.circular(30),
|
|
),
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.send,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
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(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _messagesList(List<MessageEntity> messages) {
|
|
return messages
|
|
.map(
|
|
(e) => e.ownerId != (ApiUserRepository.currentUserId ?? '')
|
|
? ListTile(
|
|
title: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
margin: const EdgeInsets.only(right: 60),
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 10, horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade200,
|
|
borderRadius: const BorderRadius.only(
|
|
topRight: Radius.circular(20),
|
|
bottomLeft: Radius.circular(20),
|
|
bottomRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: Text(
|
|
e.content,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Text(
|
|
_getFechaHoraFormateada(e.createdAt),
|
|
style:
|
|
const TextStyle(color: Colors.grey, fontSize: 11),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: ListTile(
|
|
title: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Container(
|
|
margin: const EdgeInsets.only(left: 60),
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 10,
|
|
horizontal: 16,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFD5EFFF),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
bottomLeft: Radius.circular(20),
|
|
bottomRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: Text(
|
|
e.content,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Text(
|
|
_getFechaHoraFormateada(e.createdAt),
|
|
style: const TextStyle(
|
|
color: Colors.grey,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
String _getFechaHoraFormateada(DateTime fecha) {
|
|
final now = DateTime.now();
|
|
final yesterday = now.subtract(const Duration(days: 1));
|
|
final formatter = DateFormat('h:mm a');
|
|
|
|
if (fecha.day == now.day &&
|
|
fecha.month == now.month &&
|
|
fecha.year == now.year) {
|
|
return 'Hoy - ${formatter.format(fecha)}';
|
|
} else if (fecha.day == yesterday.day &&
|
|
fecha.month == yesterday.month &&
|
|
fecha.year == yesterday.year) {
|
|
return 'Ayer - ${formatter.format(fecha)}';
|
|
} else {
|
|
return '${DateFormat('dd/MM/yyyy').format(fecha)} - ${formatter.format(fecha)}';
|
|
}
|
|
}
|
|
|
|
Future<List<dynamic>> _getUserAndProfessionalInfo(
|
|
ServiceEntity service) async {
|
|
final userRepo = Injector.appInstance.get<UserRepository>();
|
|
|
|
final MyUser? userInfo;
|
|
final MyUser? myInfo;
|
|
|
|
if ((ApiUserRepository.currentUserId ?? '') == service.userId) {
|
|
userInfo = await userRepo.getMyUser(service.professionalId);
|
|
myInfo = await userRepo.getMyUser(service.userId);
|
|
} else {
|
|
userInfo = await userRepo.getMyUser(service.userId);
|
|
myInfo = await userRepo.getMyUser(service.professionalId);
|
|
}
|
|
|
|
return [userInfo, myInfo];
|
|
}
|
|
|
|
double calculoRating(double average) {
|
|
String numeroString = average.toString();
|
|
List<String> partes = numeroString.split('.');
|
|
int parteEntera = int.parse(partes[0]);
|
|
int parteFraccionaria = partes.length > 1 ? int.parse(partes[1]) : 0;
|
|
|
|
if (parteFraccionaria >= 3) {
|
|
parteFraccionaria = 5;
|
|
} else {
|
|
parteFraccionaria = 0;
|
|
}
|
|
|
|
// Unir la parte entera y fraccionaria y convertirlo nuevamente a double
|
|
double resultado = double.parse('$parteEntera.$parteFraccionaria');
|
|
return resultado;
|
|
}
|
|
}
|