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) {
|
|
|
|
|
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(
|
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,
|
|
|
|
|
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;
|
|
|
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 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! ? '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;
|
|
|
|
|
}
|