Files
prosappco/lib/screens/score/score_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 8631e6f729 fix: port 7 web features and repair the endless-loading screens
Root cause behind most "stuck loading" reports: the backend changed shape
(schedules became an array, location_preferences a string) while the mobile
parser still hard-cast to Map/int. The TypeError was swallowed by a silent
catch that returned null, and screens only handled the success state, so a
parse failure rendered as a permanent spinner. Same class of bug appeared
across service lists via non-null map lookups and a total absence of
request timeouts.

Ported from prosappweb:
- in-app suggestions (POST /suggestions)
- policies/terms from GET /settings/policies
- configurable appointment length (slot_duration_minutes)
- block/unblock calendar slots (POST /services/block)
- GPS city detection on the profile (Nominatim)
- server-side professional search with haversine distance
- retry cooldown after a rejected professional application

Reliability:
- parse schedules array (day_of_week 0=Mon) and string location_preferences
- read times as wall clock, so 08:00 stays 08:00 across timezones
- carry minutes into hours in TimeOfDay.add; a minute-based step used to
  loop forever and freeze the calendar (covered by test/time_slots_test.dart)
- semver update check instead of string equality, which blocked every build
  that did not exactly match the configured version
- request timeouts across all repositories
- surface HTTP >= 400 instead of reporting failed writes as success
- error states with retry instead of an indefinite shimmer

Includes pre-existing uncommitted work from the UI redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:45:42 -05:00

353 lines
12 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;
@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: 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: () {
final isUser = widget.service.userId ==
(ApiUserRepository.currentUserId ?? '');
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(),
);
BlocProvider.of<ScoreBloc>(context)
.add(SendScoreEvent(comment: comment));
if (isUser) {
context
.read<ServiceBloc>()
.add(UpdateProfessionalScored(widget.service.id!));
} else {
context
.read<ServiceBloc>()
.add(UpdateUserScored(widget.service.id!));
}
Navigator.pop(context);
},
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];
}
}