970 lines
No EOL
32 KiB
Dart
970 lines
No EOL
32 KiB
Dart
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();
|
||
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<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(
|
||
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<String>(
|
||
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<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);
|
||
},
|
||
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<int, Map<int, _MonthStatus>> 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<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) {
|
||
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<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! ? '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;
|
||
} |