Files
prosappweb/lib/ui/views/chat_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 0ecca498ad fix: align Flutter endpoints and model parsing with actual NestJS backend
- Auth: phone login → POST /auth/phone (direct, no OTP); link phone → POST /auth/verify-phone; add email → POST /auth/link-email
- Services: replace query-param paths with dedicated role endpoints (/services/me, /services/professional/requests, /services/professional, etc.)
- Services: PATCH /services/:id → PATCH /services/:id/status
- Calendar: → GET /services/professional/calendar
- Professionals: /users/professionals → /professionals (handles {data:[...]} response)
- Professional info: /professional-info/:id → /professionals/:id; PATCH → /professionals/me
- Comments: /comments?... → /comments/user/:id and /comments/professional/:id
- Chat: /chats → /chat; start chat → POST /chat/start/:professionalId; poll GET /chat/:chatId/messages; send → POST /chat/:chatId/message
- Cities: /cities → GET /locations/countries (parse nested countries→regions→cities)
- Profesional.fromDocument: null-safe fields; convert schedules array→Schedules, specializations array→name/picture lists, payment_methods object/array
- Usuario.fromDocument: null-safe professional_state and id
- UsuarioProfesional.fromDocument: handle backend format (users nested, professional at top level)
- ScheduleEntity.parseTime: handle ISO8601 time strings from backend
- MessageEntity.fromDocument: accept sender_id (backend) or owner_id (legacy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 16:07:44 -05:00

351 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/message_entity.dart';
import 'package:prosapp_web_app/providers/chat_provider.dart';
import 'package:prosapp_web_app/providers/score_provider.dart';
import 'package:prosapp_web_app/providers/services_provider.dart';
import 'package:prosapp_web_app/providers/sidemenu_provider.dart';
import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:provider/provider.dart';
class ChatView extends StatelessWidget {
final String type;
final String serviceId;
final String professionalId;
const ChatView({
super.key,
required this.type,
required this.serviceId,
required this.professionalId,
});
@override
Widget build(BuildContext context) {
final chatProvider = Provider.of<ChatProvider>(context);
final servicesProvider =
Provider.of<ServicesProvider>(context, listen: false);
final scoreProvider = Provider.of<ScoreProvider>(context, listen: false);
scoreProvider.loadReputation(professionalId);
return StreamBuilder(
stream: chatProvider.getChat(serviceId, professionalId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final chat = snapshot.data;
if (chat == null) {
return const Center(
child: Text('Chat no encontrado'),
);
}
final service = servicesProvider.service?.service;
final user = servicesProvider.service?.user;
final reputation = scoreProvider.reputation;
if (service == null || user == null) {
return const Center(child: CircularProgressIndicator());
}
final image = (user.picture == '' || user.picture == null)
? const Image(image: AssetImage('no-image.jpg'))
: FadeInImage.assetNetwork(
placeholder: 'loader.gif',
fit: BoxFit.cover,
image: user.picture!,
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 15),
color: const Color(0xFFD6F4FF),
alignment: Alignment.topCenter,
child: Row(
children: [
const SizedBox(width: 20),
SizedBox(
width: 85,
height: 85,
child: ClipOval(child: image),
),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
RatingBar.builder(
initialRating: type == 'user'
? calculoRating(reputation.averagePro)
: calculoRating(reputation.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),
if (type == 'user')
Text(
'${reputation.averagePro.toStringAsFixed(1)} (${reputation.totalPro.toString()})',
),
if (type == 'professional')
Text(
'${reputation.average.toStringAsFixed(1)} (${reputation.total.toString()})',
),
],
)
],
)
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 10),
reverse: true,
child: Column(
children: _messagesList(chat.messages, Provider.of<AuthProvider>(context, listen: false).user!.id),
),
),
),
_MessageInput(
serviceId: serviceId,
userId: Provider.of<AuthProvider>(context, listen: false).user!.id),
],
),
),
);
},
);
}
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;
}
List<Widget> _messagesList(List<MessageEntity> messages, String currentUserId) {
return messages
.map(
(e) => e.ownerId != 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.shade300,
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)}';
}
}
void navigateTo(String routeName) {
NavigationService.replaceTo(routeName);
SideMenuProvider.closeMenu();
}
}
class _MessageInput extends StatefulWidget {
final String serviceId;
final String userId;
const _MessageInput({
required this.serviceId,
required this.userId,
});
@override
State<_MessageInput> createState() => _MessageInputState();
}
class _MessageInputState extends State<_MessageInput> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
void sendMessage() {
if (_controller.text.trim().isNotEmpty) {
final message = MessageEntity(
ownerId: widget.userId,
content: _controller.text.trim(),
createdAt: DateTime.now(),
);
chatProvider.sendMessage(widget.serviceId, message);
_controller.clear();
}
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
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],
),
onSubmitted: (value) => sendMessage(),
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => sendMessage(),
child: Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: const Color(0xFF2BA4EC),
borderRadius: BorderRadius.circular(30),
),
child: const Center(
child: Icon(
Icons.send,
color: Colors.white,
),
),
),
),
],
),
);
}
}