import 'package:flutter/material.dart'; import '../../core/service_locator.dart'; import '../../models/member.dart'; import '../../services/member_repository.dart'; class MembersScreen extends StatefulWidget { const MembersScreen({super.key}); @override State createState() => _MembersScreenState(); } class _MembersScreenState extends State { final _service = memberRepository(); Member? _member; bool _loading = true; String? _error; @override void initState() { super.initState(); _load(_service.first()); } void _handleAction(String action) { final labels = { 'receipts': 'Recibos anteriores — pendiente de implementar', 'edit': 'Editar abonado — pendiente de implementar', 'add_family': 'Añadir familiar — pendiente de implementar', 'retain': 'Retener abonado — pendiente de implementar', 'unregister': 'Dar de baja — pendiente de implementar', }; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(labels[action] ?? action), duration: const Duration(seconds: 2)), ); } Future _load(Future future) async { setState(() { _loading = true; _error = null; }); try { final m = await future; setState(() { _member = m; _loading = false; }); } catch (e) { setState(() { _error = e.toString(); _loading = false; }); } } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _NavBar( member: _member, loading: _loading, service: _service, onFirst: () => _load(_service.first()), onPrev: () { if (_member != null) _load(_service.prev(_member!.id)); }, onNext: () { if (_member != null) _load(_service.next(_member!.id)); }, onLast: () => _load(_service.last()), onSearch: (id) => _load(_service.show(id)), onAction: _handleAction, ), const Divider(height: 1), Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? _ErrorView(error: _error!) : _member == null ? const Center(child: Text('Sin datos')) : _MemberBody(member: _member!), ), ], ); } } // ── Nav bar ────────────────────────────────────────────────────────────────── class _NavBar extends StatelessWidget { final Member? member; final bool loading; final MemberRepository service; final VoidCallback onFirst, onPrev, onNext, onLast; final void Function(int id) onSearch; final void Function(String action) onAction; const _NavBar({ required this.member, required this.loading, required this.service, required this.onFirst, required this.onPrev, required this.onNext, required this.onLast, required this.onSearch, required this.onAction, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final disabled = loading || member == null; return Container( color: cs.surfaceContainerLow, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Expanded( child: _MemberSearch(service: service, onSelected: onSearch), ), const SizedBox(width: 4), IconButton( icon: const Icon(Icons.first_page), onPressed: loading ? null : onFirst, tooltip: 'Primero', ), IconButton( icon: const Icon(Icons.chevron_left), onPressed: loading ? null : onPrev, tooltip: 'Anterior', ), IconButton( icon: const Icon(Icons.chevron_right), onPressed: loading ? null : onNext, tooltip: 'Siguiente', ), IconButton( icon: const Icon(Icons.last_page), onPressed: loading ? null : onLast, tooltip: 'Último', ), const VerticalDivider(width: 16, indent: 8, endIndent: 8), PopupMenuButton( icon: const Icon(Icons.more_vert), tooltip: 'Acciones', enabled: !disabled, onSelected: onAction, itemBuilder: (context) => [ const PopupMenuItem( value: 'receipts', child: ListTile( leading: Icon(Icons.receipt_long_outlined), title: Text('Recibos anteriores'), dense: true, ), ), const PopupMenuItem( value: 'edit', child: ListTile( leading: Icon(Icons.edit_outlined), title: Text('Editar abonado'), dense: true, ), ), const PopupMenuItem( value: 'add_family', child: ListTile( leading: Icon(Icons.person_add_outlined), title: Text('Añadir familiar'), dense: true, ), ), const PopupMenuItem( value: 'retain', child: ListTile( leading: Icon(Icons.pause_circle_outline), title: Text('Retener abonado'), dense: true, ), ), const PopupMenuDivider(), PopupMenuItem( value: 'unregister', child: ListTile( leading: Icon(Icons.person_remove_outlined, color: cs.error), title: Text('Dar de baja', style: TextStyle(color: cs.error)), dense: true, ), ), ], ), ], ), ); } } class _MemberSearch extends StatefulWidget { final MemberRepository service; final void Function(int id) onSelected; const _MemberSearch({required this.service, required this.onSelected}); @override State<_MemberSearch> createState() => _MemberSearchState(); } class _MemberSearchState extends State<_MemberSearch> { @override Widget build(BuildContext context) { return Autocomplete>( displayStringForOption: (entry) => entry.value, optionsBuilder: (textValue) async { if (textValue.text.length < 2) return const []; final results = await widget.service.search(textValue.text); return results.entries.toList(); }, onSelected: (entry) { // key format: 5-digit member_id + 2-digit family index final memberId = int.parse(entry.key.substring(0, entry.key.length - 2)); widget.onSelected(memberId); }, fieldViewBuilder: (context, controller, focusNode, onSubmit) { return TextField( controller: controller, focusNode: focusNode, decoration: const InputDecoration( hintText: 'Buscar abonado...', prefixIcon: Icon(Icons.search), isDense: true, border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), ); }, optionsViewBuilder: (context, onSelected, options) { return Align( alignment: Alignment.topLeft, child: Material( elevation: 4, borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400, maxHeight: 280), child: ListView.builder( shrinkWrap: true, itemCount: options.length, itemBuilder: (context, i) { final entry = options.elementAt(i); return ListTile( dense: true, title: Text(entry.value), onTap: () => onSelected(entry), ); }, ), ), ), ); }, ); } } // ── Error ───────────────────────────────────────────────────────────────────── class _ErrorView extends StatelessWidget { final String error; const _ErrorView({required this.error}); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), const SizedBox(height: 8), Text(error, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], ), ); } } // ── Member body ─────────────────────────────────────────────────────────────── class _MemberBody extends StatelessWidget { final Member member; const _MemberBody({required this.member}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LayoutBuilder(builder: (context, constraints) { if (constraints.maxWidth >= 700) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(flex: 2, child: _InfoCard(member: member)), const SizedBox(width: 12), Expanded(flex: 3, child: _CommentsCard(member: member)), ], ), ); } return Column( children: [ _InfoCard(member: member), const SizedBox(height: 12), _CommentsCard(member: member), ], ); }), const SizedBox(height: 12), _FamiliarsCard(people: member.people), const SizedBox(height: 12), _FichaCard(member: member), ], ), ); } } // ── Info card ───────────────────────────────────────────────────────────────── class _InfoCard extends StatelessWidget { final Member member; const _InfoCard({required this.member}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Card.outlined( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Información', style: Theme.of(context).textTheme.titleMedium), const Divider(height: 20), // Fila principal: Número | Fecha alta | Tipo+Estado | Cuota Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Número Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Número', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: cs.outline)), Text(member.formattedId, style: Theme.of(context).textTheme.headlineSmall), ], ), const SizedBox(width: 20), // Fecha de alta Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Fecha de alta', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: cs.outline)), Text(_fmtDate(member.regDate), style: Theme.of(context).textTheme.bodyLarge), ], ), const SizedBox(width: 20), // Tipo + Estado Expanded( child: Wrap( spacing: 6, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ if (member.type.isNotEmpty) _TypeChip(label: member.type), member.isActive ? _StatusChip( label: 'Activo', color: Colors.green, icon: Icons.check_circle_outline) : _StatusChip( label: 'Baja', color: cs.error, icon: Icons.cancel_outlined), if (member.retenido) _StatusChip( label: 'Retenido', color: Colors.orange, icon: Icons.pause_circle_outline), if (member.remSpecial) _StatusChip( label: 'Remesa especial', color: cs.primary, icon: Icons.star_outline), ], ), ), // Cuota if (member.fee != null) ...[ const SizedBox(width: 20), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('Cuota', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: cs.outline)), Text( '${member.fee!.toStringAsFixed(2)} €', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(color: cs.primary), ), ], ), ], ], ), const SizedBox(height: 14), // Franjas de años _YearStrips(), // Info de baja si está dado de baja if (!member.isActive && member.unregDate != null) ...[ const SizedBox(height: 12), _InfoRow(label: 'Fecha de baja', value: _fmtDate(member.unregDate)), if (member.unregReason.isNotEmpty) _InfoRow(label: 'Motivo', value: member.unregReason), ], ], ), ), ); } } // ── Year strips ─────────────────────────────────────────────────────────────── enum _MonthStatus { unknown, paid, unpaid } class _YearStrips extends StatelessWidget { /// year → month(1–12) → status. Si está vacío todos los meses son amarillo. final Map> data; const _YearStrips({this.data = const {}}); static const _months = [ 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', ]; @override Widget build(BuildContext context) { final now = DateTime.now(); final years = [now.year - 1, now.year, now.year + 1, now.year + 2]; return Column( children: years.map((year) { final yearData = data[year] ?? {}; return Padding( padding: const EdgeInsets.only(bottom: 3), child: Row( children: [ SizedBox( width: 34, child: Text('$year', style: TextStyle( fontSize: 10, color: Theme.of(context).colorScheme.outline)), ), ...List.generate(12, (i) { final status = yearData[i + 1] ?? _MonthStatus.unknown; return Expanded(child: _MonthCell(month: _months[i], status: status)); }), ], ), ); }).toList(), ); } } class _MonthCell extends StatelessWidget { final String month; final _MonthStatus status; const _MonthCell({required this.month, required this.status}); Color _bg(BuildContext context) => switch (status) { _MonthStatus.paid => Colors.green.shade400, _MonthStatus.unpaid => Theme.of(context).colorScheme.error, _MonthStatus.unknown => Colors.amber.shade400, }; @override Widget build(BuildContext context) { return Container( height: 20, margin: const EdgeInsets.only(right: 2), decoration: BoxDecoration( color: _bg(context), borderRadius: BorderRadius.circular(3), ), alignment: Alignment.center, child: Text( month, style: const TextStyle( fontSize: 8, color: Colors.white, fontWeight: FontWeight.w700, height: 1), ), ); } } class _TypeChip extends StatelessWidget { final String label; const _TypeChip({required this.label}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Chip( label: Text(label, style: TextStyle( color: cs.onPrimaryContainer, fontWeight: FontWeight.bold, fontSize: 12)), backgroundColor: cs.primaryContainer, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } } class _StatusChip extends StatelessWidget { final String label; final Color color; final IconData icon; const _StatusChip( {required this.label, required this.color, required this.icon}); @override Widget build(BuildContext context) { return Chip( avatar: Icon(icon, size: 16, color: color), label: Text(label, style: TextStyle(fontSize: 12, color: color)), side: BorderSide(color: color.withValues(alpha: 0.4)), backgroundColor: color.withValues(alpha: 0.08), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } } // ── Comments card ───────────────────────────────────────────────────────────── class _CommentsCard extends StatelessWidget { final Member member; const _CommentsCard({required this.member}); bool get _hasContent => member.comment.isNotEmpty || member.comment2.isNotEmpty || member.comments.isNotEmpty; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Card.outlined( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Comentarios', style: Theme.of(context).textTheme.titleMedium), const Divider(height: 20), if (!_hasContent) Text('Sin comentarios', style: TextStyle(color: cs.outline, fontSize: 13)) else ...[ if (member.comment.isNotEmpty) _CommentBubble( text: member.comment, label: 'Nota heredada', legacy: true), if (member.comment2.isNotEmpty) _CommentBubble(text: member.comment2, label: 'Nota interna'), ...member.comments.map((c) => _CommentBubble( text: c.comment, label: _fmtDate(c.createdAt), )), ], ], ), ), ); } } class _CommentBubble extends StatelessWidget { final String text; final String label; final bool legacy; const _CommentBubble( {required this.text, required this.label, this.legacy = false}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: legacy ? cs.errorContainer.withValues(alpha: 0.4) : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle( fontSize: 11, color: cs.outline, fontWeight: FontWeight.w500)), const SizedBox(height: 4), Text(text, style: const TextStyle(fontSize: 13)), ], ), ); } } // ── Familiars card ──────────────────────────────────────────────────────────── class _FamiliarsCard extends StatelessWidget { final List people; const _FamiliarsCard({required this.people}); @override Widget build(BuildContext context) { return Card.outlined( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Familiares asociados', style: Theme.of(context).textTheme.titleMedium), const Divider(height: 20), if (people.isEmpty) Text('Sin familiares', style: TextStyle( color: Theme.of(context).colorScheme.outline, fontSize: 13)) else LayoutBuilder(builder: (context, constraints) { return SizedBox( width: constraints.maxWidth, child: DataTable( headingRowHeight: 36, dataRowMinHeight: 52, dataRowMaxHeight: 60, columnSpacing: 12, columns: const [ DataColumn(label: Text('Foto')), DataColumn(label: Text('FF')), DataColumn(label: Text('Nombre')), DataColumn(label: Text('DNI')), DataColumn(label: Text('Móvil')), DataColumn(label: Text('Email')), DataColumn(label: Text('Fecha nac.')), DataColumn(label: Tooltip(message: 'Acepta LOPD', child: Icon(Icons.privacy_tip_outlined, size: 16))), DataColumn(label: Tooltip(message: 'Recibir noticias', child: Icon(Icons.mail_outline, size: 16))), DataColumn(label: Tooltip(message: 'Cuestionario de salud', child: Icon(Icons.health_and_safety_outlined, size: 16))), DataColumn(label: Tooltip(message: 'Autorización 1', child: Icon(Icons.verified_user_outlined, size: 16))), DataColumn(label: Tooltip(message: 'Autorización 2', child: Icon(Icons.how_to_reg_outlined, size: 16))), DataColumn(label: Text('')), ], rows: people.map((p) => _personRow(context, p)).toList(), ), ); }), ], ), ), ); } DataRow _personRow(BuildContext context, Person p) { final cs = Theme.of(context).colorScheme; final isPrincipal = p.family == 1; return DataRow(cells: [ // Foto DataCell( p.photoPath != null ? CircleAvatar( radius: 18, backgroundImage: NetworkImage(p.photoPath!), ) : CircleAvatar( radius: 18, backgroundColor: isPrincipal ? cs.primary : cs.surfaceContainerHigh, child: Text(p.initials, style: TextStyle( fontSize: 12, color: isPrincipal ? cs.onPrimary : cs.onSurface)), ), ), // FF — número de familiar DataCell( Tooltip( message: isPrincipal ? 'Familiar principal' : 'Familiar ${p.family}', child: Text( p.family.toString(), style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: isPrincipal ? cs.primary : null, ), ), ), ), DataCell(Text(p.fullName, style: const TextStyle(fontSize: 13))), DataCell(Text(p.dni.isEmpty ? '—' : p.dni, style: const TextStyle(fontSize: 13))), DataCell(Text(p.phone.isEmpty ? '—' : p.phone, style: const TextStyle(fontSize: 13))), DataCell(Text(p.email.isEmpty ? '—' : p.email, style: const TextStyle(fontSize: 13))), DataCell(Text(_fmtDate(p.birthDate.isEmpty ? null : p.birthDate), style: const TextStyle(fontSize: 13))), DataCell(_BoolIcon(p.lopd, 'Acepta LOPD')), DataCell(_BoolIcon(p.newsletter, 'Recibir noticias')), DataCell(_BoolIcon(p.bHealth, 'Cuestionario de salud')), DataCell(_BoolIcon(p.authorized, 'Autorización 1')), DataCell(_BoolIcon(p.authorized2, 'Autorización 2')), // Acciones DataCell( PopupMenuButton( icon: const Icon(Icons.more_vert, size: 18), tooltip: 'Acciones', padding: EdgeInsets.zero, onSelected: (value) { // TODO: implementar acciones de familiar ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$value — pendiente de implementar'), duration: const Duration(seconds: 2), ), ); }, itemBuilder: (context) => [ const PopupMenuItem( value: 'edit', child: ListTile(leading: Icon(Icons.edit_outlined), title: Text('Editar familiar'), dense: true), ), const PopupMenuItem( value: 'photo', child: ListTile(leading: Icon(Icons.camera_alt_outlined), title: Text('Tomar foto'), dense: true), ), const PopupMenuItem( value: 'card', child: ListTile(leading: Icon(Icons.badge_outlined), title: Text('Ver carnet'), dense: true), ), const PopupMenuItem( value: 'password', child: ListTile(leading: Icon(Icons.lock_reset_outlined), title: Text('Resetear contraseña'), dense: true), ), const PopupMenuDivider(), PopupMenuItem( value: 'delete', child: ListTile( leading: Icon(Icons.delete_outline, color: cs.error), title: Text('Borrar familiar', style: TextStyle(color: cs.error)), dense: true, ), ), ], ), ), ]); } } class _BoolIcon extends StatelessWidget { final bool? value; final String tooltip; const _BoolIcon(this.value, this.tooltip); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (value == null) { return Tooltip( message: '$tooltip: desconocido', child: Icon(Icons.help_outline, size: 18, color: cs.outlineVariant), ); } return Tooltip( message: '$tooltip: ${value! ? 'Sí' : 'No'}', child: Icon( value! ? Icons.check_circle : Icons.cancel, size: 18, color: value! ? Colors.green.shade600 : cs.error, ), ); } } // ── Ficha card ──────────────────────────────────────────────────────────────── class _FichaCard extends StatelessWidget { final Member member; const _FichaCard({required this.member}); @override Widget build(BuildContext context) { final hasBank = member.bank.isNotEmpty || member.bankAccount.isNotEmpty; return Card.outlined( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Ficha', style: Theme.of(context).textTheme.titleMedium), const Divider(height: 20), _SectionLabel('Contacto'), const SizedBox(height: 8), Wrap( spacing: 32, runSpacing: 8, children: [ _InfoRow( label: 'Dirección', value: member.address.isEmpty ? '—' : member.address), _InfoRow( label: 'Población', value: member.city.isEmpty ? '—' : member.city), _InfoRow( label: 'C.P.', value: member.zip.isEmpty ? '—' : member.zip), _InfoRow( label: 'Teléfono', value: member.phone.isEmpty ? '—' : member.phone), _InfoRow( label: 'Email', value: member.email.isEmpty ? '—' : member.email), ], ), if (hasBank) ...[ const SizedBox(height: 16), _SectionLabel('Datos bancarios'), const SizedBox(height: 8), Wrap( spacing: 32, runSpacing: 8, children: [ _InfoRow( label: 'Entidad', value: member.bank.isEmpty ? '—' : member.bank), _InfoRow( label: 'Sucursal', value: member.bankOffice.isEmpty ? '—' : member.bankOffice), _InfoRow( label: 'D.C.', value: member.bankSecCode.isEmpty ? '—' : member.bankSecCode), _InfoRow( label: 'Cuenta', value: member.bankAccount.isEmpty ? '—' : member.bankAccount), if (member.bankName.isNotEmpty) _InfoRow(label: 'Nombre', value: member.bankName), if (member.mandate.isNotEmpty) _InfoRow(label: 'Mandato SEPA', value: member.mandate), ], ), ], ], ), ), ); } } class _SectionLabel extends StatelessWidget { final String text; const _SectionLabel(this.text); @override Widget build(BuildContext context) { return Text(text, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, )); } } // ── Shared helpers ──────────────────────────────────────────────────────────── class _InfoRow extends StatelessWidget { final String label; final String value; const _InfoRow({required this.label, required this.value}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle( fontSize: 11, color: cs.outline, fontWeight: FontWeight.w500)), const SizedBox(height: 2), Text(value, style: const TextStyle(fontSize: 14)), ], ); } } String _fmtDate(String? date) { if (date == null || date.isEmpty) return '—'; try { final parts = date.split('-'); if (parts.length == 3) return '${parts[2]}/${parts[1]}/${parts[0]}'; } catch (_) {} return date; }