- 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>
351 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|