Files
prosappco/lib/screens/professional/professional_profile_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

749 lines
25 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() {
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: _rateController.text,
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)),
]),
),
);
}
}