Files
prosappco/lib/screens/lists/professional_list_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

458 lines
16 KiB
Dart

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';
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
import 'package:prosappco/screens/lists/city_list_screen.dart';
import 'package:prosappco/screens/user/user_calendar_screen.dart';
import 'package:prosappco/screens/user/user_view_profile_screen.dart';
import 'package:setting_repository/setting_repository.dart';
import 'package:shimmer/shimmer.dart';
class ProfessionalListScreen extends StatefulWidget {
const ProfessionalListScreen({super.key});
@override
State<ProfessionalListScreen> createState() => _ProfessionalListScreenState();
}
class _ProfessionalListScreenState extends State<ProfessionalListScreen> {
final _searchController = TextEditingController();
final professionRepository = Injector.appInstance.get<ProfessionRepository>();
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? settings;
List<String>? _professions;
List<String>? _filteredProfessions;
String? _selectedProfession;
bool _isLoading = true;
double? _lat;
double? _lng;
bool _isLocating = false;
Timer? _debounce;
late final ProfessionalListBloc bloc;
@override
void initState() {
super.initState();
bloc = Injector.appInstance.get<ProfessionalListBloc>();
bloc.add(const ProfessionalListFetch());
_loadSettings();
_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(() {
settings = value;
}),
);
}
void _loadProfessions() {
professionRepository.getProfessions().then((Professions element) {
setState(() {
_professions = element.professions;
_filteredProfessions = _professions;
_isLoading = false;
});
});
}
@override
Widget build(BuildContext context) {
return BlocProvider<ProfessionalListBloc>(
create: (context) => bloc,
child: BlocBuilder<ProfessionalListBloc, ProfessionalListState>(
builder: (context, state) {
return Scaffold(
appBar: AppBar(
actions: [
_selectedProfession != null
? IconButton(
icon: const Icon(
Icons.clear,
color: Colors.red,
size: 32,
),
onPressed: () {
setState(() {
_selectedProfession = null;
});
},
)
: Container()
],
title: _selectedProfession != null
? Text(_selectedProfession!)
: const Text('Selecciona un profesional'),
),
body: Column(
children: [
_isLoading
? _buildShimmerEffect()
: _selectedProfession == null
? Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.grey[400]!,
),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: _selectedProfession,
hint: const Text('Seleccione una profesión'),
items: _filteredProfessions
?.map((String profession) {
return DropdownMenuItem<String>(
value: profession,
child: Text(profession),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
_selectedProfession = newValue;
});
},
),
),
)
: Container(),
_selectedProfession != null
? TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchController.text = value;
});
_onSearchChanged(value);
},
decoration: InputDecoration(
hintText: 'Busca un profesional',
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: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
),
),
)
: Container(),
Expanded(
child: _selectedProfession != null
? _body(state)
: const Center(
child: Text(
'Agrega los filtros para ver los profesionales'),
),
),
],
),
);
},
),
);
}
_body(ProfessionalListState state) {
if (state is ProfessionalListSuccess) {
final List<UserProfessional> filteredUsers;
// TODO: encaso de filtro dañado
// if (_searchController.text.isNotEmpty) {
filteredUsers = state.users
.where((element) => removeDiacritics(element.myUser.name!)
.toLowerCase()
.contains(removeDiacritics(_searchController.text.toLowerCase())))
.where((user) =>
user.myUser.id != (ApiUserRepository.currentUserId ?? ''))
.where(
(user) => user.professionalInfo.profession == _selectedProfession)
.toList();
// } else {
// filteredUsers = state.users
// // .where((user) => user.myUser.id != ApiUserRepository.currentUserId ?? '')
// .toList();
// }
if (filteredUsers.isEmpty) {
return const Center(
child: Text('Ningun profesional coincide con la busqueda'),
);
}
return ListView.builder(
itemCount: filteredUsers.length,
itemBuilder: (BuildContext context, int index) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.withOpacity(0.2)),
),
),
child: ListTile(
onTap: () async {
var datos = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => UserCalendarScreen(
userProfessional: filteredUsers[index],
),
),
);
if (datos != null) {
Navigator.pop(context, datos);
}
},
trailing: GestureDetector(
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => UserViewProfileScreen(
userProfessional: filteredUsers[index],
),
),
);
},
child: filteredUsers[index].professionalInfo.rate.isEmpty ||
settings?.tarifas != true
? const Icon(
Icons.keyboard_arrow_right,
color: Colors.black,
)
: Column(
children: [
const SizedBox(height: 5),
const Icon(
Icons.keyboard_arrow_right,
color: Colors.black,
),
Container(
//fondo azul
decoration: BoxDecoration(
color: Colors.blue[200],
borderRadius: BorderRadius.circular(50),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
child: Text(
'\$${filteredUsers[index].professionalInfo.rate}',
style: const TextStyle(
fontSize: 13,
),
),
),
),
],
),
),
leading: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
image: filteredUsers[index].myUser.picture == null
? null
: DecorationImage(
image: NetworkImage(
filteredUsers[index].myUser.picture ?? ''),
fit: BoxFit.contain,
),
),
child: filteredUsers[index].myUser.picture == null
? Icon(
CupertinoIcons.person,
color: Colors.grey.shade400,
size: 40,
)
: null,
),
title: Row(
children: [
Flexible(
child: Text(
'${filteredUsers[index].myUser.name ?? ''}, ',
overflow: TextOverflow.ellipsis,
),
),
Text(
filteredUsers[index].professionalInfo.profession,
overflow: TextOverflow.ellipsis,
),
],
),
subtitle: Text(
_disponibilidad(filteredUsers[index].professionalInfo) +
(filteredUsers[index].distanceKm != null
? ' · ${filteredUsers[index].distanceKm!.toStringAsFixed(1)} km'
: ''),
style: const TextStyle(
fontSize: 13,
color: Colors.blue,
),
),
),
);
},
);
}
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: ListView.builder(
itemCount: 8,
itemBuilder: (_, __) => Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.withOpacity(0.4)),
),
),
child: ListTile(
trailing: const Icon(
Icons.keyboard_arrow_right,
color: Colors.black,
),
leading: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
title: Container(
width: MediaQuery.of(context).size.width * 0.8,
height: 20.0,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
subtitle: Container(
width: MediaQuery.of(context).size.width * 0.3,
height: 15.0,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
);
}
String _disponibilidad(ProfessionalEntity professionalInfo) {
if (professionalInfo.locationPreferences == LocationPreferences.office) {
return 'Disponibilidad: en sitio.';
}
if (professionalInfo.locationPreferences == LocationPreferences.delivery) {
return 'Disponibilidad: domicilio.';
}
return 'Disponibilidad: domicilio, en sitio.';
}
Widget _buildShimmerEffect() {
return Expanded(
child: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: ListView.builder(
itemCount: 8,
itemBuilder: (_, __) => const ListTileShimmer(),
),
),
);
}
}