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:
Lizandro Guarnizo
2026-08-24 16:45:42 -05:00
co-authored by Claude Opus 5
parent 06a89df690
commit 8631e6f729
86 changed files with 5470 additions and 5291 deletions
@@ -1,8 +1,11 @@
import 'package:user_repository/user_repository.dart';
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:injector/injector.dart';
import 'package:intl_phone_field/helpers.dart';
import 'package:profession_repository/profession_repository.dart';
@@ -34,6 +37,11 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
bool _isLoading = true;
double? _lat;
double? _lng;
bool _isLocating = false;
Timer? _debounce;
late final ProfessionalListBloc bloc;
@override
@@ -46,6 +54,56 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
_loadProfessions();
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
bloc.add(ProfessionalListFetch(
search: value.isEmpty ? null : value,
lat: _lat,
lng: _lng,
));
});
}
Future<void> _useMyLocation() async {
setState(() => _isLocating = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) throw Exception('Location services disabled');
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
throw Exception('Location permission denied');
}
final position = await Geolocator.getCurrentPosition();
_lat = position.latitude;
_lng = position.longitude;
bloc.add(ProfessionalListFetch(
search: _searchController.text.isEmpty ? null : _searchController.text,
lat: _lat,
lng: _lng,
));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo obtener tu ubicación')),
);
} finally {
if (mounted) setState(() => _isLocating = false);
}
}
void _loadSettings() {
settingRepository.getSettings().then(
(value) => setState(() {
@@ -134,14 +192,30 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
setState(() {
_searchController.text = value;
});
_onSearchChanged(value);
},
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: 'Busca un profesional',
prefixIcon: Icon(Icons.search),
enabledBorder: UnderlineInputBorder(
prefixIcon: const Icon(Icons.search),
suffixIcon: _isLocating
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.my_location_outlined),
tooltip: 'Usar mi ubicación',
onPressed: _useMyLocation,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
),
focusedBorder: UnderlineInputBorder(
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
),
),
@@ -174,7 +248,7 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
.toLowerCase()
.contains(removeDiacritics(_searchController.text.toLowerCase())))
.where((user) =>
user.myUser.id != ApiUserRepository.currentUserId ?? '')
user.myUser.id != (ApiUserRepository.currentUserId ?? ''))
.where(
(user) => user.professionalInfo.profession == _selectedProfession)
.toList();
@@ -226,7 +300,7 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
);
},
child: filteredUsers[index].professionalInfo.rate.isEmpty ||
settings?.tarifas == false
settings?.tarifas != true
? const Icon(
Icons.keyboard_arrow_right,
color: Colors.black,
@@ -295,7 +369,10 @@ class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
],
),
subtitle: Text(
_disponibilidad(filteredUsers[index].professionalInfo),
_disponibilidad(filteredUsers[index].professionalInfo) +
(filteredUsers[index].distanceKm != null
? ' · ${filteredUsers[index].distanceKm!.toStringAsFixed(1)} km'
: ''),
style: const TextStyle(
fontSize: 13,
color: Colors.blue,
@@ -66,6 +66,11 @@ class _ProfessionalPendingServiceListScreenState
_RequestCard(info: serviceState.services[index]),
);
}
if (serviceState is CreateServiceFailure) {
return _errorState(context, () => serviceBloc.add(
LoadPendingServicesForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList();
},
),
@@ -73,6 +78,37 @@ class _ProfessionalPendingServiceListScreenState
);
}
Widget _errorState(BuildContext context, VoidCallback onRetry) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
child:
Icon(Icons.error_outline, size: 36, color: context.subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: context.muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: context.subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) {
return Center(
child: Padding(
@@ -78,6 +78,12 @@ class _ProfessionalServiceHistoryListScreenState
},
);
}
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesHistoryForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList();
},
),
@@ -86,6 +92,34 @@ class _ProfessionalServiceHistoryListScreenState
}
}
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -78,6 +78,12 @@ class _ProfessionalServiceListScreenState
},
);
}
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesForProfessional(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList();
},
),
@@ -86,6 +92,34 @@ class _ProfessionalServiceListScreenState
}
}
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -78,6 +78,12 @@ class _UserServiceHistoryListScreenState
},
);
}
if (serviceState is CreateServiceFailure) {
return _errorState(
context,
() => serviceBloc.add(LoadServicesHistoryForUser(
ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList();
},
),
@@ -86,6 +92,34 @@ class _UserServiceHistoryListScreenState
}
}
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
@@ -75,6 +75,10 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
},
);
}
if (serviceState is CreateServiceFailure) {
return _errorState(context, () => serviceBloc.add(
LoadServicesForUser(ApiUserRepository.currentUserId ?? '')));
}
return _shimmerList();
},
),
@@ -83,6 +87,34 @@ class _UserServiceListScreenState extends State<UserServiceListScreen> {
}
}
Widget _errorState(BuildContext context, VoidCallback onRetry) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 36, color: subtle),
),
const SizedBox(height: 16),
Text('No se pudo cargar la información',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: muted)),
const SizedBox(height: 6),
Text('Revisa tu conexión e inténtalo de nuevo.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: subtle, height: 1.5)),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')),
]),
),
);
}
Widget _emptyState(BuildContext context) {
final subtle = Theme.of(context).colorScheme.onSurface.withOpacity(0.35);
final muted = Theme.of(context).colorScheme.onSurface.withOpacity(0.55);