Files
prosappco/lib/screens/professional/professional_profile_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 6cb2755858 fix(booking): read the live agenda and re-check the slot before confirming
/services/public-calendar also returns the professional's current opening
hours; the client threw them away and drew the day from the snapshot the
search list carried, which can be days old. It now uses the fresh ones.

Between picking an hour and pressing "Pedir cita" another patient can
take the slot. The confirm step asks the agenda again and, if the hour is
gone, says so and clears the selection instead of creating a service the
professional would have to deny. A failed re-check lets the booking
through: a network hiccup should not block a patient.

Also:
- tapping a busy slot with no id explains itself instead of doing nothing
- the profile refuses to save an empty rate with the rate switch on,
  which was reaching the backend as rate: ''

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

769 lines
26 KiB
Dart

import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:injector/injector.dart';
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart';
import 'package:prosappco/blocs/professional_profile_bloc/professional_profile_bloc.dart';
import 'package:prosappco/screens/professional/professional_map_screen.dart';
import 'package:prosappco/screens/professional/professional_schedule_screen.dart';
import 'package:setting_repository/setting_repository.dart';
const _kPrimary = Color(0xFF1565C0);
const _kAccent = Color(0xFF42A4EF);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadowSm =>
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
}
class ProfessionalProfileScreen extends StatefulWidget {
const ProfessionalProfileScreen({super.key});
@override
State<ProfessionalProfileScreen> createState() =>
_ProfessionalProfileScreenState();
}
class _ProfessionalProfileScreenState
extends State<ProfessionalProfileScreen> {
final settingRepository = Injector.appInstance.get<SettingRepository>();
SettingEntity? settings;
XFile? _imageFile;
bool loadFinish = false;
bool officeValue = false;
bool deliveryValue = false;
bool rateValue = false;
final _addressController = TextEditingController();
final _aditionalAddressController = TextEditingController();
final _rateController = TextEditingController();
Schedules schedules = Schedules.empty;
int _slotDurationMinutes = 120;
bool isInit = false;
bool _isSaving = false;
double _longitudeController = 0;
double _latitudeController = 0;
bool isNequiActive = false;
bool isDatafonoActive = false;
bool isTransferActive = false;
@override
void initState() {
super.initState();
settingRepository.getSettings().then((v) {
if (!mounted) return;
setState(() => settings = v);
}).catchError((_) {});
}
@override
void dispose() {
_addressController.dispose();
_aditionalAddressController.dispose();
_rateController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<ProfessionalProfileBloc, ProfessionalProfileState>(
listener: (context, saveState) {
if (saveState is UpdateProfessionalInfoLoading) {
setState(() => _isSaving = true);
} else if (saveState is UpdateProfessionalInfoSuccess) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Información actualizada correctamente')),
);
} else if (saveState is UpdateProfessionalInfoFailure) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo guardar. Revisa tu conexión e inténtalo de nuevo.')),
);
}
},
child: Scaffold(
backgroundColor: context.bg,
appBar: AppBar(
title: const Text('Perfil Profesional'),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: BlocBuilder<ProfessionalBloc, ProfessionalState>(
builder: (context, state) {
if (state is LoadedModeProState) {
if (state.isProModeActive) {
if (state.proInfo == null) {
return const Center(child: CircularProgressIndicator(color: _kPrimary));
}
if (!isInit) {
schedules = state.proInfo!.schedules;
isInit = true;
}
return SingleChildScrollView(
child: _body(context, state.proInfo!),
);
}
}
if (state is ProfessionalStateFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: context.subtle),
const SizedBox(height: 12),
const Text('No se pudo cargar tu perfil profesional'),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context
.read<ProfessionalBloc>()
.add(const UpdateProfessionalEvent(isProModeActive: true)),
child: const Text('Reintentar'),
),
],
),
),
);
}
return const Center(child: Text('Vuelve atrás'));
},
),
),
);
}
Widget _body(BuildContext context, ProfessionalEntity proInfo) {
if (!loadFinish) {
_addressController.text = proInfo.address;
_aditionalAddressController.text = proInfo.aditionalAddress;
_longitudeController = proInfo.longitude;
_latitudeController = proInfo.latitude;
isNequiActive = proInfo.paymentMethods.nequi;
isDatafonoActive = proInfo.paymentMethods.datafono;
isTransferActive = proInfo.paymentMethods.transferencia;
if (proInfo.locationPreferences == LocationPreferences.both) {
officeValue = true;
deliveryValue = true;
} else if (proInfo.locationPreferences == LocationPreferences.office) {
officeValue = true;
deliveryValue = false;
} else if (proInfo.locationPreferences == LocationPreferences.delivery) {
officeValue = false;
deliveryValue = true;
}
_rateController.text = proInfo.rate;
// Restored from the record, not left at its false default: opening the
// profile to fix an address and saving used to switch the rate off.
rateValue = proInfo.ratePreferences;
_slotDurationMinutes = proInfo.slotDurationMinutes;
loadFinish = true;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_bannerHeader(context, proInfo),
const SizedBox(height: 16),
_sectionCard(
context,
icon: Icons.place_outlined,
title: 'Modalidad de servicio',
child: Column(children: [
if (settings?.domicilios ?? false)
_toggleRow(
context,
icon: Icons.delivery_dining_outlined,
title: 'Servicio a domicilio',
subtitle: 'El profesional se desplaza al cliente',
value: deliveryValue,
onChanged: (v) => setState(() {
deliveryValue = v;
if (!deliveryValue) officeValue = true;
}),
),
_toggleRow(
context,
icon: Icons.storefront_outlined,
title: 'Servicio en sitio',
subtitle: 'El cliente asiste al lugar del profesional',
value: officeValue,
onChanged: (v) => setState(() {
if (settings?.domicilios == true) {
officeValue = v;
if (!officeValue) deliveryValue = true;
} else {
officeValue = true;
deliveryValue = false;
}
}),
),
if (officeValue) ...[
const SizedBox(height: 8),
_addressField(context),
],
]),
),
const SizedBox(height: 12),
_sectionCard(
context,
icon: Icons.attach_money_outlined,
title: 'Tarifa',
child: Column(children: [
_toggleRow(
context,
icon: Icons.monetization_on_outlined,
title: 'Mostrar tarifa',
subtitle: 'Visible para los clientes',
value: rateValue,
onChanged: (v) => setState(() {
rateValue = (settings?.tarifas == true) ? v : false;
}),
),
if (rateValue) ...[
const SizedBox(height: 8),
TextField(
controller: _rateController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
prefixIcon:
Icon(Icons.attach_money, color: context.muted),
hintText: 'Valor en COP',
filled: true,
fillColor: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
],
]),
),
const SizedBox(height: 12),
_sectionCard(
context,
icon: Icons.credit_card_outlined,
title: 'Métodos de pago',
child: Wrap(spacing: 8, runSpacing: 8, children: [
_PayChip(
label: 'Datafono',
icon: Icons.credit_card,
active: isDatafonoActive,
onTap: () => setState(() => isDatafonoActive = !isDatafonoActive),
),
_PayChip(
label: 'Nequi',
icon: Icons.phone_android_outlined,
active: isNequiActive,
onTap: () => setState(() => isNequiActive = !isNequiActive),
),
_PayChip(
label: 'Transferencia',
icon: Icons.swap_horiz,
active: isTransferActive,
onTap: () =>
setState(() => isTransferActive = !isTransferActive),
),
]),
),
const SizedBox(height: 12),
_sectionCard(
context,
icon: Icons.calendar_month_outlined,
title: 'Horarios',
trailing: GestureDetector(
onTap: _openScheduleEditor,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: const Text('Editar',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _kPrimary)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_slotDurationDropdown(context),
const SizedBox(height: 8),
_scheduleRows(context),
],
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
disabledBackgroundColor: _kPrimary.withOpacity(0.5),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: _isSaving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Guardar cambios',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 32),
],
);
}
Widget _bannerHeader(BuildContext context, ProfessionalEntity proInfo) {
final pathImageFile = _imageFile?.path;
ImageProvider<Object>? bannerImage;
if (pathImageFile != null && pathImageFile.isNotEmpty) {
bannerImage = FileImage(File(pathImageFile));
} else if (proInfo.bannerPicture.isNotEmpty) {
bannerImage = NetworkImage(proInfo.bannerPicture);
}
return GestureDetector(
onTap: _pickBannerImage,
child: Container(
height: 180,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [_kPrimary, _kAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
image: bannerImage != null
? DecorationImage(image: bannerImage, fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.35), BlendMode.darken))
: null,
),
child: Stack(children: [
Positioned(
bottom: 14,
right: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(20),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.camera_alt_outlined, color: Colors.white, size: 14),
SizedBox(width: 5),
Text('Cambiar banner',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
]),
),
),
if (proInfo.profession.isNotEmpty)
Positioned(
bottom: 14,
left: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white38),
),
child: Text(proInfo.profession,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
),
),
]),
),
);
}
Widget _sectionCard(
BuildContext context, {
required IconData icon,
required String title,
required Widget child,
Widget? trailing,
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: context.shadowSm,
blurRadius: 10,
offset: const Offset(0, 2))
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, size: 17, color: _kPrimary),
),
const SizedBox(width: 10),
Expanded(
child: Text(title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: context.onSurface)),
),
if (trailing != null) trailing,
]),
const SizedBox(height: 14),
child,
]),
),
);
}
Widget _toggleRow(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Row(children: [
Icon(icon, size: 18, color: context.muted),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.onSurface)),
Text(subtitle,
style: TextStyle(fontSize: 11, color: context.muted)),
]),
),
Switch(value: value, onChanged: onChanged, activeColor: _kPrimary),
]);
}
Widget _addressField(BuildContext context) {
return Column(children: [
GestureDetector(
onTap: () async {
final Map<String, dynamic>? mapInfo = await Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const ProfessionalMapScreen()),
);
if (mapInfo != null) {
setState(() {
_addressController.text = mapInfo['address'] ?? '';
_latitudeController = mapInfo['latitude'] ?? 0;
_longitudeController = mapInfo['longitude'] ?? 0;
});
}
},
child: AbsorbPointer(
child: TextField(
controller: _addressController,
decoration: InputDecoration(
prefixIcon:
Icon(Icons.location_on_outlined, color: context.muted),
hintText: 'Dirección',
filled: true,
fillColor: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none),
),
),
),
),
const SizedBox(height: 8),
TextField(
controller: _aditionalAddressController,
decoration: InputDecoration(
prefixIcon: Icon(Icons.house_outlined, color: context.muted),
hintText: 'Piso / Conjunto / Apartamento',
filled: true,
fillColor: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none),
),
),
]);
}
Widget _slotDurationDropdown(BuildContext context) {
const options = [15, 20, 30, 45, 60, 90, 120];
final value = options.contains(_slotDurationMinutes)
? _slotDurationMinutes
: 120;
return DropdownButtonFormField<int>(
value: value,
decoration: InputDecoration(
labelText: 'Duración de cada cita',
prefixIcon: Icon(Icons.timer_outlined, color: context.muted),
filled: true,
fillColor: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
items: options
.map((m) => DropdownMenuItem(
value: m,
child: Text(m < 60 ? '$m min' : '${m ~/ 60}h${m % 60 == 0 ? '' : ' ${m % 60}min'}'),
))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _slotDurationMinutes = v);
},
);
}
Widget _scheduleRows(BuildContext context) {
final days = <_DayEntry>[
_DayEntry('Lunes', schedules.monday),
_DayEntry('Martes', schedules.tuesday),
_DayEntry('Miércoles', schedules.wednesday),
_DayEntry('Jueves', schedules.thursday),
_DayEntry('Viernes', schedules.friday),
_DayEntry('Sábado', schedules.saturday),
_DayEntry('Domingo', schedules.sunday),
];
return Column(
children: days.map((entry) {
final name = entry.name;
final s = entry.schedule;
final active = s != null && s.enabled;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: active
? const Color(0xFF16A34A)
: context.subtle,
shape: BoxShape.circle),
),
const SizedBox(width: 10),
SizedBox(
width: 80,
child: Text(name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: context.onSurface)),
),
Expanded(
child: Text(
_formatSchedule(s),
style: TextStyle(fontSize: 12, color: context.muted),
overflow: TextOverflow.ellipsis,
),
),
]),
);
}).toList(),
);
}
String _formatSchedule(ScheduleEntity? s) {
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) {
return 'Sin horario';
}
if (s.continuousDay) {
return '${ScheduleEntity.getFormatTime(s.range1Hour1)} - ${ScheduleEntity.getFormatTime(s.range2Hour2)}';
}
if (s.range1Hour2 == null || s.range2Hour1 == null) return 'Sin horario';
return '${ScheduleEntity.getFormatTime(s.range1Hour1)} a ${ScheduleEntity.getFormatTime(s.range1Hour2)} · '
'${ScheduleEntity.getFormatTime(s.range2Hour1)} a ${ScheduleEntity.getFormatTime(s.range2Hour2)}';
}
Future<void> _pickBannerImage() async {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.gallery,
maxHeight: 1000,
maxWidth: 2000,
imageQuality: 40,
);
if (image != null) setState(() => _imageFile = image);
}
Future<void> _openScheduleEditor() async {
final result = await Navigator.push<Schedules>(
context,
CupertinoPageRoute(
builder: (_) =>
ProfessionalScheduleScreen(schedules: schedules)),
);
if (result != null) setState(() => schedules = result);
}
void _save() {
// Saving with the rate switch on and the field empty used to send
// `rate: ''`, and the patient then saw a professional with no price.
if (rateValue) {
final rate = int.tryParse(_rateController.text.trim()) ?? 0;
if (rate <= 0) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Escribe el valor de tu tarifa o desactívala'),
));
return;
}
}
if (_addressController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Escribe la dirección donde atiendes'),
));
return;
}
context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo(
address: _addressController.text,
aditionalAddress: _aditionalAddressController.text,
latitude: _latitudeController,
longitude: _longitudeController,
locationPreferences: officeValue && deliveryValue
? LocationPreferences.both
: officeValue && !deliveryValue
? LocationPreferences.office
: deliveryValue && !officeValue
? LocationPreferences.delivery
: LocationPreferences.office,
paymentMethods: PaymentMethodEntity(
datafono: isDatafonoActive,
nequi: isNequiActive,
transferencia: isTransferActive,
),
schedules: schedules,
ratePreferences: rateValue,
rate: rateValue ? _rateController.text.trim() : '',
slotDurationMinutes: _slotDurationMinutes,
));
if (_imageFile != null) {
context.read<ProfessionalProfileBloc>().add(
UpdateProfessionalBannerInfo(fileBanner: _imageFile!.path));
}
}
}
class _DayEntry {
final String name;
final ScheduleEntity? schedule;
const _DayEntry(this.name, this.schedule);
}
class _PayChip extends StatelessWidget {
final String label;
final IconData icon;
final bool active;
final VoidCallback onTap;
const _PayChip({
required this.label,
required this.icon,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final color = active ? _kPrimary : context.subtle;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: active
? _kPrimary.withOpacity(0.1)
: context.isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.03),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: active ? _kPrimary.withOpacity(0.5) : color.withOpacity(0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (active)
const Padding(
padding: EdgeInsets.only(right: 5),
child: Icon(Icons.check, size: 13, color: _kPrimary),
),
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: color)),
]),
),
);
}
}