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>
458 lines
16 KiB
Dart
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(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|