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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
06a89df690
commit
8631e6f729
@@ -51,7 +51,9 @@ class _ProfessionalProfileScreenState
|
||||
final _rateController = TextEditingController();
|
||||
|
||||
Schedules schedules = Schedules.empty;
|
||||
int _slotDurationMinutes = 120;
|
||||
bool isInit = false;
|
||||
bool _isSaving = false;
|
||||
|
||||
double _longitudeController = 0;
|
||||
double _latitudeController = 0;
|
||||
@@ -78,7 +80,28 @@ class _ProfessionalProfileScreenState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
return BlocListener<ProfessionalProfileBloc, ProfessionalProfileState>(
|
||||
listener: (context, saveState) {
|
||||
if (saveState is UpdateProfessionalInfoLoading) {
|
||||
setState(() => _isSaving = true);
|
||||
} else if (saveState is UpdateProfessionalInfoSuccess) {
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Información actualizada correctamente')),
|
||||
);
|
||||
} else if (saveState is UpdateProfessionalInfoFailure) {
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No se pudo guardar. Revisa tu conexión e inténtalo de nuevo.')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: context.bg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Perfil Profesional'),
|
||||
@@ -102,9 +125,32 @@ class _ProfessionalProfileScreenState
|
||||
);
|
||||
}
|
||||
}
|
||||
if (state is ProfessionalStateFailure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 40, color: context.subtle),
|
||||
const SizedBox(height: 12),
|
||||
const Text('No se pudo cargar tu perfil profesional'),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: () => context
|
||||
.read<ProfessionalBloc>()
|
||||
.add(const UpdateProfessionalEvent(isProModeActive: true)),
|
||||
child: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: Text('Vuelve atrás'));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +174,7 @@ class _ProfessionalProfileScreenState
|
||||
deliveryValue = true;
|
||||
}
|
||||
_rateController.text = proInfo.rate;
|
||||
_slotDurationMinutes = proInfo.slotDurationMinutes;
|
||||
loadFinish = true;
|
||||
}
|
||||
|
||||
@@ -261,23 +308,39 @@ class _ProfessionalProfileScreenState
|
||||
color: _kPrimary)),
|
||||
),
|
||||
),
|
||||
child: _scheduleRows(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_slotDurationDropdown(context),
|
||||
const SizedBox(height: 8),
|
||||
_scheduleRows(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ElevatedButton(
|
||||
onPressed: _save,
|
||||
onPressed: _isSaving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _kPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: _kPrimary.withOpacity(0.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Guardar cambios',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('Guardar cambios',
|
||||
style:
|
||||
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -481,6 +544,37 @@ class _ProfessionalProfileScreenState
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _slotDurationDropdown(BuildContext context) {
|
||||
const options = [15, 20, 30, 45, 60, 90, 120];
|
||||
final value = options.contains(_slotDurationMinutes)
|
||||
? _slotDurationMinutes
|
||||
: 120;
|
||||
return DropdownButtonFormField<int>(
|
||||
value: value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Duración de cada cita',
|
||||
prefixIcon: Icon(Icons.timer_outlined, color: context.muted),
|
||||
filled: true,
|
||||
fillColor: context.isDark
|
||||
? Colors.white.withOpacity(0.05)
|
||||
: Colors.black.withOpacity(0.04),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
items: options
|
||||
.map((m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text(m < 60 ? '$m min' : '${m ~/ 60}h${m % 60 == 0 ? '' : ' ${m % 60}min'}'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _slotDurationMinutes = v);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scheduleRows(BuildContext context) {
|
||||
final days = <_DayEntry>[
|
||||
_DayEntry('Lunes', schedules.monday),
|
||||
@@ -585,14 +679,12 @@ class _ProfessionalProfileScreenState
|
||||
schedules: schedules,
|
||||
ratePreferences: rateValue,
|
||||
rate: _rateController.text,
|
||||
slotDurationMinutes: _slotDurationMinutes,
|
||||
));
|
||||
context.read<ProfessionalProfileBloc>().add(
|
||||
UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path));
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Información actualizada correctamente')),
|
||||
);
|
||||
if (_imageFile != null) {
|
||||
context.read<ProfessionalProfileBloc>().add(
|
||||
UpdateProfessionalBannerInfo(fileBanner: _imageFile!.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user