Files
prosappco/lib/screens/professional/professional_denied_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

246 lines
10 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injector/injector.dart';
import 'package:prosappco/blocs/authentication_bloc/authentication_bloc.dart';
import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart';
import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart';
import 'package:prosappco/screens/professional/professional_form_screen.dart';
import 'package:setting_repository/setting_repository.dart';
class ProfessionalDeniedScreen extends StatefulWidget {
const ProfessionalDeniedScreen({super.key});
@override
State<ProfessionalDeniedScreen> createState() =>
_ProfessionalDeniedScreenState();
}
class _ProfessionalDeniedScreenState extends State<ProfessionalDeniedScreen> {
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? _settings;
bool _loadingSettings = true;
bool _isResetting = false;
@override
void initState() {
super.initState();
settingRepository.getSettings().then((value) {
if (!mounted) return;
setState(() {
_settings = value;
_loadingSettings = false;
});
});
}
void _confirmRetry() {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Volver a registrarme'),
content: const Text(
'Esta acción reiniciará tu solicitud de profesional y no se puede deshacer. '
'¿Deseas continuar?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
context
.read<ProfessionalBloc>()
.add(const ResetProfessionalApplicationEvent());
},
child: const Text('Continuar'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return BlocListener<ProfessionalBloc, ProfessionalState>(
listener: (context, state) {
if (state is ResetProfessionalApplicationLoading) {
setState(() => _isResetting = true);
} else if (state is ResetProfessionalApplicationSuccess) {
setState(() => _isResetting = false);
Navigator.of(context).pushReplacement(
CupertinoPageRoute(builder: (_) => const ProfessionalFormScreen()),
);
} else if (state is ProfessionalStateFailure) {
setState(() => _isResetting = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo reiniciar la solicitud')),
);
}
},
child: BlocBuilder<MyUserBloc, MyUserState>(
builder: (context, myUserState) {
final rejectedAt = myUserState.user?.rejectedAt;
// Backend currently ships rejection_wait_days = 0 (no cooldown).
// 7 is only the fallback when /settings could not be read.
final waitDays = _settings?.rejectionWaitDays?.toInt() ?? 7;
// Fail-open, same as the web: if we cannot tell when the rejection
// happened, assume the wait already elapsed. Locking someone out of
// re-applying forever is worse than letting them retry early.
final daysElapsed = rejectedAt != null
? DateTime.now().difference(rejectedAt).inDays
: waitDays;
final daysLeft = (waitDays - daysElapsed).clamp(0, waitDays);
final canRetry = !_loadingSettings && daysLeft == 0;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: const Color(0xFFFFEBEE),
shape: BoxShape.circle,
),
child: const Icon(Icons.cancel_outlined,
color: Color(0xFFE53935), size: 48),
),
const SizedBox(height: 24),
const Text(
'Solicitud rechazada',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF1A1A2E)),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Tu solicitud para convertirte en profesional no fue aprobada. Esto puede deberse a información incompleta o documentación no válida.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey, height: 1.5),
),
const SizedBox(height: 32),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(0xFFFFCC02).withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Row(
children: [
Icon(Icons.info_outline,
color: Color(0xFFF57C00), size: 20),
SizedBox(width: 8),
Text('¿Qué puedo hacer?',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFFF57C00))),
],
),
SizedBox(height: 12),
_BulletPoint(text: 'Verifica que todos tus datos sean correctos'),
_BulletPoint(
text: 'Asegúrate de haber adjuntado los documentos requeridos'),
_BulletPoint(
text: 'Vuelve a registrarte como profesional con la información actualizada'),
_BulletPoint(text: 'Contacta a soporte si crees que fue un error'),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: (canRetry && !_isResetting) ? _confirmRetry : null,
icon: _isResetting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.refresh),
label: const Text('Volver a registrarme'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade300,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
if (!canRetry && !_loadingSettings) ...[
const SizedBox(height: 8),
Text(
'Podrás volver a intentarlo en $daysLeft día${daysLeft == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context
.read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested()),
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: Colors.grey),
),
),
),
],
),
),
),
);
},
),
);
}
}
class _BulletPoint extends StatelessWidget {
final String text;
const _BulletPoint({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(color: Color(0xFFF57C00), fontWeight: FontWeight.bold)),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)))),
],
),
);
}
}