Files
prosappco/lib/screens/lists/professional_list_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 389f876cfa
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run
fix: repair the booking flow end to end
Verified the real contracts against the backend before changing anything.

Chat (three defects, one root cause):
- every chat endpoint is keyed by the chat id, not the service id. The app
  called POST /chat/start, threw away the id it returned and kept using the
  service id, so every later request 404'd.
- messages arrive as {data, meta}; reading the body as a bare list threw and
  surfaced as an empty conversation.
- the bloc created the chat and then never emitted ChatLoaded (the else hung
  off `if (chat == null)`), leaving a permanent spinner. Sending a message
  emitted nothing at all, so it vanished until reopening.
Messages now render optimistically and roll back if the send fails, and the
screen distinguishes "loading" from "could not open" with a retry.

Appointments:
- a null range1_hour2 parsed as 00:00, so new appointments were born
  "Caducado" and every action was hidden. It now falls back to the start time.
- service requests validate the HTTP status and tolerate an empty body: a 4xx
  was treated as success and a 204 as failure.
- creating a service with no id in the response no longer reports success and
  navigates to a service that does not exist.
- dispatching LoadService from build() looped forever on failure; the three
  detail screens now load once and offer a retry.

Ratings:
- both sides read userScored, so only one of the two could ever rate. The
  client side now reads professionalScored.
- the screen closed before the request finished, killing the provider mid
  flight while addComment swallowed every error. It now waits for confirmation.
- score/reputation parsing tolerates integers and numeric strings instead of
  emptying the review list.

Also: guarded map lookups in ScoreBloc, a nullable name in the search list,
and error states with retry where a failure used to shimmer forever.

Verified against the backend: `accepted` is the status the API expects, so the
suspected spelling bug was a false alarm and was left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:47:54 -05:00

486 lines
17 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) {
if (!mounted) return;
setState(() {
_professions = element.professions;
_filteredProfessions = _professions;
_isLoading = false;
});
}).catchError((e) {
// Without this the shimmer never stopped and the dropdown stayed empty.
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudieron cargar las profesiones')),
);
});
}
@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 ProfessionalListFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.grey),
const SizedBox(height: 12),
const Text('No se pudo cargar la lista', textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => bloc.add(const ProfessionalListFetch()),
child: const Text('Reintentar'),
),
],
),
),
);
}
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(),
),
),
);
}
}