Files
prosappco/lib/screens/professional/professional_profile_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 8631e6f729 fix: port 7 web features and repair the endless-loading screens
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>
2026-08-24 16:45:42 -05:00

745 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) => setState(() => settings = v),
);
}
@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;
_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)),
]),
),
);
}
}