Agrego frontend app
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user