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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user