Files
prosappweb/lib/ui/views/professional_profile_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 ef8ccb9aee Redesign professional profile UI + sidebar mode indicator
- professional_profile_view: gradient header card with avatar overlay,
  sectioned cards (service modality, address, rate, payment chips, schedule),
  animated payment method chips, full-width save button with loading state
- sidebar: teal-dark background in pro mode vs navy in user mode,
  animated mode indicator pill, muted toggle button in pro mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:34:25 -05:00

848 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:prosapp_web_app/models/location_preferences.dart';
import 'package:prosapp_web_app/models/schedules_entity.dart';
import 'package:prosapp_web_app/models/usuario.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
import 'package:prosapp_web_app/providers/professional_provider.dart';
import 'package:prosapp_web_app/providers/profile_form_provider.dart';
import 'package:prosapp_web_app/providers/settings_provider.dart';
import 'package:prosapp_web_app/router/router.dart';
import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// ── Constants ──────────────────────────────────────────────────────────────
const _kPrimary = Color(0xFF1565C0);
const _kAccent = Color(0xFF42A4EF);
const _kBg = Color(0xFFF4F6FA);
const _kCard = Colors.white;
// ── Entry ──────────────────────────────────────────────────────────────────
class ProfessionalProfileView extends StatefulWidget {
const ProfessionalProfileView({super.key});
@override
State<ProfessionalProfileView> createState() =>
_ProfessionalProfileViewState();
}
class _ProfessionalProfileViewState extends State<ProfessionalProfileView> {
@override
void initState() {
super.initState();
final auth = Provider.of<AuthProvider>(context, listen: false);
final pfp = Provider.of<ProfileFormProvider>(context, listen: false);
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
pfp.user = auth.user;
Provider.of<ProfessionalProvider>(context, listen: false)
.getProfessional(auth.user!.id)
.then(fp.setProfesional);
}
@override
Widget build(BuildContext context) {
return Container(
color: _kBg,
child: ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 32),
children: const [_ProfileHeader(), _ProfileForm()],
),
);
}
}
// ── Profile header card ───────────────────────────────────────────────────
class _ProfileHeader extends StatelessWidget {
const _ProfileHeader();
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context).user!;
final pfp = Provider.of<ProfileFormProvider>(context);
final fp = Provider.of<ProfessionalFormProvider>(context);
final picture = pfp.user?.picture;
final profession = fp.profesional?.profession;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 0),
decoration: BoxDecoration(
color: _kCard,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 16,
offset: const Offset(0, 4),
)
],
),
child: Column(
children: [
// Banner gradient
Container(
height: 110,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [_kAccent, _kPrimary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius:
BorderRadius.vertical(top: Radius.circular(16)),
),
),
// Avatar overlapping the banner
Transform.translate(
offset: const Offset(0, -44),
child: Column(
children: [
Stack(
alignment: Alignment.bottomRight,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _kCard, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 8,
)
],
),
child: ClipOval(
child: picture != null && picture.isNotEmpty
? FadeInImage.assetNetwork(
placeholder: 'loader.gif',
image: picture,
fit: BoxFit.cover,
)
: Container(
color: _kAccent.withOpacity(0.15),
child: const Icon(Icons.person,
size: 44, color: _kAccent),
),
),
),
_CameraButton(
onTap: () async {
final result = await FilePicker.platform
.pickFiles(withData: true);
if (result == null) return;
final bytes = result.files.first.bytes;
if (bytes == null) return;
if (!context.mounted) return;
NotificationsService.showBusyIndicator(context);
await pfp.uploadPicture(bytes);
if (!context.mounted) return;
Provider.of<AuthProvider>(context, listen: false)
.refreshUser();
Navigator.pop(context);
},
),
],
),
const SizedBox(height: 8),
Text(
auth.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF1E293B),
),
),
if (profession != null && profession.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 3),
decoration: BoxDecoration(
color: _kAccent.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
profession,
style: const TextStyle(
color: _kPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 16),
],
),
),
],
),
),
),
);
}
}
class _CameraButton extends StatelessWidget {
final VoidCallback onTap;
const _CameraButton({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: _kPrimary,
shape: BoxShape.circle,
border: Border.all(color: _kCard, width: 2),
),
child: const Icon(Icons.camera_alt, size: 14, color: Colors.white),
),
);
}
}
// ── Form ──────────────────────────────────────────────────────────────────
class _ProfileForm extends StatefulWidget {
const _ProfileForm();
@override
State<_ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState extends State<_ProfileForm> {
bool _delivery = false;
bool _office = false;
bool _rateEnabled = false;
bool _saving = false;
bool _first = true;
@override
Widget build(BuildContext context) {
final pfp = Provider.of<ProfileFormProvider>(context);
final settings = Provider.of<SettingsProvider>(context).settings;
return Consumer<ProfessionalFormProvider>(builder: (context, fp, _) {
if (fp.profesional == null || settings == null) {
return const Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator(color: _kPrimary)),
);
}
final pro = fp.profesional!;
final user = pfp.user!;
if (_first) {
_first = false;
_rateEnabled = settings.tarifas ? pro.ratePreferences : false;
if (pro.locationPreferences == LocationPreferences.both) {
_delivery = true;
_office = true;
} else if (pro.locationPreferences == LocationPreferences.office) {
_office = true;
} else {
_delivery = true;
}
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Form(
key: fp.profileFormKey,
autovalidateMode: AutovalidateMode.always,
child: Column(
children: [
// ── Modalidad ──────────────────────────────────────────
_SectionCard(
icon: Icons.tune_outlined,
title: 'Modalidad de servicio',
child: Column(
children: [
_ToggleRow(
icon: Icons.delivery_dining_outlined,
title: 'Servicio a domicilio',
subtitle: 'Vas donde está el cliente',
value: _delivery,
onChanged: settings.domicilios
? (v) => setState(() {
_delivery = v;
if (!v) _office = true;
})
: null,
),
const _Divider(),
_ToggleRow(
icon: Icons.store_mall_directory_outlined,
title: 'Servicio en consultorio / sitio',
subtitle: 'El cliente viene a tu local',
value: _office,
onChanged: (v) => setState(() {
if (settings.domicilios == true) {
_office = v;
if (!v) _delivery = true;
} else {
_office = true;
_delivery = false;
}
}),
),
],
),
),
// ── Dirección ─────────────────────────────────────────
if (_office)
_SectionCard(
icon: Icons.location_on_outlined,
title: 'Dirección del consultorio',
child: Column(
children: [
_Field(
initial: pro.address,
hint: 'Calle 123 # 45-67',
label: 'Dirección principal',
icon: Icons.map_outlined,
onChanged: (v) =>
fp.copyProfesionalWith(address: v),
),
const SizedBox(height: 12),
_Field(
initial: pro.aditionalAddress,
hint: 'Piso 2, Consultorio 204',
label: 'Piso / Apartamento / Conjunto',
icon: Icons.apartment_outlined,
onChanged: (v) =>
fp.copyProfesionalWith(aditionalAddress: v),
),
],
),
),
// ── Tarifa ────────────────────────────────────────────
if (settings.tarifas)
_SectionCard(
icon: Icons.payments_outlined,
title: 'Tarifa del servicio',
child: Column(
children: [
_ToggleRow(
icon: Icons.attach_money,
title: 'Mostrar tarifa en el perfil',
subtitle:
'Los clientes verán el precio por consulta',
value: _rateEnabled,
onChanged: (v) =>
setState(() => _rateEnabled = v),
),
if (_rateEnabled) ...[
const _Divider(),
_Field(
initial: pro.rate,
hint: '50000',
label: 'Tarifa (COP)',
icon: Icons.monetization_on_outlined,
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) {
return 'La tarifa es obligatoria';
}
if (!RegExp(r'^[0-9]+$').hasMatch(v)) {
return 'Solo números';
}
return null;
},
onChanged: (v) =>
fp.copyProfesionalWith(rate: v),
),
],
],
),
),
// ── Métodos de pago ───────────────────────────────────
_SectionCard(
icon: Icons.credit_card_outlined,
title: 'Métodos de pago',
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
_PayChip(
icon: Icons.point_of_sale_outlined,
label: 'Datáfono',
selected: pro.paymentMethods.datafono,
onTap: () => fp.copyProfesionalWith(
paymentMethods: pro.paymentMethods.copyWith(
datafono: !pro.paymentMethods.datafono,
),
),
),
_PayChip(
icon: Icons.phone_android_outlined,
label: 'Nequi',
selected: pro.paymentMethods.nequi,
onTap: () => fp.copyProfesionalWith(
paymentMethods: pro.paymentMethods
.copyWith(nequi: !pro.paymentMethods.nequi),
),
),
_PayChip(
icon: Icons.account_balance_outlined,
label: 'Transferencia',
selected: pro.paymentMethods.transferencia,
onTap: () => fp.copyProfesionalWith(
paymentMethods: pro.paymentMethods.copyWith(
transferencia:
!pro.paymentMethods.transferencia,
),
),
),
],
),
),
// ── Horarios ──────────────────────────────────────────
_SectionCard(
icon: Icons.schedule_outlined,
title: 'Horarios de atención',
trailing: TextButton.icon(
onPressed: () => NavigationService.replaceTo(
Flurorouter.professionalScheduleRoute),
icon: const Icon(Icons.edit_outlined,
size: 16, color: _kPrimary),
label: const Text('Editar',
style:
TextStyle(color: _kPrimary, fontSize: 13)),
),
child: Column(
children: _buildScheduleRows(pro.schedules),
),
),
// ── Botón guardar ─────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _saving
? null
: () async {
setState(() => _saving = true);
LocationPreferences lp;
if (_delivery && _office) {
lp = LocationPreferences.both;
} else if (_delivery) {
lp = LocationPreferences.delivery;
} else {
lp = LocationPreferences.office;
}
fp.copyProfesionalWith(
locationPreferences: lp,
ratePreferences: _rateEnabled,
);
await fp.updateProfesionalProfileInfo(user.id);
if (mounted) setState(() => _saving = false);
},
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
disabledBackgroundColor:
_kPrimary.withOpacity(0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Guardar cambios',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600),
),
),
),
),
const SizedBox(height: 16),
],
),
),
),
);
});
}
List<Widget> _buildScheduleRows(dynamic schedules) {
const days = [
('Lunes', 'monday'),
('Martes', 'tuesday'),
('Miércoles', 'wednesday'),
('Jueves', 'thursday'),
('Viernes', 'friday'),
('Sábado', 'saturday'),
('Domingo', 'sunday'),
];
return days.asMap().entries.map((entry) {
final i = entry.key;
final (name, field) = entry.value;
final ScheduleEntity? sch = switch (field) {
'monday' => schedules.monday,
'tuesday' => schedules.tuesday,
'wednesday' => schedules.wednesday,
'thursday' => schedules.thursday,
'friday' => schedules.friday,
'saturday' => schedules.saturday,
_ => schedules.sunday,
};
final time = _formatTime(sch);
final active = sch?.enabled == true;
return Column(
children: [
if (i > 0) const _Divider(),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: active
? const Color(0xFF1E293B)
: Colors.grey.shade400,
),
),
),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? Colors.green.shade400
: Colors.grey.shade300,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
time,
style: TextStyle(
fontSize: 12,
color: active
? const Color(0xFF475569)
: Colors.grey.shade400,
),
),
),
],
),
),
],
);
}).toList();
}
String _formatTime(ScheduleEntity? s) {
if (s == null || !s.enabled) return 'No disponible';
if (s.range1Hour1 == null || s.range2Hour2 == null) {
return 'Sin horario definido';
}
if (s.continuousDay) {
return '${ScheduleEntity.getFormatTime(s.range1Hour1)} ${ScheduleEntity.getFormatTime(s.range2Hour2)}';
}
if (s.range1Hour2 == null || s.range2Hour1 == null) {
return 'Sin horario definido';
}
return '${ScheduleEntity.getFormatTime(s.range1Hour1)}${ScheduleEntity.getFormatTime(s.range1Hour2)} · ${ScheduleEntity.getFormatTime(s.range2Hour1)}${ScheduleEntity.getFormatTime(s.range2Hour2)}';
}
}
// ── Reusable widgets ──────────────────────────────────────────────────────
class _SectionCard extends StatelessWidget {
final IconData icon;
final String title;
final Widget child;
final Widget? trailing;
const _SectionCard({
required this.icon,
required this.title,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
decoration: BoxDecoration(
color: _kCard,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 12,
offset: const Offset(0, 2),
)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 0),
child: 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: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Color(0xFF1E293B),
),
),
),
if (trailing != null) trailing!,
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 14),
child: child,
),
],
),
);
}
}
class _ToggleRow extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
const _ToggleRow({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
this.onChanged,
});
@override
Widget build(BuildContext context) {
final enabled = onChanged != null;
return Opacity(
opacity: enabled ? 1.0 : 0.45,
child: Row(
children: [
Icon(icon,
size: 20,
color: value ? _kPrimary : Colors.grey.shade400),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
Text(
subtitle,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8),
),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeColor: _kPrimary,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
);
}
}
class _PayChip extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
const _PayChip({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: selected
? _kPrimary.withOpacity(0.08)
: Colors.grey.shade50,
border: Border.all(
color: selected ? _kPrimary : Colors.grey.shade200,
width: 1.5,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon,
size: 18,
color: selected ? _kPrimary : Colors.grey.shade400),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected ? _kPrimary : Colors.grey.shade500,
),
),
const SizedBox(width: 6),
AnimatedOpacity(
opacity: selected ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: const Icon(Icons.check_circle_rounded,
size: 15, color: _kPrimary),
),
],
),
),
);
}
}
class _Field extends StatelessWidget {
final String? initial;
final String hint;
final String label;
final IconData icon;
final TextInputType? keyboardType;
final String? Function(String?)? validator;
final ValueChanged<String> onChanged;
const _Field({
required this.initial,
required this.hint,
required this.label,
required this.icon,
required this.onChanged,
this.keyboardType,
this.validator,
});
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: initial,
keyboardType: keyboardType,
validator: validator,
onChanged: onChanged,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon:
Icon(icon, size: 18, color: Colors.grey.shade500),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
labelStyle: const TextStyle(
fontSize: 13,
color: Color(0xFF64748B),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade200),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade200),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _kPrimary, width: 1.5),
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) =>
Divider(height: 16, color: Colors.grey.shade100);
}