Files
prosappweb/lib/ui/views/professionals_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 aa2fde187a feat: rediseño UI/UX de vista de profesionales
- Grid responsivo (1-4 columnas según ancho)
- Tarjeta con header gradiente, avatar centrado superpuesto y badge de score
- Modo compacto (lista horizontal) en pantallas pequeñas
- Buscador en tiempo real por nombre, profesión o ciudad
- Chips de métodos de pago (Datáfono, Nequi, Transferencia)
- Dark mode completo, avatar con inicial como fallback
- Estado vacío con ícono y mensaje contextual

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 19:51:03 -05:00

554 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:prosapp_web_app/models/usuario_profesional.dart';
import 'package:prosapp_web_app/providers/professionals_provider.dart';
import 'package:prosapp_web_app/providers/theme_provider.dart';
import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:provider/provider.dart';
class ProfessionalsView extends StatefulWidget {
const ProfessionalsView({super.key});
@override
State<ProfessionalsView> createState() => _ProfessionalsViewState();
}
class _ProfessionalsViewState extends State<ProfessionalsView> {
String _search = '';
@override
Widget build(BuildContext context) {
final provider = context.watch<ProfessionalsProvider>();
final isDark = context.watch<ThemeProvider>().isDark;
final filtered = provider.professionals.where((p) {
if (_search.isEmpty) return true;
final q = _search.toLowerCase();
return p.user.name.toLowerCase().contains(q) ||
p.professionalInfo.profession.toLowerCase().contains(q) ||
(p.user.city ?? '').toLowerCase().contains(q);
}).toList();
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
final inputFill = isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9);
final hintColor = isDark ? const Color(0xFF64748B) : const Color(0xFF94A3B8);
return Column(
children: [
// ── Barra de búsqueda ──
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 14),
child: TextField(
onChanged: (v) => setState(() => _search = v),
style: TextStyle(
color: isDark ? Colors.white : const Color(0xFF111827),
fontSize: 14,
),
decoration: InputDecoration(
hintText: 'Buscar por nombre, profesión o ciudad...',
hintStyle: TextStyle(color: hintColor, fontSize: 13),
prefixIcon:
const Icon(Icons.search, color: Color(0xFF42A4EF), size: 20),
suffixIcon: _search.isNotEmpty
? IconButton(
icon: Icon(Icons.close, size: 18, color: hintColor),
onPressed: () => setState(() => _search = ''),
)
: null,
filled: true,
fillColor: inputFill,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
const BorderSide(color: Color(0xFF42A4EF), width: 2),
),
contentPadding:
const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
),
),
),
// ── Lista / grid / estados ──
Expanded(
child: provider.isLoading
? const Center(child: CircularProgressIndicator())
: filtered.isEmpty
? _EmptyState(isDark: isDark, search: _search)
: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final cols = w >= 1200
? 4
: w >= 800
? 3
: w >= 500
? 2
: 1;
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: cols == 1 ? 2.4 : 0.78,
),
itemCount: filtered.length,
itemBuilder: (context, i) => _ProfCard(
data: filtered[i],
isDark: isDark,
cardBg: cardBg,
border: border,
compact: cols == 1,
onTap: () async {
final result = await NavigationService
.navigateToFuture(
'/dashboard/calendar/${filtered[i].user.id}',
);
if (context.mounted) {
Navigator.pop(context, [
filtered[i],
result[0] as DateTime,
result[1] as TimeOfDay,
]);
}
},
),
);
},
),
),
],
);
}
}
// ── Card ─────────────────────────────────────────────────────────────────────
class _ProfCard extends StatelessWidget {
final UsuarioProfesional data;
final bool isDark;
final Color cardBg;
final Color border;
final bool compact;
final VoidCallback onTap;
const _ProfCard({
required this.data,
required this.isDark,
required this.cardBg,
required this.border,
required this.compact,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final textPrimary = isDark ? Colors.white : const Color(0xFF111827);
final textSecondary =
isDark ? const Color(0xFF94A3B8) : const Color(0xFF6B7280);
final hasPic =
data.user.picture != null && data.user.picture!.isNotEmpty;
final score = data.averageScore;
final pm = data.professionalInfo.paymentMethods;
if (compact) {
// Fila horizontal en pantallas pequeñas
return Material(
color: cardBg,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: border),
),
child: Row(
children: [
_Avatar(hasPic: hasPic, picture: data.user.picture, name: data.user.name, size: 60),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(data.user.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: textPrimary)),
const SizedBox(height: 3),
if (data.professionalInfo.profession.isNotEmpty)
Text(data.professionalInfo.profession,
style: TextStyle(
fontSize: 12,
color: const Color(0xFF42A4EF),
fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(children: [
_StarRow(score: score),
const SizedBox(width: 10),
if (data.user.city != null && data.user.city!.isNotEmpty)
Row(children: [
Icon(Icons.location_on_outlined,
size: 11, color: textSecondary),
const SizedBox(width: 2),
Text(data.user.city!,
style: TextStyle(
fontSize: 11, color: textSecondary)),
]),
]),
],
),
),
Icon(Icons.chevron_right, color: textSecondary, size: 20),
],
),
),
),
);
}
// Tarjeta vertical (grid)
return Material(
color: cardBg,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Header con avatar ──
Container(
height: 90,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [const Color(0xFF1E3A5F), const Color(0xFF0D1B3E)]
: [const Color(0xFFDBEAFE), const Color(0xFFBFDBFE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16)),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// Score badge
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.25),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded,
color: Colors.amber, size: 13),
const SizedBox(width: 3),
Text(
score.toStringAsFixed(1),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700),
),
],
),
),
),
// Avatar centrado en la parte inferior del header
Positioned(
bottom: -28,
left: 0,
right: 0,
child: Center(
child: _Avatar(
hasPic: hasPic,
picture: data.user.picture,
name: data.user.name,
size: 56,
border: true,
isDark: isDark,
),
),
),
],
),
),
// ── Contenido ──
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 36, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Nombre
Text(
data.user.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: textPrimary,
),
),
// Profesión
if (data.professionalInfo.profession.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
data.professionalInfo.profession,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF42A4EF),
fontWeight: FontWeight.w600,
),
),
],
// Ciudad
if (data.user.city != null &&
data.user.city!.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on_outlined,
size: 11, color: textSecondary),
const SizedBox(width: 2),
Flexible(
child: Text(
data.user.city!,
style: TextStyle(
fontSize: 11, color: textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
const Spacer(),
// Métodos de pago
if (pm.datafono || pm.nequi || pm.transferencia) ...[
const Divider(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 4,
runSpacing: 4,
children: [
if (pm.datafono)
_PayChip(
label: 'Datáfono',
icon: Icons.credit_card_outlined,
isDark: isDark),
if (pm.nequi)
_PayChip(
label: 'Nequi',
icon: Icons.phone_android_outlined,
isDark: isDark),
if (pm.transferencia)
_PayChip(
label: 'Transferencia',
icon: Icons.account_balance_outlined,
isDark: isDark),
],
),
] else
Text('Sin métodos de pago',
style: TextStyle(
fontSize: 10, color: textSecondary)),
],
),
),
),
],
),
),
),
);
}
}
// ── Widgets auxiliares ────────────────────────────────────────────────────────
class _Avatar extends StatelessWidget {
final bool hasPic;
final String? picture;
final String name;
final double size;
final bool border;
final bool isDark;
const _Avatar({
required this.hasPic,
required this.picture,
required this.name,
required this.size,
this.border = false,
this.isDark = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF42A4EF).withOpacity(0.15),
border: border
? Border.all(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
width: 3)
: null,
),
child: ClipOval(
child: hasPic
? FadeInImage.assetNetwork(
placeholder: 'loader.gif',
image: picture!,
fit: BoxFit.cover,
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: const Color(0xFF42A4EF),
fontSize: size * 0.38,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
class _StarRow extends StatelessWidget {
final double score;
const _StarRow({required this.score});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded, color: Colors.amber, size: 14),
const SizedBox(width: 3),
Text(score.toStringAsFixed(1),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.amber)),
],
);
}
}
class _PayChip extends StatelessWidget {
final String label;
final IconData icon;
final bool isDark;
const _PayChip(
{required this.label, required this.icon, required this.isDark});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF42A4EF).withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: const Color(0xFF42A4EF).withOpacity(0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: const Color(0xFF42A4EF)),
const SizedBox(width: 3),
Text(label,
style: const TextStyle(
fontSize: 9,
color: Color(0xFF42A4EF),
fontWeight: FontWeight.w600)),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
final bool isDark;
final String search;
const _EmptyState({required this.isDark, required this.search});
@override
Widget build(BuildContext context) {
final textPrimary = isDark ? Colors.white : const Color(0xFF111827);
final textSecondary =
isDark ? const Color(0xFF94A3B8) : const Color(0xFF6B7280);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_search_outlined,
size: 64, color: textSecondary.withOpacity(0.4)),
const SizedBox(height: 16),
Text(
search.isNotEmpty
? 'Sin resultados para "$search"'
: 'No hay profesionales disponibles',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimary),
),
const SizedBox(height: 6),
Text(
search.isNotEmpty
? 'Intenta con otro nombre o profesión'
: 'Aún no hay profesionales activos en tu ciudad',
style: TextStyle(fontSize: 13, color: textSecondary),
),
],
),
);
}
}