depor_os/lib/screens/members/members_screen.dart

1314 lines
44 KiB
Dart
Raw Normal View History

2026-03-18 11:47:06 +00:00
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<MembersScreen> createState() => _MembersScreenState();
}
class _MembersScreenState extends State<MembersScreen> {
final _service = memberRepository();
2026-03-18 22:20:31 +00:00
final _searchKey = GlobalKey<_MemberSearchState>();
2026-03-18 11:47:06 +00:00
Member? _member;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load(_service.first());
}
void _handleAction(String action) {
2026-03-22 23:54:12 +00:00
final member = _member;
if (member == null) return;
switch (action) {
case 'receipts': _doPaidCaution(member);
case 'retain': _doRetener(member);
case 'unregister': _doDarDeBaja(member);
case 'recover': _doReactivar(member);
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$action — pendiente de implementar'),
duration: const Duration(seconds: 2)),
);
}
}
Future<void> _doPaidCaution(Member member) async {
try {
final updated = await _service.paidCaution(member.id);
if (!mounted) return;
setState(() => _member = updated);
} catch (e) {
if (!mounted) return;
_showError('Error: $e');
}
}
Future<void> _doRetener(Member member) async {
final cancelar = member.retenido;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(cancelar ? 'Cancelar retención' : 'Retener abonado'),
content: Text(cancelar
? '¿Cancelar la retención del socio ${member.formattedId}?'
: '¿Marcar como retenido al socio ${member.formattedId}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(cancelar ? 'Cancelar retención' : 'Retener'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final updated = cancelar
? await _service.retenerCancel(member.id)
: await _service.retener(member.id);
if (!mounted) return;
setState(() => _member = updated);
} catch (e) {
if (!mounted) return;
_showError('Error: $e');
}
}
Future<void> _doDarDeBaja(Member member) async {
Map<String, int> preCheck;
try {
preCheck = await _service.preDelete(member.id);
} catch (e) {
if (!mounted) return;
_showError('Error al verificar: $e');
return;
}
if (!mounted) return;
final result = await showDialog<(String, String)>(
context: context,
builder: (_) => _BajaDialog(member: member, preCheck: preCheck),
);
if (result == null || !mounted) return;
try {
final updated = await _service.delete(member.id, result.$1, result.$2);
if (!mounted) return;
setState(() => _member = updated);
} catch (e) {
if (!mounted) return;
_showError('Error al dar de baja: $e');
}
}
Future<void> _doReactivar(Member member) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reactivar abonado'),
content: Text('¿Reactivar al socio ${member.formattedId}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Reactivar'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final updated = await _service.recover(member.id);
if (!mounted) return;
setState(() => _member = updated);
} catch (e) {
if (!mounted) return;
_showError('Error al reactivar: $e');
}
}
void _showError(String msg) {
2026-03-18 11:47:06 +00:00
ScaffoldMessenger.of(context).showSnackBar(
2026-03-22 23:54:12 +00:00
SnackBar(content: Text(msg), duration: const Duration(seconds: 3)),
2026-03-18 11:47:06 +00:00
);
}
Future<void> _load(Future<Member> 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(
2026-03-18 22:20:31 +00:00
searchKey: _searchKey,
2026-03-18 11:47:06 +00:00
member: _member,
loading: _loading,
service: _service,
2026-03-18 22:20:31 +00:00
onFirst: () { _searchKey.currentState?.clear(); _load(_service.first()); },
2026-03-18 11:47:06 +00:00
onPrev: () {
2026-03-18 22:20:31 +00:00
_searchKey.currentState?.clear();
2026-03-18 11:47:06 +00:00
if (_member != null) _load(_service.prev(_member!.id));
},
onNext: () {
2026-03-18 22:20:31 +00:00
_searchKey.currentState?.clear();
2026-03-18 11:47:06 +00:00
if (_member != null) _load(_service.next(_member!.id));
},
2026-03-18 22:20:31 +00:00
onLast: () { _searchKey.currentState?.clear(); _load(_service.last()); },
2026-03-18 11:47:06 +00:00
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 {
2026-03-18 22:20:31 +00:00
final GlobalKey<_MemberSearchState> searchKey;
2026-03-18 11:47:06 +00:00
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({
2026-03-18 22:20:31 +00:00
required this.searchKey,
2026-03-18 11:47:06 +00:00
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(
2026-03-18 22:20:31 +00:00
child: _MemberSearch(key: searchKey, service: service, onSelected: onSearch),
2026-03-18 11:47:06 +00:00
),
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<String>(
icon: const Icon(Icons.more_vert),
tooltip: 'Acciones',
enabled: !disabled,
onSelected: onAction,
2026-03-22 23:54:12 +00:00
itemBuilder: (context) {
final m = member;
final isActive = m?.isActive ?? true;
final retenido = m?.retenido ?? false;
return [
const PopupMenuItem(
value: 'receipts',
child: ListTile(
leading: Icon(Icons.receipt_long_outlined),
title: Text('Recibos anteriores'),
dense: true,
),
2026-03-18 11:47:06 +00:00
),
2026-03-22 23:54:12 +00:00
const PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit_outlined),
title: Text('Editar abonado'),
dense: true,
),
2026-03-18 11:47:06 +00:00
),
2026-03-22 23:54:12 +00:00
const PopupMenuItem(
value: 'add_family',
child: ListTile(
leading: Icon(Icons.person_add_outlined),
title: Text('Añadir familiar'),
dense: true,
),
2026-03-18 11:47:06 +00:00
),
2026-03-22 23:54:12 +00:00
if (isActive)
PopupMenuItem(
value: 'retain',
child: ListTile(
leading: Icon(retenido
? Icons.play_circle_outline
: Icons.pause_circle_outline),
title: Text(retenido ? 'Cancelar retención' : 'Retener abonado'),
dense: true,
),
),
const PopupMenuDivider(),
if (!isActive)
const PopupMenuItem(
value: 'recover',
child: ListTile(
leading: Icon(Icons.person_add_alt_1_outlined),
title: Text('Reactivar abonado'),
dense: true,
),
),
if (isActive)
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,
),
),
];
},
2026-03-18 11:47:06 +00:00
),
],
),
);
}
}
class _MemberSearch extends StatefulWidget {
final MemberRepository service;
final void Function(int id) onSelected;
2026-03-18 22:20:31 +00:00
const _MemberSearch({super.key, required this.service, required this.onSelected});
2026-03-18 11:47:06 +00:00
@override
State<_MemberSearch> createState() => _MemberSearchState();
}
class _MemberSearchState extends State<_MemberSearch> {
2026-03-18 22:20:31 +00:00
TextEditingController? _controller;
void clear() => _controller?.clear();
2026-03-18 11:47:06 +00:00
@override
Widget build(BuildContext context) {
return Autocomplete<MapEntry<String, String>>(
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);
2026-03-18 22:20:31 +00:00
_controller?.clear();
2026-03-18 11:47:06 +00:00
},
fieldViewBuilder: (context, controller, focusNode, onSubmit) {
2026-03-18 22:20:31 +00:00
_controller = controller;
2026-03-18 11:47:06 +00:00
return TextField(
controller: controller,
focusNode: focusNode,
2026-03-18 22:20:31 +00:00
onTap: () => controller.clear(),
onSubmitted: (text) {
final n = int.tryParse(text.trim());
if (n != null) {
widget.onSelected(n);
controller.clear();
} else {
onSubmit();
}
},
2026-03-18 11:47:06 +00:00
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),
2026-03-22 23:54:12 +00:00
if (member.paid)
_StatusChip(
label: 'Debe recibos',
color: cs.error,
icon: Icons.warning_amber_rounded),
2026-03-18 11:47:06 +00:00
],
),
),
// 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
2026-03-18 22:20:31 +00:00
_YearStrips(data: member.monthlyFees),
2026-03-18 11:47:06 +00:00
// 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 {
2026-03-18 22:20:31 +00:00
/// year → lista de 12 valores: -1 amarillo, 1 verde, 0 o 4 rojo.
final Map<int, List<int>> data;
2026-03-18 11:47:06 +00:00
const _YearStrips({this.data = const {}});
static const _months = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
2026-03-18 22:20:31 +00:00
static _MonthStatus _toStatus(int v) => switch (v) {
1 => _MonthStatus.paid,
0 || 4 => _MonthStatus.unpaid,
_ => _MonthStatus.unknown,
};
2026-03-18 11:47:06 +00:00
@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) {
2026-03-18 22:20:31 +00:00
final months = data[year] ?? [];
2026-03-18 11:47:06 +00:00
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) {
2026-03-18 22:20:31 +00:00
final status = i < months.length
? _toStatus(months[i])
: _MonthStatus.unknown;
2026-03-18 11:47:06 +00:00
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});
2026-03-18 22:20:31 +00:00
void _showAddCommentDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Añadir comentario'),
content: TextField(
controller: controller,
maxLines: 4,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Escribe el comentario...',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
// TODO: llamar al endpoint de creación de comentario
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Guardar comentario — pendiente de implementar'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Guardar'),
),
],
),
);
}
2026-03-18 11:47:06 +00:00
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: [
2026-03-18 22:20:31 +00:00
Row(
children: [
Expanded(
child: Text('Comentarios',
style: Theme.of(context).textTheme.titleMedium),
),
TextButton.icon(
onPressed: () => _showAddCommentDialog(context),
icon: const Icon(Icons.add, size: 16),
label: const Text('Añadir'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
2026-03-18 11:47:06 +00:00
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<Person> 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) {
2026-03-18 22:20:31 +00:00
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
2026-03-18 11:47:06 +00:00
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(),
),
2026-03-18 22:20:31 +00:00
),
2026-03-18 11:47:06 +00:00
);
}),
],
),
),
);
}
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<String>(
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! ? '' : '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)),
],
);
}
}
2026-03-22 23:54:12 +00:00
// ── Dar de baja dialog ────────────────────────────────────────────────────────
class _BajaDialog extends StatefulWidget {
final Member member;
final Map<String, int> preCheck;
const _BajaDialog({required this.member, required this.preCheck});
@override
State<_BajaDialog> createState() => _BajaDialogState();
}
class _BajaDialogState extends State<_BajaDialog> {
late DateTime _date;
final _reasonCtrl = TextEditingController();
@override
void initState() {
super.initState();
_date = DateTime.now();
}
@override
void dispose() {
_reasonCtrl.dispose();
super.dispose();
}
String get _dateStr =>
'${_date.year.toString().padLeft(4, '0')}-'
'${_date.month.toString().padLeft(2, '0')}-'
'${_date.day.toString().padLeft(2, '0')}';
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) setState(() => _date = picked);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final pendientes = widget.preCheck['pendientes'] ?? 0;
final devueltos = widget.preCheck['devueltos'] ?? 0;
final revision = widget.preCheck['revision'] ?? 0;
final hasWarning = pendientes > 0 || devueltos > 0 || revision > 0;
return AlertDialog(
title: Text('Dar de baja — ${widget.member.formattedId}'),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasWarning) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.warning_amber_rounded, size: 16, color: cs.error),
const SizedBox(width: 6),
Text('Atención', style: TextStyle(fontWeight: FontWeight.bold, color: cs.error)),
]),
const SizedBox(height: 4),
if (pendientes > 0) Text('$pendientes recibo(s) pendiente(s)', style: const TextStyle(fontSize: 13)),
if (devueltos > 0) Text('$devueltos recibo(s) devuelto(s)', style: const TextStyle(fontSize: 13)),
if (revision > 0) Text('$revision recibo(s) en revisión', style: const TextStyle(fontSize: 13)),
],
),
),
const SizedBox(height: 16),
],
// Fecha de baja
Row(
children: [
Expanded(
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Fecha de baja',
border: OutlineInputBorder(),
isDense: true,
),
child: Text(_dateStr),
),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(Icons.calendar_today, size: 18),
onPressed: _pickDate,
tooltip: 'Seleccionar fecha',
),
],
),
const SizedBox(height: 12),
TextField(
controller: _reasonCtrl,
decoration: const InputDecoration(
labelText: 'Motivo (opcional)',
border: OutlineInputBorder(),
isDense: true,
),
maxLines: 2,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: cs.error),
onPressed: () => Navigator.pop(context, (_dateStr, _reasonCtrl.text)),
child: const Text('Dar de baja'),
),
],
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
2026-03-18 11:47:06 +00:00
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;
}