Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -0,0 +1,991 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_card.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_detail_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_form_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_table_view.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/editar_plan_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_overview.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
import 'package:go_router/go_router.dart';
enum _DeudaFilter { todos, alDia, debe }
enum _RolFilter { todos, staff, clientes }
enum _ActiveFilter { todos, activos, inactivos }
class UsuariosScreen extends ConsumerStatefulWidget {
const UsuariosScreen({super.key});
@override
ConsumerState<UsuariosScreen> createState() => _UsuariosScreenState();
}
class _UsuariosScreenState extends ConsumerState<UsuariosScreen> {
final _searchCtrl = TextEditingController();
_DeudaFilter _filter = _DeudaFilter.todos;
_RolFilter _rolFilter = _RolFilter.todos;
_ActiveFilter _activeFilter = _ActiveFilter.todos;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _onSearch(String value) {
setState(() {}); // Rebuild para aplicar filtro local
}
bool get _actorIsSuperadmin =>
ref.read(authStateProvider).valueOrNull?.isSuperadmin ?? false;
Widget _filterItem(BuildContext ctx, String label) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(ctx).colorScheme.onSurface,
),
),
);
}
List<Usuario> _applyLocalSearch(List<Usuario> usuarios) {
final query = _searchCtrl.text.trim().toLowerCase();
if (query.isEmpty) return usuarios;
return usuarios.where((u) {
return u.nombre.toLowerCase().contains(query) ||
(u.apellido?.toLowerCase().contains(query) ?? false) ||
u.dni.contains(query) ||
(u.mail?.toLowerCase().contains(query) ?? false);
}).toList();
}
Future<void> _showCreateDialog() async {
final existingDnis = ref
.read(allUsuariosProvider)
.valueOrNull
?.map((u) => u.dni)
.toSet() ??
{};
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => UsuarioFormDialog(existingDnis: existingDnis),
);
if (result == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.insertUsuario(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario creado',
type: ToastType.success,
);
}
}
Future<void> _showDetail(Usuario usuario) async {
final result = await showDialog<String>(
context: context,
builder: (_) => UsuarioDetailDialog(usuario: usuario),
);
if (result == 'edit' && mounted) {
_showEditDialog(usuario);
}
}
Future<void> _showEditDialog(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => UsuarioFormDialog(
usuario: usuario,
actorIsSuperadmin: _actorIsSuperadmin,
),
);
if (result == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.updateUsuario(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario actualizado',
type: ToastType.success,
);
}
}
Future<void> _toggleStatus(Usuario usuario) async {
final newStatus = !usuario.isActive;
final accion = newStatus ? 'activar' : 'desactivar';
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('${newStatus ? 'Activar' : 'Desactivar'} usuario'),
content: Text(
'¿Estás seguro de que querés $accion a ${usuario.displayName}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(newStatus ? 'Activar' : 'Desactivar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.toggleStatus(usuario.id, newStatus);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: newStatus ? 'Usuario activado' : 'Usuario desactivado',
type: ToastType.success,
);
}
}
Future<void> _deleteUsuario(Usuario usuario) async {
if (!_actorIsSuperadmin) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar usuario'),
content: Text(
'¿Estás seguro de que querés eliminar a ${usuario.displayName}?\n'
'Esta acción no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.deleteUsuario(usuario.dni);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario eliminado',
type: ToastType.success,
);
}
}
List<Usuario> _applyRolFilter(List<Usuario> usuarios) {
if (_rolFilter == _RolFilter.todos) return usuarios;
return usuarios.where((u) {
if (_rolFilter == _RolFilter.staff) {
return u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor';
}
if (_rolFilter == _RolFilter.clientes) {
return u.rol == 'cliente';
}
return true;
}).toList();
}
List<Usuario> _applyActiveFilter(List<Usuario> usuarios) {
if (_activeFilter == _ActiveFilter.todos) return usuarios;
return usuarios
.where((u) => _activeFilter == _ActiveFilter.activos ? u.isActive : !u.isActive)
.toList();
}
List<Usuario> _applyFilter(
List<Usuario> usuarios, Map<String, double> deudaMap) {
if (_filter == _DeudaFilter.todos) return usuarios;
return usuarios.where((u) {
final d = getUsuarioDeuda(u, deudaMap);
if (_filter == _DeudaFilter.debe) return d != null && d > 0;
if (_filter == _DeudaFilter.alDia) return d != null && d <= 0;
return true;
}).toList();
}
// Acciones rápidas
Future<void> _resetearContrasena(Usuario usuario) async {
final nuevaPassword = await showDialog<String>(
context: context,
builder: (_) => ResetearContrasenaDialog(usuario: usuario),
);
if (nuevaPassword == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.resetearContrasena(usuario.id, nuevaPassword);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Contraseña actualizada',
type: ToastType.success,
);
}
}
Future<void> _registrarPago(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: usuario.dni),
);
if (result == null || !mounted) return;
// Extraer y remover datos de actualización de plan antes de insertar pago
final planUpdate =
result.remove('actualizar_plan') as Map<String, dynamic>?;
// Insertar el pago directamente vía repositorio para evitar la race
// condition con pagosProvider.autoDispose (nadie lo watchea aquí).
String? error;
try {
await ref.read(pagosRepositoryProvider).insertPago(result);
ref.invalidate(ultimoPagoMapProvider);
// userPagosProvider y userHistorialProvider son family autoDispose;
// ref.invalidate sobre la familia entera fuerza re-fetch en próximo watch.
ref.invalidate(userPagosProvider);
ref.invalidate(userHistorialProvider);
// fc_insertar_pago activa al cliente incondicionalmente; refrescamos
// la lista para que la UI refleje el nuevo isactive.
await ref.read(usuariosProvider.notifier).loadUsuarios();
} catch (e) {
error = e.toString().replaceFirst('Exception: ', '');
}
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
// Actualizar plan del usuario si se pidió
if (planUpdate != null) {
final planError =
await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (mounted && planError != null) {
SomaToast.show(context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info);
return;
}
}
if (mounted) {
SomaToast.show(
context,
message: planUpdate != null
? 'Pago registrado y plan actualizado'
: 'Pago registrado',
type: ToastType.success,
);
}
}
void _asignarRutina(Usuario usuario) {
context.go('/rutinas?dni=${usuario.dni}');
}
void _verHistorial(Usuario usuario) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: usuario.dni,
nombre: usuario.displayName,
initials: usuario.initials,
),
);
}
Future<void> _editarPlan(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => EditarPlanDialog(usuario: usuario),
);
if (result == null || !mounted) return; // Usuario canceló
final newTipoCuota = result['tipo_cuota'] as String?;
// Si no cambió, no hacer nada
if (newTipoCuota == usuario.tipoCuota) return;
// Actualizar el tipo_cuota del usuario
final updateData = {
'id': usuario.id,
'tipo_cuota': newTipoCuota, // null si se seleccionó "Sin plan"
};
final error = await ref
.read(usuariosProvider.notifier)
.updateUsuario(updateData);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Plan actualizado',
type: ToastType.success,
);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(usuariosProvider);
final deudaAsync = ref.watch(usuariosDeudaProvider);
final deudaMap = deudaAsync.valueOrNull ?? {};
final ultimoPagoMap = ref.watch(ultimoPagoMapProvider).valueOrNull ?? {};
final isWide = MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint;
final theme = Theme.of(context);
final viewMode = ref.watch(usuariosViewModeProvider);
final actorIsSuperadmin =
ref.watch(authStateProvider).valueOrNull?.isSuperadmin ?? false;
// Overview mode: simplified header + dashboard
if (viewMode == UsuariosViewMode.overview) {
return Scaffold(
body: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Usuarios',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.dashboard_outlined,
text: 'Cambiá entre vista resumen, tarjetas o tabla.',
),
SomaHelpItem(
icon: Icons.add,
text: 'Creá un nuevo usuario.',
),
],
),
const Spacer(),
_ViewToggleButton(isWide: isWide),
const SizedBox(width: 8),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
Expanded(
child: UsuariosOverview(
onVerTodos: () => ref
.read(usuariosViewModeProvider.notifier)
.setMode(UsuariosViewMode.cards),
),
),
],
),
);
}
return Scaffold(
body: Column(
children: [
// Header con búsqueda
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
if (isWide) ...[
const Text(
'Usuarios',
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.search,
text: 'Buscá por nombre, apellido, DNI o email.',
),
SomaHelpItem(
icon: Icons.dashboard_outlined,
text: 'Cambiá entre vista resumen, tarjetas o tabla.',
),
SomaHelpItem(
icon: Icons.add,
text: 'Creá un nuevo usuario.',
),
SomaHelpItem(
icon: Icons.filter_alt_outlined,
text: 'Filtrá la lista por estado de pago, rol o si '
'están activos.',
),
],
),
const SizedBox(width: 16),
],
Expanded(
child: SizedBox(
height: 42,
child: TextField(
controller: _searchCtrl,
onChanged: _onSearch,
decoration: InputDecoration(
hintText: 'Buscar por nombre, email o DNI...',
hintStyle: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface
.withAlpha(100),
),
prefixIcon: Icon(
Icons.search,
size: 20,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
_searchCtrl.clear();
_onSearch('');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: theme
.colorScheme.surfaceContainerHighest,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: theme
.colorScheme.surfaceContainerHighest,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: SomaColors.primary,
width: 1.5,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
),
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: 12),
_ViewToggleButton(isWide: isWide),
const SizedBox(width: 8),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Filtros de deuda y rol
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
12,
isWide ? 32 : 16,
8,
),
child: Row(
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_DeudaFilter>(
value: _filter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Estado'),
_filterItem(ctx, 'Al día'),
_filterItem(ctx, 'Debe'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _DeudaFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _DeudaFilter.alDia,
child: Text('Al día', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _DeudaFilter.debe,
child: Text('Debe', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _filter = value);
},
),
),
VerticalDivider(
width: 1,
thickness: 0.8,
color: theme.colorScheme.surfaceContainerHighest,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_RolFilter>(
value: _rolFilter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Rol'),
_filterItem(ctx, 'Admins'),
_filterItem(ctx, 'Clientes'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _RolFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _RolFilter.staff,
child: Text('Admins', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _RolFilter.clientes,
child: Text('Clientes', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _rolFilter = value);
},
),
),
VerticalDivider(
width: 1,
thickness: 0.8,
color: theme.colorScheme.surfaceContainerHighest,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_ActiveFilter>(
value: _activeFilter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Estado'),
_filterItem(ctx, 'Activos'),
_filterItem(ctx, 'Inactivos'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _ActiveFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _ActiveFilter.activos,
child: Text('Activos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _ActiveFilter.inactivos,
child: Text('Inactivos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _activeFilter = value);
},
),
),
],
),
),
),
if (_filter != _DeudaFilter.todos || _rolFilter != _RolFilter.todos || _activeFilter != _ActiveFilter.todos) ...[
const SizedBox(width: 8),
InkWell(
onTap: () => setState(() {
_filter = _DeudaFilter.todos;
_rolFilter = _RolFilter.todos;
_activeFilter = _ActiveFilter.todos;
}),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: SomaColors.primary.withAlpha(14),
border: Border.all(
color: SomaColors.primary.withAlpha(50),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.close, size: 14, color: SomaColors.primaryText),
const SizedBox(width: 5),
Text(
'Limpiar',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
],
),
),
),
],
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref
.read(usuariosProvider.notifier)
.loadUsuarios(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (usuarios) {
final searched = _applyLocalSearch(usuarios);
final activeFiltered = _applyActiveFilter(searched);
final rolFiltered = _applyRolFilter(activeFiltered);
final filtered = _applyFilter(rolFiltered, deudaMap);
if (filtered.isEmpty) {
final hasSearch = _searchCtrl.text.isNotEmpty;
final hasFilter = _filter != _DeudaFilter.todos ||
_rolFilter != _RolFilter.todos;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 56,
color: theme.colorScheme.onSurface.withAlpha(60),
),
const SizedBox(height: 12),
Text(
hasFilter
? 'No hay usuarios con este filtro'
: hasSearch
? 'No se encontraron usuarios para "${_searchCtrl.text}"'
: 'No hay usuarios',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
textAlign: TextAlign.center,
),
if (hasSearch) ...[
const SizedBox(height: 14),
TextButton.icon(
onPressed: () {
_searchCtrl.clear();
_onSearch('');
},
icon: const Icon(Icons.close, size: 16),
label: const Text('Limpiar búsqueda'),
),
],
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () => ref
.read(usuariosProvider.notifier)
.loadUsuarios(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: viewMode == UsuariosViewMode.table && isWide
? Padding(
key: const ValueKey('table'),
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
4,
isWide ? 32 : 16,
80,
),
child: UsuariosTableView(
usuarios: filtered,
deudaMap: deudaMap,
ultimoPagoMap: ultimoPagoMap,
onTap: _showDetail,
onEdit: _showEditDialog,
onToggleStatus: _toggleStatus,
onDelete: _deleteUsuario,
onRegistrarPago: _registrarPago,
onAsignarRutina: _asignarRutina,
onVerHistorial: _verHistorial,
onEditarPlan: _editarPlan,
actorIsSuperadmin: actorIsSuperadmin,
onResetPassword: _resetearContrasena,
),
)
: ListView.separated(
key: const ValueKey('cards'),
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
4,
isWide ? 32 : 16,
80,
),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final usuario = filtered[index];
return UsuarioCard(
usuario: usuario,
deuda: getUsuarioDeuda(usuario, deudaMap),
onTap: () => _showDetail(usuario),
onEdit: () => _showEditDialog(usuario),
onToggleStatus: () => _toggleStatus(usuario),
onDelete: () => _deleteUsuario(usuario),
onRegistrarPago: () => _registrarPago(usuario),
onAsignarRutina: () => _asignarRutina(usuario),
onVerHistorial: () => _verHistorial(usuario),
onEditarPlan: () => _editarPlan(usuario),
canDelete: actorIsSuperadmin,
canResetPassword: actorIsSuperadmin &&
usuario.rol != 'cliente',
onResetPassword: () =>
_resetearContrasena(usuario),
);
},
),
),
);
},
),
),
],
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nuevo'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42)),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
class _ViewToggleButton extends ConsumerWidget {
final bool isWide;
const _ViewToggleButton({required this.isWide});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewMode = ref.watch(usuariosViewModeProvider);
final notifier = ref.read(usuariosViewModeProvider.notifier);
final theme = Theme.of(context);
if (isWide) {
return SegmentedButton<UsuariosViewMode>(
segments: const [
ButtonSegment(
value: UsuariosViewMode.overview,
icon: Icon(Icons.dashboard_outlined, size: 18),
tooltip: 'Resumen',
),
ButtonSegment(
value: UsuariosViewMode.cards,
icon: Icon(Icons.view_list, size: 18),
tooltip: 'Tarjetas',
),
ButtonSegment(
value: UsuariosViewMode.table,
icon: Icon(Icons.table_rows_outlined, size: 18),
tooltip: 'Tabla',
),
],
selected: {viewMode},
onSelectionChanged: (s) => notifier.setMode(s.first),
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
showSelectedIcon: false,
);
}
// Narrow: cicla entre modos con un ícono contextual
final (icon, tooltip) = switch (viewMode) {
UsuariosViewMode.overview => (Icons.view_list, 'Vista de tarjetas'),
UsuariosViewMode.cards => (Icons.table_rows_outlined, 'Vista de tabla'),
UsuariosViewMode.table => (Icons.dashboard_outlined, 'Vista resumen'),
};
return IconButton(
onPressed: () => notifier.toggle(),
tooltip: tooltip,
icon: Icon(icon, size: 20),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
minimumSize: const Size(42, 42),
),
);
}
}