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