Files
prosappco/lib/screens/user/user_map_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 0a8e5d11a2 fix(agenda): one slot generator for professional and patient
The two calendars each built their own list of hours. The patient's
enforced a 3-hour booking lead time; the professional's did not, so at
15:00 an 08:00-18:00 agenda reported "3 libres" (15, 16, 17) that no
patient could actually take.

Both now go through SlotGenerator. The professional still sees the
near-term hours (blocking the next hour is legitimate) but they are
labelled "Sin reserva" and excluded from the "libres" count, so the
number on his agenda means what the patient sees.

Also in this pass:

- endOf() clamps the slot end at 23:59; a 120-minute slot booked at
  23:00 was sending "25:00" to the backend.
- ScheduleEntity.copyWith can set an hour back to null, so returning a
  day to jornada continua no longer keeps the split hours around.
- Removed the Firebase-era schedule map ('habilitado', 'range1Hour1')
  together with the dead ProfessionalEntity.fromDocument that fed it.
- Settings loads guard on mounted and swallow failures instead of
  calling setState after dispose.
- "Pedir cita" says what is missing instead of doing nothing.

Tests: 9 passing, including the clamp and the empty/inverted ranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:17:03 -05:00

879 lines
34 KiB
Dart

import 'dart:async';
import 'package:prosappco/utils/slot_generator.dart';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:injector/injector.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/my_user_bloc/my_user_bloc.dart';
import 'package:prosappco/blocs/notification_bloc/notification_bloc.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart';
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/constansts.dart';
import 'package:prosappco/local_notifications/local_notifications.dart';
import 'package:prosappco/screens/lists/professional_list_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/user/user_service_screen.dart';
import 'package:prosappco/utils/nominatim_geocoder.dart';
import 'package:prosappco/utils/service_day_param.dart';
import 'package:prosappco/utils/version_utils.dart';
import 'package:service_repository/service_repository.dart';
import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart';
class UserMapScreen extends StatefulWidget {
const UserMapScreen({super.key});
@override
State<UserMapScreen> createState() => _UserMapScreenState();
}
class _UserMapScreenState extends State<UserMapScreen> {
final Completer<GoogleMapController> _mapController =
Completer<GoogleMapController>();
LatLng? _currentP;
final TextEditingController _addressController = TextEditingController();
final TextEditingController _observationController = TextEditingController();
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? settings;
String appVersion = '';
final DateFormat formatter = DateFormat('dd/MM/yyyy');
LatLng coordenadas = const LatLng(7.1253900, -73.1198000);
DateTime? fechaSeleccionada;
TimeOfDay? horaSeleccionada;
ServiceLocationPreferences? serviceLocationPreference;
UserProfessional? profesionalSeleccionado;
Map<PolylineId, Polyline> polylines = {};
bool isClearButtonVisible = false;
bool isLoading = false;
Set<Marker> markers = {};
BitmapDescriptor? _markerIcon;
final Uri _urlIos =
Uri.parse('https://apps.apple.com/co/app/prosapp/id6469028900');
final Uri _urlAndroid = Uri.parse(
'https://play.google.com/store/apps/details?id=com.prosapp.prosapp');
@override
void initState() {
super.initState();
_loadSettings();
getLocation();
if (Platform.isAndroid) {
BitmapDescriptor.fromAssetImage(
const ImageConfiguration(size: Size(2, 2)),
'images/pro_marke_android.png',
).then((icon) {
setState(() {
_markerIcon = icon;
});
});
} else {
BitmapDescriptor.fromAssetImage(
const ImageConfiguration(size: Size(1, 1)),
'images/pro_marke.png',
).then((icon) {
setState(() {
_markerIcon = icon;
});
});
}
}
// void requestNotificationPermission() async {
// context.read<NotificationBloc>().requestPermission();
// }
_loadSettings() {
settingRepository.getSettings().then(
(value) => setState(() {
settings = value;
_getAppVersion();
}),
);
}
void _getAppVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
String version = packageInfo.version;
if (mounted) {
setState(() {
appVersion = version;
});
}
if (settings != null) {
_checkForUpdate(settings!);
}
}
void _checkForUpdate(SettingEntity settings) {
if (Platform.isAndroid) {
if (isUpdateRequired(appVersion, settings.versionAndroid)) {
showDialog(
context: context,
builder: (context) {
return WillPopScope(
onWillPop: () async {
return false;
},
child: Center(
child: AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
title: const Text(
"Debes Actualizar la Aplicación",
style: TextStyle(fontWeight: FontWeight.bold),
),
content: const Text(
"Tienes una versión desactualizada de nuestra aplicación. Te recomendamos actualizarla para disfrutar de las últimas características y mejoras.",
),
actions: [
TextButton(
child: const Text(
"Actualizar",
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 20),
),
onPressed: () {
launchUrl(_urlAndroid);
},
),
],
),
),
);
},
);
}
}
if (Platform.isIOS) {
if (isUpdateRequired(appVersion, settings.versionIos)) {
showDialog(
context: context,
builder: (context) {
return WillPopScope(
onWillPop: () async {
return false;
},
child: Center(
child: AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
title: const Text(
"Debes Actualizar la Aplicación",
style: TextStyle(fontWeight: FontWeight.bold),
),
content: const Text(
"Tienes una versión desactualizada de nuestra aplicación. Te recomendamos actualizarla para disfrutar de las últimas características y mejoras.",
),
actions: [
TextButton(
child: const Text(
"Actualizar",
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 20),
),
onPressed: () {
launchUrl(_urlIos);
},
),
],
),
),
);
},
);
}
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<ServiceBloc>(
create: (context) => Injector.appInstance.get<ServiceBloc>(),
child: BlocConsumer<ServiceBloc, ServiceState>(
listener: (context, serviceState) {
if (serviceState is CreateServiceLoading) {
isLoading = true;
}
if (serviceState is CreateServiceFailure) {
isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'No pudimos agendar tu cita. Revisa tu conexión e inténtalo otra vez.'),
));
}
if (serviceState is CreateServiceSuccess) {
isLoading = false;
final token = profesionalSeleccionado?.myUser.token;
if (token != null) {
LocalNotifications.sendPushNotification(
token,
'Nuevo servicio',
'Tienes una nueva solicitud de servicio pendiente',
);
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
Navigator.push(
context,
CupertinoPageRoute(
builder: (BuildContext context) {
return UserServiceScreen(serviceId: serviceState.serviceId);
},
),
);
}
},
builder: (context, state) {
return BlocBuilder<MyUserBloc, MyUserState>(
builder: (context, myUserState) {
return Column(
children: [
Expanded(
child: Stack(
children: [
GoogleMap(
myLocationEnabled: true,
polylines: Set<Polyline>.of(polylines.values),
onMapCreated: (GoogleMapController controller) {
_mapController.complete(controller);
},
markers: {
...markers,
Marker(
markerId: const MarkerId('currentLocation'),
position: _currentP ?? const LatLng(0, 0),
),
},
onCameraIdle: () {
getLocationName(
coordenadas.latitude, coordenadas.longitude)
.then((value) => setState(() {
_addressController.text = value;
}));
},
onCameraMove: (position) {
if (serviceLocationPreference !=
ServiceLocationPreferences.office) {
coordenadas = position.target;
}
},
initialCameraPosition: const CameraPosition(
target: LatLng(7.1253900, -73.1198000),
zoom: 16,
),
myLocationButtonEnabled: false,
),
Positioned(
top: 10,
left: 0,
child: Builder(
builder: (context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
shape: const CircleBorder(),
elevation: 3,
minimumSize: const Size(50, 50),
),
child: Icon(
Icons.menu,
color: Colors.black,
size: context.select(
(NotificationBloc bloc) =>
bloc.state.status.index > 0 ? 35 : 35,
),
),
onPressed: () {
Scaffold.of(context).openDrawer();
},
);
},
),
),
const Positioned(
bottom: 30,
right: 0,
left: 0,
top: 0,
child: Icon(
Icons.location_on,
size: 40,
color: Color(0xFFFF0000),
),
),
Positioned(
top: 10,
right: 10,
child: FloatingActionButton(
onPressed: () async {
try {
Position position = await _determinePosition();
setState(() {
_currentP = LatLng(
position.latitude,
position.longitude,
);
});
_animateCameraToPosition(_currentP!);
} catch (e) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Por favor activa la ubicacion'),
),
);
}
},
elevation: 0,
child: const Icon(
Icons.gps_fixed,
size: 30,
),
),
),
],
),
),
buildBottom(context, myUserState),
],
);
},
);
},
),
);
}
Container buildBottom(BuildContext context, MyUserState state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: TextEditingController(
text: profesionalSeleccionado == null
? ''
: '${profesionalSeleccionado?.myUser.name ?? ''} - ${profesionalSeleccionado?.professionalInfo.profession ?? ''}',
),
readOnly: true,
onTap: () async {
if (state.user?.name == null ||
state.user?.name == '' ||
state.user?.phone == '' ||
state.user?.phone == null) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Por favor completa tu perfil y agrega tu celular'),
action: SnackBarAction(
label: 'Ir al perfil',
onPressed: () {
Navigator.push(context, CupertinoPageRoute(
builder: (context) => const ProfileScreen(),
));
},
),
),
);
return;
}
var datos = await Navigator.push(context,
CupertinoPageRoute(builder: (context) {
return const ProfessionalListScreen();
}));
if (datos != null) {
polylines.clear();
fechaSeleccionada = datos[0];
horaSeleccionada = datos[1];
serviceLocationPreference = datos[2];
profesionalSeleccionado = datos[3];
setState(() {});
if (serviceLocationPreference ==
ServiceLocationPreferences.office) {
_addressController.text =
profesionalSeleccionado?.professionalInfo.address ?? '';
_animateCameraToPosition(
LatLng(
profesionalSeleccionado!.professionalInfo.latitude,
profesionalSeleccionado!.professionalInfo.longitude,
),
);
coordenadas = LatLng(
profesionalSeleccionado!.professionalInfo.latitude,
profesionalSeleccionado!.professionalInfo.longitude,
);
if (_currentP != null) {
getPolylinePoints(
_currentP!,
LatLng(
profesionalSeleccionado!.professionalInfo.latitude,
profesionalSeleccionado!.professionalInfo.longitude,
)).then(
(coordinates) => {
generatePolyLineFromPoints(coordinates),
},
);
}
markers.add(
Marker(
icon: _markerIcon!,
markerId: MarkerId(
profesionalSeleccionado!.professionalInfo.id),
position: LatLng(
profesionalSeleccionado!.professionalInfo.latitude,
profesionalSeleccionado!.professionalInfo.longitude,
),
),
);
} else {}
isClearButtonVisible = true;
}
},
decoration: const InputDecoration(
prefixIcon: Icon(Icons.assignment_ind_rounded),
suffixIcon: Icon(Icons.arrow_drop_down),
hintText: 'Selecciona un profesional',
),
),
const SizedBox(height: 15),
TextField(
readOnly: true,
controller: _addressController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.location_on),
hintText: 'Dirección',
),
),
Visibility(
visible: fechaSeleccionada != null && horaSeleccionada != null,
child: Column(
children: [
const SizedBox(height: 15),
Row(
children: [
Expanded(
child: TextField(
readOnly: true,
controller: TextEditingController(
text: fechaSeleccionada == null
? ''
: formatter.format(fechaSeleccionada!),
),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.calendar_month),
hintText: 'Fecha',
),
),
),
Expanded(
child: TextField(
readOnly: true,
controller: TextEditingController(
text: horaSeleccionada == null
? ''
: ScheduleEntity.getFormatTime(horaSeleccionada),
),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.watch_later_outlined),
hintText: 'Hora',
),
),
),
],
),
const SizedBox(height: 15),
TextField(
controller: _observationController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.message_outlined),
hintText: 'Observaciones',
),
),
],
),
),
const SizedBox(height: 15),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: isLoading
? null
: () {
// Used to `return` in silence when anything was
// missing, so the main button simply did nothing.
final faltan = <String>[
if (profesionalSeleccionado == null)
'un profesional',
if (fechaSeleccionada == null) 'la fecha',
if (horaSeleccionada == null) 'la hora',
];
if (faltan.isNotEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Falta ${faltan.join(' y ')} para pedir la cita'),
));
return;
}
if (state.user?.name == null ||
state.user?.name == '' ||
state.user?.phone == '' ||
state.user?.phone == null) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Por favor completa tu perfil y agrega tu celular'),
),
);
return;
}
if (serviceLocationPreference ==
ServiceLocationPreferences.office) {
if (settings?.tarifas == true) {
context.read<ServiceBloc>().add(
CreateService(
professionalId: profesionalSeleccionado!
.professionalInfo.id,
userId: state.user!.id,
address: profesionalSeleccionado!
.professionalInfo.address,
aditionalAddress: profesionalSeleccionado!
.professionalInfo.aditionalAddress,
latitude: profesionalSeleccionado!
.professionalInfo.latitude,
longitude: profesionalSeleccionado!
.professionalInfo.longitude,
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
),
);
} else {
context.read<ServiceBloc>().add(
CreateService(
professionalId: profesionalSeleccionado!
.professionalInfo.id,
userId: state.user!.id,
address: profesionalSeleccionado!
.professionalInfo.address,
aditionalAddress: profesionalSeleccionado!
.professionalInfo.aditionalAddress,
latitude: profesionalSeleccionado!
.professionalInfo.latitude,
longitude: profesionalSeleccionado!
.professionalInfo.longitude,
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
);
}
} else if (serviceLocationPreference ==
ServiceLocationPreferences.delivery) {
if (settings?.tarifas == true) {
context.read<ServiceBloc>().add(
CreateService(
professionalId: profesionalSeleccionado!
.professionalInfo.id,
userId: state.user!.id,
address: _addressController.text,
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
),
);
} else {
context.read<ServiceBloc>().add(
CreateService(
professionalId: profesionalSeleccionado!.professionalInfo.id,
userId: state.user!.id,
address: _addressController.text,
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
);
}
} else {
// TODO: error inesperado
}
// The form is NOT cleared here and the professional
// is NOT notified here: both used to run right after
// dispatching the event, so a failed booking still
// sent "tienes una nueva solicitud" and wiped the
// patient's date, hour and notes. See the
// CreateServiceSuccess branch in the listener.
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text(
'Pedir cita',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
Visibility(
visible: isClearButtonVisible,
child: const SizedBox(width: 10),
),
Visibility(
visible: isClearButtonVisible,
child: FilledButton(
onPressed: () {
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
setState(() {});
},
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Icon(
CupertinoIcons.xmark,
color: Colors.white,
size: 30,
),
),
),
],
)
],
),
);
}
Future<void> _animateCameraToPosition(LatLng position) async {
final GoogleMapController controller = await _mapController.future;
controller.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: position,
zoom: 17.5,
),
),
);
}
Future<List<LatLng>> getPolylinePoints(
LatLng originP, LatLng destinationP) async {
List<LatLng> polylineCoordinates = [];
PolylinePoints polylinePoints = PolylinePoints();
PolylineResult result = await polylinePoints.getRouteBetweenCoordinates(
GOOGLE_MAPS_API_KEY,
PointLatLng(originP.latitude, originP.longitude),
PointLatLng(destinationP.latitude, destinationP.longitude),
travelMode: TravelMode.driving,
);
if (result.points.isNotEmpty) {
for (var point in result.points) {
polylineCoordinates.add(LatLng(point.latitude, point.longitude));
}
} else {
print(result.errorMessage);
}
return polylineCoordinates;
}
void getLocation() async {
try {
Position position = await _determinePosition();
setState(() {
_currentP = LatLng(
position.latitude,
position.longitude,
);
});
_animateCameraToPosition(_currentP!);
} catch (e) {
log(e.toString());
await _locateByUserCity();
}
}
Future<void> _locateByUserCity() async {
try {
final city = Injector.appInstance.get<MyUserBloc>().state.user?.city;
if (city == null || city.isEmpty) return;
final coords = await NominatimGeocoder.forwardGeocodeCity(city);
if (coords == null) return;
setState(() {
_currentP = LatLng(coords.$1, coords.$2);
});
_animateCameraToPosition(_currentP!);
} catch (e) {
log(e.toString());
}
}
void generatePolyLineFromPoints(List<LatLng> polylineCoordinates) async {
PolylineId id = const PolylineId("poly");
Polyline polyline = Polyline(
polylineId: id,
color: Colors.blue,
points: polylineCoordinates,
width: 6,
);
setState(() {
polylines[id] = polyline;
});
}
Future<String> getLocationName(double latitude, double longitude) async {
String address;
List<Placemark> placemarks =
await placemarkFromCoordinates(latitude, longitude);
Placemark place = placemarks[0];
if (place.thoroughfare != '' || place.subThoroughfare != '') {
address =
"${place.thoroughfare} ${place.subThoroughfare} ${place.subLocality}, ${place.locality}, ${place.administrativeArea}";
} else {
address = '';
}
return address;
}
Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('Location services are disabled');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('Location permission denied');
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error('Location permissions are permanently denied');
}
Position position = await Geolocator.getCurrentPosition();
return position;
}
}