Files
prosappco/lib/screens/score/score_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 389f876cfa
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
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>
2026-08-24 20:47:54 -05:00

383 lines
14 KiB
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:prosappco/blocs/score_bloc/score_bloc.dart';
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/components/general_reputation.dart';
import 'package:score_repository/score_repository.dart';
import 'package:service_repository/service_repository.dart';
import 'package:user_repository/user_repository.dart';
const _kPrimary = Color(0xFF1565C0);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class ScoreScreen extends StatefulWidget {
final ServiceEntity service;
const ScoreScreen({Key? key, required this.service}) : super(key: key);
@override
State<ScoreScreen> createState() => _ScoreScreenState();
}
class _ScoreScreenState extends State<ScoreScreen> {
double _rating = 1.0;
final TextEditingController _commentController = TextEditingController();
late Future<List<dynamic>> _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() {
super.initState();
_userInfoFuture = _getUserInfo(widget.service);
isProfessional =
(ApiUserRepository.currentUserId ?? '') == widget.service.userId;
userId =
isProfessional ? widget.service.professionalId : widget.service.userId;
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => Injector.appInstance.get<ScoreBloc>(),
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,
appBar: AppBar(
title: const Text('Calificar servicio'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
FutureBuilder<List<dynamic>>(
future: _userInfoFuture,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text('Error: ${snapshot.error}'),
);
}
final userInfo = snapshot.data![0] as MyUser;
return _profileHeader(context, userInfo);
},
),
const SizedBox(height: 4),
_ratingSection(context),
_commentSection(context),
],
),
),
),
const Divider(height: 1, thickness: 0.5),
BlocProvider<ServiceBloc>(
create: (_) => Injector.appInstance.get<ServiceBloc>(),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 16),
child: BlocBuilder<ServiceBloc, ServiceState>(
builder: (context, serviceState) {
return _sendButton(context);
},
),
),
),
],
),
),
),
);
}
Widget _profileHeader(BuildContext context, MyUser user) {
return Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: context.shadowSm,
blurRadius: 12,
offset: const Offset(0, 2))
],
),
child: Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 84,
height: 84,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _kPrimary.withOpacity(0.25), width: 3),
color: Colors.grey.shade200,
image: user.picture != null && user.picture!.isNotEmpty
? DecorationImage(
image: NetworkImage(user.picture!),
fit: BoxFit.cover)
: null,
),
child: user.picture == null || user.picture!.isEmpty
? Icon(CupertinoIcons.person,
color: Colors.grey.shade500, size: 38)
: null,
),
const SizedBox(height: 10),
Text(user.name ?? '',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: context.onSurface)),
],
),
GeneralReputation(
userId: userId,
builder: (_, ReputationEntity reputation) {
final average = isProfessional
? reputation.averagePro
: reputation.average;
return Positioned(
top: 0,
right: MediaQuery.of(context).size.width * 0.18,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: context.shadowSm,
blurRadius: 6,
spreadRadius: 1)
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.star, color: Colors.amber, size: 14),
const SizedBox(width: 3),
Text(average.toStringAsFixed(1),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: context.onSurface)),
]),
),
);
},
),
],
),
);
}
Widget _ratingSection(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
),
child: Column(
children: [
Text('¿Cómo fue el servicio?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: context.onSurface)),
const SizedBox(height: 14),
RatingBar.builder(
initialRating: _rating,
minRating: 1,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemSize: 42,
glow: false,
maxRating: 5,
itemPadding: const EdgeInsets.symmetric(horizontal: 4),
itemBuilder: (_, __) =>
const Icon(Icons.star_rounded, color: _kPrimary),
onRatingUpdate: (r) => setState(() => _rating = r),
),
const SizedBox(height: 8),
Text(
_ratingMessage(_rating),
style:
TextStyle(fontSize: 14, color: _kPrimary, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _commentSection(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Comentario (opcional)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.onSurface)),
const SizedBox(height: 10),
TextFormField(
controller: _commentController,
maxLines: 4,
maxLength: 400,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: 'Cuéntanos tu experiencia...',
hintStyle: TextStyle(color: context.muted, fontSize: 13),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kPrimary, width: 2),
),
contentPadding: const EdgeInsets.all(12),
),
),
],
),
);
}
Widget _sendButton(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSending
? null
: () {
final comment = CommentEntity(
serviceId: widget.service.id!,
authorId: ApiUserRepository.currentUserId ?? '',
isFromUser: isUser,
score: _rating,
destinationId: isUser
? widget.service.professionalId
: widget.service.userId,
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<ScoreBloc>(context)
.add(SendScoreEvent(comment: comment));
},
icon: const Icon(Icons.send_rounded, size: 18),
label: const Text('Enviar calificación',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
}
String _ratingMessage(double rating) {
if (rating > 4.0) return '¡Excelente!';
if (rating < 2.0) return 'Necesita mejorar';
if (rating >= 3.0) return '¡Bien!';
return 'Regular';
}
Future<List<dynamic>> _getUserInfo(ServiceEntity service) async {
final userRepo = Injector.appInstance.get<UserRepository>();
final currentId = ApiUserRepository.currentUserId ?? '';
final targetId =
currentId == service.userId ? service.professionalId : service.userId;
final userInfo = await userRepo.getMyUser(targetId);
return [userInfo];
}
}