depor_os/lib/screens/members/members_screen.dart
Daniel Esteban 34e7cbc382 first commit
2026-03-18 12:47:06 +01:00

970 lines
No EOL
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(112) → 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! ? '' : '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;
}