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,135 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart';
class UsuariosRepositoryImpl implements UsuariosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<Usuario>> getUsuarios({
int pagina = 1,
int cantidad = 50,
String? dni,
}) async {
final token = await _getToken();
final params = <String, dynamic>{
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetUsuarios,
params: params,
);
if (response is List) {
return response
.map((e) => Usuario.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertUsuario(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertUsuario,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<void> updateUsuario(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateUsuario,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<void> toggleUsuarioStatus(String id, bool estado) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcToggleUsuarioStatus,
params: {
'p_token': token,
'p_id': id,
'p_estado': estado,
},
);
}
@override
Future<bool> deleteUsuario(String dni) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteUsuario,
params: {
'p_dni': dni,
'p_token': token,
},
);
return response == true;
}
@override
Future<void> resetearContrasena(String usuarioId, String passwordNueva) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcResetearContrasenaUsuario,
params: {
'p_token': token,
'p_usuario_id': usuarioId,
'p_password_nueva': passwordNueva,
},
);
}
@override
Future<List<Map<String, dynamic>>> getUsuariosTipoCuota(
{String? dni}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetUsuarioTipoCuota,
params: params,
);
if (response is List) {
return response
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
return [];
}
}
@@ -0,0 +1,118 @@
class Usuario {
final String id;
final String dni;
final String nombre;
final String? apellido;
final double? peso;
final int? altura;
final String rol;
final bool isActive;
final DateTime? fechaCreacion;
final DateTime? fechaModificacion;
final String? mail;
final String? telefono;
final double? fuerzaMax;
final String? sexo;
final String? tipoCuota;
const Usuario({
required this.id,
required this.dni,
required this.nombre,
this.apellido,
this.peso,
this.altura,
required this.rol,
this.isActive = true,
this.fechaCreacion,
this.fechaModificacion,
this.mail,
this.telefono,
this.fuerzaMax,
this.sexo,
this.tipoCuota,
});
factory Usuario.fromMap(Map<String, dynamic> map) {
return Usuario(
id: map['id'] as String,
dni: map['dni'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String?,
peso: (map['peso'] as num?)?.toDouble(),
altura: (map['altura'] as num?)?.toInt(),
rol: map['rol'] as String? ?? 'cliente',
isActive: map['isactive'] as bool? ?? map['isActive'] as bool? ?? true,
fechaCreacion: map['fecha_creacion'] != null
? DateTime.tryParse(map['fecha_creacion'].toString())
: null,
fechaModificacion: map['fecha_modificacion'] != null
? DateTime.tryParse(map['fecha_modificacion'].toString())
: null,
mail: map['mail'] as String?,
telefono: map['telefono'] as String?,
fuerzaMax: (map['fuerza_max'] as num?)?.toDouble(),
sexo: map['sexo'] as String?,
tipoCuota: map['tipo_cuota'] as String?,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '$nombre $apellido';
}
return nombre.isNotEmpty ? nombre : dni;
}
String get rolDisplay {
return switch (rol) {
'superadmin' || 'admin' || 'profesor' => 'Admin',
'cliente' => 'Cliente',
_ => rol,
};
}
String get initials {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '${nombre[0]}${apellido![0]}'.toUpperCase();
}
if (nombre.isNotEmpty) return nombre[0].toUpperCase();
if (dni.length >= 2) return dni.substring(0, 2);
return '?';
}
/// Para fc_insertar_usuario (p_datos jsonb).
Map<String, dynamic> toInsertMap(String password) {
return {
'dni': dni,
'nombre': nombre,
if (apellido != null) 'apellido': apellido,
if (peso != null) 'peso': peso,
if (altura != null) 'altura': altura,
'rol': rol,
if (mail != null && mail!.isNotEmpty) 'mail': mail,
if (telefono != null && telefono!.isNotEmpty) 'telefono': telefono,
if (fuerzaMax != null) 'fuerza_max': fuerzaMax,
if (sexo != null) 'sexo': sexo,
if (tipoCuota != null) 'tipo_cuota': tipoCuota,
if (password.isNotEmpty) 'password': password,
'isActive': isActive,
};
}
/// Para fc_modificar_usuario (p_datos jsonb). Sin password.
Map<String, dynamic> toUpdateMap() {
return {
'dni': dni,
'nombre': nombre,
if (apellido != null) 'apellido': apellido,
if (peso != null) 'peso': peso,
if (altura != null) 'altura': altura,
'rol': rol,
if (mail != null) 'mail': mail,
if (telefono != null) 'telefono': telefono,
if (fuerzaMax != null) 'fuerza_max': fuerzaMax,
if (sexo != null) 'sexo': sexo,
};
}
}
@@ -0,0 +1,24 @@
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
abstract class UsuariosRepository {
Future<List<Usuario>> getUsuarios({
int pagina = 1,
int cantidad = 50,
String? dni,
});
Future<void> insertUsuario(Map<String, dynamic> datos);
Future<void> updateUsuario(Map<String, dynamic> datos);
Future<void> toggleUsuarioStatus(String id, bool estado);
Future<bool> deleteUsuario(String dni);
/// Reset administrativo de contraseña (solo superadmin). No requiere la
/// contraseña actual del usuario objetivo.
Future<void> resetearContrasena(String usuarioId, String passwordNueva);
/// Obtener mapping usuario → tipo de cuota.
Future<List<Map<String, dynamic>>> getUsuariosTipoCuota({String? dni});
}
@@ -0,0 +1,152 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/data/repositories/usuarios_repository_impl.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart';
final usuariosRepositoryProvider = Provider<UsuariosRepository>((ref) {
return UsuariosRepositoryImpl();
});
/// Todos los usuarios sin filtro. Se invalida automáticamente cuando usuariosProvider muta.
final allUsuariosProvider = FutureProvider.autoDispose<List<Usuario>>((ref) async {
final repo = ref.read(usuariosRepositoryProvider);
return repo.getUsuarios();
});
/// Mapa DNI → monto de deuda del mes actual.
/// Se recalcula automáticamente cuando allUsuariosProvider o tiposCuotaProvider cambian.
final usuariosDeudaProvider = FutureProvider.autoDispose<Map<String, double>>((ref) async {
final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull;
if (tiposCuota == null || tiposCuota.isEmpty) return {};
final usuarios = ref.watch(allUsuariosProvider).valueOrNull;
if (usuarios == null || usuarios.isEmpty) return {};
final pagosRepo = ref.read(pagosRepositoryProvider);
final pagos = await pagosRepo.getPagos(cantidad: 500);
final now = DateTime.now();
final mesActual =
'${now.year}-${now.month.toString().padLeft(2, '0')}-01';
final pagosMes = <String, double>{};
for (final p in pagos) {
if (p.anioMesPagado == mesActual && p.cliente != null) {
pagosMes[p.cliente!.dni] =
(pagosMes[p.cliente!.dni] ?? 0) + p.montoTotal;
}
}
final deuda = <String, double>{};
for (final u in usuarios) {
if (u.tipoCuota == null || !u.isActive) continue;
final tc =
tiposCuota.where((t) => t.id == u.tipoCuota).firstOrNull;
if (tc == null) continue;
double esperado = tc.precio;
if (tc.recargo != null &&
tc.recargo! > 0 &&
now.day > tc.diaDePago) {
esperado += tc.recargo!;
}
final pagado = pagosMes[u.dni] ?? 0;
deuda[u.dni] = esperado - pagado;
}
return deuda;
});
/// Helper compartido: retorna null si el usuario no tiene plan o está inactivo,
/// 0.0 si está al día, >0 si debe.
double? getUsuarioDeuda(Usuario u, Map<String, double> deudaMap) {
if (u.tipoCuota == null || !u.isActive) return null;
return deudaMap[u.dni] ?? 0.0;
}
final usuariosProvider =
StateNotifierProvider.autoDispose<UsuariosNotifier, AsyncValue<List<Usuario>>>((ref) {
return UsuariosNotifier(ref.read(usuariosRepositoryProvider), ref);
});
class UsuariosNotifier extends StateNotifier<AsyncValue<List<Usuario>>> {
final UsuariosRepository _repository;
final Ref _ref;
UsuariosNotifier(this._repository, this._ref) : super(const AsyncValue.loading()) {
loadUsuarios();
}
/// Invalida providers derivados para que refetcheen con datos frescos.
void _invalidateDerived() {
_ref.invalidate(allUsuariosProvider);
// usuariosDeudaProvider y pagosEstadoProvider se recalculan solos
// porque hacen ref.watch(allUsuariosProvider)
}
Future<void> loadUsuarios() async {
state = const AsyncValue.loading();
try {
final usuarios = await _repository.getUsuarios();
state = AsyncValue.data(usuarios);
_invalidateDerived();
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertUsuario(Map<String, dynamic> datos) async {
try {
await _repository.insertUsuario(datos);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> updateUsuario(Map<String, dynamic> datos) async {
try {
await _repository.updateUsuario(datos);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> toggleStatus(String id, bool estado) async {
try {
await _repository.toggleUsuarioStatus(id, estado);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> deleteUsuario(String dni) async {
try {
await _repository.deleteUsuario(dni);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
/// Reset administrativo de contraseña. No refetchea la lista: no cambia
/// ningún dato visible en ella.
Future<String?> resetearContrasena(String usuarioId, String passwordNueva) async {
try {
await _repository.resetearContrasena(usuarioId, passwordNueva);
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
@@ -0,0 +1,48 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
enum UsuariosViewMode { overview, cards, table }
final usuariosViewModeProvider =
StateNotifierProvider<UsuariosViewModeNotifier, UsuariosViewMode>((ref) {
return UsuariosViewModeNotifier();
});
class UsuariosViewModeNotifier extends StateNotifier<UsuariosViewMode> {
UsuariosViewModeNotifier() : super(UsuariosViewMode.overview) {
_loadViewMode();
}
Future<void> _loadViewMode() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(AppConstants.usuariosViewModeKey);
if (stored == 'table') {
state = UsuariosViewMode.table;
} else if (stored == 'cards') {
state = UsuariosViewMode.cards;
} else {
state = UsuariosViewMode.overview;
}
}
Future<void> toggle() async {
final newMode = switch (state) {
UsuariosViewMode.overview => UsuariosViewMode.cards,
UsuariosViewMode.cards => UsuariosViewMode.table,
UsuariosViewMode.table => UsuariosViewMode.overview,
};
await setMode(newMode);
}
Future<void> setMode(UsuariosViewMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
final stored = switch (mode) {
UsuariosViewMode.table => 'table',
UsuariosViewMode.cards => 'cards',
UsuariosViewMode.overview => 'overview',
};
await prefs.setString(AppConstants.usuariosViewModeKey, stored);
}
}
@@ -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),
),
);
}
}
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
class EditarPlanDialog extends ConsumerStatefulWidget {
final Usuario usuario;
const EditarPlanDialog({super.key, required this.usuario});
@override
ConsumerState<EditarPlanDialog> createState() => _EditarPlanDialogState();
}
class _EditarPlanDialogState extends ConsumerState<EditarPlanDialog> {
String? _selectedTipoCuotaId;
@override
void initState() {
super.initState();
_selectedTipoCuotaId = widget.usuario.tipoCuota;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tiposCuotaAsync = ref.watch(tiposCuotaProvider);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: const BoxConstraints(maxWidth: 450),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.card_membership,
color: SomaColors.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Editar Plan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
widget.usuario.displayName,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleccionar Tipo de Cuota',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 12),
tiposCuotaAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(
color: SomaColors.primary,
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Error al cargar tipos de cuota',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.error,
),
),
),
data: (tiposCuota) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
),
),
child: DropdownButton<String?>(
isExpanded: true,
value: _selectedTipoCuotaId,
underline: const SizedBox(),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(
'Sin plan',
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
),
...tiposCuota.map((tc) {
return DropdownMenuItem<String?>(
value: tc.id,
child: Row(
children: [
Expanded(
child: Text(
tc.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
Text(
'${tc.precioDisplay}${tc.diasDisplay}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
);
}),
],
onChanged: (value) {
setState(() {
_selectedTipoCuotaId = value;
});
},
),
);
},
),
],
),
),
// Footer
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
final removingPlan = widget.usuario.tipoCuota != null &&
_selectedTipoCuotaId == null;
if (removingPlan) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Quitar plan?'),
content: Text(
'${widget.usuario.displayName} quedará sin plan asignado. '
'No se eliminan los pagos registrados.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
),
child: const Text('Quitar plan'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
}
Navigator.of(context)
.pop({'tipo_cuota': _selectedTipoCuotaId});
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
/// Dialog de reset administrativo: el superadmin fija una contraseña nueva
/// para otro admin/superadmin, sin pedir la contraseña actual del objetivo.
/// Retorna la nueva contraseña (String) si se confirma, o null si se cancela.
class ResetearContrasenaDialog extends StatefulWidget {
final Usuario usuario;
const ResetearContrasenaDialog({super.key, required this.usuario});
@override
State<ResetearContrasenaDialog> createState() =>
_ResetearContrasenaDialogState();
}
class _ResetearContrasenaDialogState extends State<ResetearContrasenaDialog> {
final _formKey = GlobalKey<FormState>();
final _nuevaCtrl = TextEditingController();
final _repetirCtrl = TextEditingController();
bool _obscureNueva = true;
bool _obscureRepetir = true;
@override
void dispose() {
_nuevaCtrl.dispose();
_repetirCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_nuevaCtrl.text);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cambiar contraseña de ${widget.usuario.displayName}',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
SomaTextField(
controller: _nuevaCtrl,
labelText: 'Contraseña nueva',
prefixIcon: Icons.lock_outline,
obscureText: _obscureNueva,
suffixIcon: IconButton(
icon: Icon(
_obscureNueva
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: theme.colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: () =>
setState(() => _obscureNueva = !_obscureNueva),
),
validator: (v) {
if (v == null || v.isEmpty) return 'Requerido';
if (v.length < 8) return 'Mínimo 8 caracteres';
return null;
},
),
const SizedBox(height: 12),
SomaTextField(
controller: _repetirCtrl,
labelText: 'Repetir contraseña',
prefixIcon: Icons.lock_outline,
obscureText: _obscureRepetir,
suffixIcon: IconButton(
icon: Icon(
_obscureRepetir
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: theme.colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: () =>
setState(() => _obscureRepetir = !_obscureRepetir),
),
validator: (v) => v != _nuevaCtrl.text
? 'Las contraseñas no coinciden'
: null,
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Confirmar'),
),
],
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,524 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.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/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
class UsuarioCard extends ConsumerWidget {
final Usuario usuario;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onToggleStatus;
final VoidCallback onDelete;
final VoidCallback onRegistrarPago;
final VoidCallback onAsignarRutina;
final VoidCallback onVerHistorial;
final VoidCallback onEditarPlan;
final bool canResetPassword;
final VoidCallback? onResetPassword;
final bool canDelete;
/// null = sin plan, 0 = al día, >0 = monto de deuda
final double? deuda;
const UsuarioCard({
super.key,
required this.usuario,
required this.onTap,
required this.onEdit,
required this.onToggleStatus,
required this.onDelete,
required this.onRegistrarPago,
required this.onAsignarRutina,
required this.onVerHistorial,
required this.onEditarPlan,
this.canResetPassword = false,
this.onResetPassword,
this.canDelete = false,
this.deuda,
});
Color _stripeColor(ThemeData theme) {
if (!usuario.isActive) return theme.colorScheme.surfaceContainerHighest;
if (deuda == null) return theme.colorScheme.surfaceContainerHighest;
return deuda! <= 0 ? SomaColors.success : SomaColors.error;
}
ContextMenu<String> _buildContextMenu() {
return ContextMenu<String>(
entries: [
MenuItem(
label: const Text('Registrar pago'),
icon: const Icon(Icons.payment, size: 16),
value: 'pago',
),
MenuItem(
label: const Text('Asignar rutina'),
icon: const Icon(Icons.fitness_center, size: 16),
value: 'rutina',
),
MenuItem(
label: const Text('Ver historial'),
icon: const Icon(Icons.history, size: 16),
value: 'historial',
),
MenuItem(
label: const Text('Editar plan'),
icon: const Icon(Icons.card_membership, size: 16),
value: 'plan',
),
const MenuDivider(),
MenuItem(
label: const Text('Editar'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
MenuItem(
label: Text(usuario.isActive ? 'Desactivar' : 'Activar'),
icon: Icon(
usuario.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 16,
),
value: 'toggle',
),
if (canResetPassword)
MenuItem(
label: const Text('Cambiar contraseña'),
icon: const Icon(Icons.lock_reset, size: 16),
value: 'password',
),
if (canDelete) ...[
const MenuDivider(),
MenuItem(
label: const Text(
'Eliminar',
style: TextStyle(color: SomaColors.error),
),
icon: const Icon(Icons.delete_outline, size: 16, color: SomaColors.error),
value: 'delete',
),
],
],
);
}
void _handleContextAction(String? value) {
switch (value) {
case 'pago': onRegistrarPago();
case 'rutina': onAsignarRutina();
case 'historial': onVerHistorial();
case 'plan': onEditarPlan();
case 'edit': onEdit();
case 'toggle': onToggleStatus();
case 'password': onResetPassword?.call();
case 'delete': onDelete();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isWide = MediaQuery.of(context).size.width >= 800;
final isStaff = usuario.rol != 'cliente';
final stripe = _stripeColor(theme);
// Último pago — solo si tiene plan asignado
Widget ultimoPagoRow = const SizedBox.shrink();
if (usuario.tipoCuota != null) {
final pagosAsync = ref.watch(
userPagosProvider((dni: usuario.dni, incluirAnulados: false)),
);
ultimoPagoRow = pagosAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (pagos) {
if (pagos.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
'Último pago: ${pagos.first.mesPagadoDisplay}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
);
},
);
}
final card = ContextMenuRegion<String>(
contextMenu: _buildContextMenu(),
onItemSelected: _handleContextAction,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de estado — franja izquierda semántica
Container(width: 4, color: stripe),
// Contenido principal
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Avatar — ring amarillo para staff
Container(
padding: isStaff
? const EdgeInsets.all(2)
: EdgeInsets.zero,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isStaff
? SomaColors.primary
: Colors.transparent,
),
child: CircleAvatar(
radius: AppConstants.kAvatarRadiusMd,
backgroundColor: usuario.isActive
? SomaColors.primary.withAlpha(40)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
usuario.initials,
style: TextStyle(
color: usuario.isActive
? (isStaff
? SomaColors.onPrimary
: theme.colorScheme.onSurface)
: theme.colorScheme.onSurface.withAlpha(100),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
usuario.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: usuario.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface
.withAlpha(100),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_RolBadge(rol: usuario.rol),
if (deuda != null) ...[
const SizedBox(width: 6),
_DeudaBadge(monto: deuda!),
],
],
),
const SizedBox(height: 3),
Row(
children: [
// Indicador activo/inactivo inline
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: usuario.isActive
? SomaColors.success
: SomaColors.error,
),
),
Text(
'DNI: ${usuario.dni}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
if (usuario.mail != null &&
usuario.mail!.isNotEmpty) ...[
Text(
'',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(80),
),
),
Expanded(
child: Text(
usuario.mail!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
ultimoPagoRow,
],
),
),
const SizedBox(width: 6),
// Quick actions con fondo sutil (solo en pantallas anchas)
if (isWide) ...[
_QuickActionButton(
icon: Icons.payment,
tooltip: 'Registrar pago',
onPressed: onRegistrarPago,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.fitness_center,
tooltip: 'Asignar rutina',
onPressed: onAsignarRutina,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.history,
tooltip: 'Ver historial',
onPressed: onVerHistorial,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.card_membership,
tooltip: 'Editar plan',
onPressed: onEditarPlan,
),
const SizedBox(width: 4),
],
// Menú de acciones
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(130),
),
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
case 'toggle':
onToggleStatus();
case 'password':
onResetPassword?.call();
case 'delete':
onDelete();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 10),
Text('Editar',
style: TextStyle(fontSize: 13)),
],
),
),
PopupMenuItem(
value: 'toggle',
child: Row(
children: [
Icon(
usuario.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 18,
),
const SizedBox(width: 10),
Text(
usuario.isActive ? 'Desactivar' : 'Activar',
style: const TextStyle(fontSize: 13),
),
],
),
),
if (canResetPassword)
const PopupMenuItem(
value: 'password',
child: Row(
children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 10),
Text('Cambiar contraseña',
style: TextStyle(fontSize: 13)),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 18, color: SomaColors.error),
SizedBox(width: 10),
Text('Eliminar',
style: TextStyle(
fontSize: 13,
color: SomaColors.error)),
],
),
),
],
),
],
),
),
),
],
),
),
),
),
);
// Usuarios inactivos se atenúan visualmente
if (!usuario.isActive) {
return Opacity(opacity: 0.58, child: card);
}
return card;
}
}
class _QuickActionButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
const _QuickActionButton({
required this.icon,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: Icon(icon, size: 17),
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest.withAlpha(180),
foregroundColor: theme.colorScheme.onSurface.withAlpha(160),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
class _RolBadge extends StatelessWidget {
final String rol;
const _RolBadge({required this.rol});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isStaff =
rol == 'superadmin' || rol == 'admin' || rol == 'profesor';
final label = switch (rol) {
'superadmin' || 'admin' || 'profesor' => 'Admin',
'cliente' => 'Cliente',
_ => rol,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isStaff
? SomaColors.primary.withAlpha(25)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(5),
border: isStaff
? Border.all(color: SomaColors.primary.withAlpha(60), width: 0.5)
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isStaff
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(178),
),
),
);
}
}
class _DeudaBadge extends StatelessWidget {
final double monto;
const _DeudaBadge({required this.monto});
@override
Widget build(BuildContext context) {
final alDia = monto <= 0;
final color = alDia ? SomaColors.success : SomaColors.error;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
alDia
? 'Al día'
: 'Debe \$${monto.toStringAsFixed(monto.truncateToDouble() == monto ? 0 : 2)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,712 @@
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/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
/// Retorna 'edit' si el usuario quiere editar.
class UsuarioDetailDialog extends ConsumerWidget {
final Usuario usuario;
const UsuarioDetailDialog({super.key, required this.usuario});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull ?? [];
final pagosAsync = ref.watch(
userPagosProvider((dni: usuario.dni, incluirAnulados: false)),
);
final deudaMap = ref.watch(usuariosDeudaProvider).valueOrNull ?? {};
final width = MediaQuery.of(context).size.width;
final isWide = width >= AppConstants.kDesktopBreakpoint;
final theme = Theme.of(context);
final tipoCuota = usuario.tipoCuota != null
? tiposCuota
.where((t) => t.id == usuario.tipoCuota)
.firstOrNull
: null;
final deuda = (usuario.tipoCuota != null && usuario.isActive)
? (deudaMap[usuario.dni] ?? 0.0)
: null;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 500) / 2 : 16,
vertical: 24,
),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header con cerrar
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 0),
child: Row(
children: [
const Text(
'Detalle de usuario',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body scrollable
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar + nombre + rol
_buildHeader(theme),
const SizedBox(height: 20),
// Info personal
_sectionTitle('Información'),
const SizedBox(height: 8),
_infoCard(theme, [
_infoRow(Icons.badge_outlined, 'DNI',
usuario.dni),
if (usuario.mail != null &&
usuario.mail!.isNotEmpty)
_infoRow(Icons.email_outlined, 'Email',
usuario.mail!),
if (usuario.telefono != null &&
usuario.telefono!.isNotEmpty)
_infoRow(Icons.phone_outlined, 'Teléfono',
usuario.telefono!),
if (usuario.sexo != null &&
usuario.sexo!.isNotEmpty)
_infoRow(
Icons.person_outline,
'Sexo',
usuario.sexo == 'M'
? 'Masculino'
: usuario.sexo == 'F'
? 'Femenino'
: usuario.sexo!),
if (usuario.fechaCreacion != null)
_infoRow(
Icons.calendar_today_outlined,
'Miembro desde',
_fmtDate(usuario.fechaCreacion!)),
]),
// Medidas
if (usuario.peso != null ||
usuario.altura != null ||
usuario.fuerzaMax != null) ...[
const SizedBox(height: 16),
_sectionTitle('Medidas'),
const SizedBox(height: 8),
_buildMedidas(theme),
],
// Plan
const SizedBox(height: 16),
_sectionTitle('Plan'),
const SizedBox(height: 8),
tipoCuota != null
? _buildPlan(theme, tipoCuota)
: _emptyCard(
theme, 'Sin plan asignado'),
// Estado de cuenta
if (deuda != null) ...[
const SizedBox(height: 16),
_sectionTitle('Estado de cuenta'),
const SizedBox(height: 8),
_buildEstadoCuenta(
theme, deuda, pagosAsync),
],
// Últimos pagos
const SizedBox(height: 16),
_sectionTitle('Últimos pagos'),
const SizedBox(height: 8),
pagosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
),
),
),
error: (_, _) => _emptyCard(
theme, 'Error cargando pagos'),
data: (pagos) => pagos.isEmpty
? _emptyCard(
theme, 'Sin pagos registrados')
: _buildPagosList(theme, pagos),
),
],
),
),
),
const Divider(height: 1),
// Acciones
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cerrar',
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () =>
Navigator.of(context).pop('edit'),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
),
],
),
),
],
),
),
);
}
// ─── Secciones ──────────────────────────────────────────
Widget _buildHeader(ThemeData theme) {
final isStaff = usuario.rol != 'cliente';
final statusColor =
usuario.isActive ? SomaColors.success : SomaColors.error;
return Row(
children: [
// Avatar con ring para staff activos
Container(
padding: isStaff ? const EdgeInsets.all(2.5) : EdgeInsets.zero,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isStaff ? SomaColors.primary : Colors.transparent,
),
child: CircleAvatar(
radius: AppConstants.kAvatarRadiusLg,
backgroundColor: usuario.isActive
? SomaColors.primary.withAlpha(40)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
usuario.initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: usuario.isActive
? (isStaff
? SomaColors.onPrimary
: theme.colorScheme.onSurface)
: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
usuario.displayName,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Row(
children: [
// Rol badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isStaff
? SomaColors.primary.withAlpha(25)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(5),
border: isStaff
? Border.all(
color: SomaColors.primary.withAlpha(60),
width: 0.5)
: null,
),
child: Text(
usuario.rolDisplay,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isStaff
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 8),
// Estado pill
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: statusColor.withAlpha(60), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 5),
Text(
usuario.isActive ? 'Activo' : 'Inactivo',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
],
),
),
],
);
}
Widget _buildMedidas(ThemeData theme) {
final items = <(String, String)>[];
if (usuario.peso != null) {
items.add(('Peso',
'${usuario.peso!.toStringAsFixed(usuario.peso!.truncateToDouble() == usuario.peso! ? 0 : 1)} kg'));
}
if (usuario.altura != null) {
items.add(('Altura', '${usuario.altura} cm'));
}
if (usuario.fuerzaMax != null) {
items.add(('Fuerza máx', usuario.fuerzaMax!.toStringAsFixed(0)));
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SomaColors.primary.withAlpha(8),
border: Border.all(
color: SomaColors.primary.withAlpha(40),
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
children: [
for (int i = 0; i < items.length; i++) ...[
if (i > 0)
Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
Expanded(child: _medidaItem(theme, items[i].$1, items[i].$2)),
],
],
),
),
);
}
Widget _medidaItem(ThemeData theme, String label, String value) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
);
}
Widget _buildPlan(ThemeData theme, TipoCuota tc) {
return _cardContainer(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tc.nombre,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
),
if (tc.descripcion != null &&
tc.descripcion!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
tc.descripcion!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
const SizedBox(height: 8),
Row(
children: [
_planChip(theme, tc.precioDisplay),
const SizedBox(width: 8),
_planChip(theme, tc.diasDisplay),
const SizedBox(width: 8),
_planChip(theme, 'Vto. día ${tc.diaDePago}'),
],
),
if (tc.recargo != null && tc.recargo! > 0) ...[
const SizedBox(height: 6),
Text(
'Recargo: \$${tc.recargo!.toStringAsFixed(tc.recargo!.truncateToDouble() == tc.recargo! ? 0 : 2)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
],
),
);
}
Widget _planChip(ThemeData theme, String text) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
);
}
Widget _buildEstadoCuenta(
ThemeData theme,
double deuda,
AsyncValue<List<Pago>> pagosAsync,
) {
final alDia = deuda <= 0;
final statusColor = alDia ? SomaColors.success : SomaColors.error;
final ultimoPago = pagosAsync.valueOrNull?.firstOrNull;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: statusColor.withAlpha(alDia ? 14 : 18),
border: Border.all(
color: statusColor.withAlpha(60),
width: 0.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 8),
Text(
alDia
? 'Al día'
: 'Debe \$${deuda.toStringAsFixed(deuda.truncateToDouble() == deuda ? 0 : 2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: statusColor,
),
),
],
),
if (ultimoPago != null) ...[
const SizedBox(height: 10),
Divider(height: 1, color: statusColor.withAlpha(40)),
const SizedBox(height: 10),
Text(
'ÚLTIMO PAGO',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: statusColor.withAlpha(160),
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
ultimoPago.mesPagadoDisplay,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
Text(
'\$${ultimoPago.montoTotal.toStringAsFixed(ultimoPago.montoTotal.truncateToDouble() == ultimoPago.montoTotal ? 0 : 2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: statusColor),
),
const SizedBox(width: 8),
Text(
ultimoPago.metodo,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const Spacer(),
Text(
ultimoPago.fechaPagoDisplay,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
],
],
),
);
}
Widget _buildPagosList(ThemeData theme, List<Pago> pagos) {
final show = pagos.take(5).toList();
return _cardContainer(
theme,
child: Column(
children: [
for (int i = 0; i < show.length; i++) ...[
if (i > 0)
Divider(
height: 16,
color:
theme.colorScheme.surfaceContainerHighest,
),
Row(
children: [
Expanded(
child: Text(
show[i].mesPagadoDisplay,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
Text(
_fmtMonto(show[i].montoTotal),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
SizedBox(
width: 70,
child: Text(
show[i].metodo,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
textAlign: TextAlign.end,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
// ─── Helpers ──────────────────────────────────────────
Widget _sectionTitle(String title) {
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
title.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: SomaColors.primary,
letterSpacing: 0.8,
),
),
],
);
}
Widget _cardContainer(ThemeData theme, {required Widget child}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: child,
);
}
Widget _emptyCard(ThemeData theme, String text) {
return _cardContainer(
theme,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
),
),
);
}
Widget _infoCard(ThemeData theme, List<Widget> rows) {
return _cardContainer(
theme,
child: Column(children: rows),
);
}
Widget _infoRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 18, color: SomaColors.primary.withAlpha(150)),
const SizedBox(width: 10),
SizedBox(
width: 100,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
),
),
],
),
);
}
String _fmtMonto(double n) =>
'\$${n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2)}';
String _fmtDate(DateTime d) {
const meses = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
return '${meses[d.month]} ${d.year}';
}
}
@@ -0,0 +1,446 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
/// Dialog para crear o editar un usuario.
/// Retorna un `Map` con los datos si el usuario confirma, o null si cancela.
class UsuarioFormDialog extends StatefulWidget {
final Usuario? usuario; // null = crear, non-null = editar
final Set<String> existingDnis;
final bool actorIsSuperadmin;
const UsuarioFormDialog({
super.key,
this.usuario,
this.existingDnis = const {},
this.actorIsSuperadmin = false,
});
@override
State<UsuarioFormDialog> createState() => _UsuarioFormDialogState();
}
class _UsuarioFormDialogState extends State<UsuarioFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _dniCtrl;
late final TextEditingController _nombreCtrl;
late final TextEditingController _apellidoCtrl;
late final TextEditingController _mailCtrl;
late final TextEditingController _telefonoCtrl;
late final TextEditingController _passwordCtrl;
late final TextEditingController _pesoCtrl;
late final TextEditingController _alturaCtrl;
late final TextEditingController _fuerzaMaxCtrl;
late String _rol;
late String _sexo;
bool _obscurePassword = true;
bool get isEditing => widget.usuario != null;
bool get _showPasswordField =>
_rol != 'cliente' && (!isEditing || widget.actorIsSuperadmin);
@override
void initState() {
super.initState();
final u = widget.usuario;
_dniCtrl = TextEditingController(text: u?.dni ?? '');
_nombreCtrl = TextEditingController(text: u?.nombre ?? '');
_apellidoCtrl = TextEditingController(text: u?.apellido ?? '');
_mailCtrl = TextEditingController(text: u?.mail ?? '');
_telefonoCtrl = TextEditingController(text: u?.telefono ?? '');
_passwordCtrl = TextEditingController();
_pesoCtrl = TextEditingController(
text: u?.peso != null ? u!.peso!.toString() : '',
);
_alturaCtrl = TextEditingController(
text: u?.altura != null ? u!.altura!.toString() : '',
);
_fuerzaMaxCtrl = TextEditingController(
text: u?.fuerzaMax != null ? u!.fuerzaMax!.toString() : '',
);
_rol = u?.rol ?? 'cliente';
_sexo = u?.sexo ?? 'Hombre';
}
@override
void dispose() {
_dniCtrl.dispose();
_nombreCtrl.dispose();
_apellidoCtrl.dispose();
_mailCtrl.dispose();
_telefonoCtrl.dispose();
_passwordCtrl.dispose();
_pesoCtrl.dispose();
_alturaCtrl.dispose();
_fuerzaMaxCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (isEditing) {
final data = <String, dynamic>{
'dni': _dniCtrl.text.trim(),
'nombre': _nombreCtrl.text.trim(),
};
if (_apellidoCtrl.text.trim().isNotEmpty) {
data['apellido'] = _apellidoCtrl.text.trim();
}
if (_mailCtrl.text.trim().isNotEmpty) {
data['mail'] = _mailCtrl.text.trim();
}
if (_telefonoCtrl.text.trim().isNotEmpty) {
data['telefono'] = _telefonoCtrl.text.trim();
}
data['rol'] = _rol;
data['sexo'] = _sexo;
if (_pesoCtrl.text.trim().isNotEmpty) {
data['peso'] = double.tryParse(_pesoCtrl.text.trim());
}
if (_alturaCtrl.text.trim().isNotEmpty) {
data['altura'] = int.tryParse(_alturaCtrl.text.trim());
}
if (_fuerzaMaxCtrl.text.trim().isNotEmpty) {
data['fuerza_max'] = double.tryParse(_fuerzaMaxCtrl.text.trim());
}
if (_showPasswordField && _passwordCtrl.text.trim().isNotEmpty) {
data['password'] = _passwordCtrl.text.trim();
}
Navigator.of(context).pop(data);
} else {
// Crear
final usuario = Usuario(
id: '',
dni: _dniCtrl.text.trim(),
nombre: _nombreCtrl.text.trim(),
apellido: _apellidoCtrl.text.trim().isEmpty
? null
: _apellidoCtrl.text.trim(),
mail: _mailCtrl.text.trim().isEmpty ? null : _mailCtrl.text.trim(),
telefono: _telefonoCtrl.text.trim().isEmpty
? null
: _telefonoCtrl.text.trim(),
rol: _rol,
sexo: _sexo,
peso: double.tryParse(_pesoCtrl.text.trim()),
altura: int.tryParse(_alturaCtrl.text.trim()),
fuerzaMax: double.tryParse(_fuerzaMaxCtrl.text.trim()),
);
Navigator.of(context)
.pop(usuario.toInsertMap(_passwordCtrl.text.trim()));
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 520) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
isEditing ? 'Editar Usuario' : 'Nuevo Usuario',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Información básica'),
const SizedBox(height: 8),
SomaTextField(
controller: _dniCtrl,
labelText: 'DNI *',
prefixIcon: Icons.badge_outlined,
enabled: !isEditing,
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'DNI requerido';
}
if (!isEditing &&
widget.existingDnis.contains(v.trim())) {
return 'Ya existe un usuario con ese DNI';
}
return null;
},
),
const SizedBox(height: 12),
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.person_outline,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 12),
SomaTextField(
controller: _apellidoCtrl,
labelText: 'Apellido',
prefixIcon: Icons.person_outline,
),
const SizedBox(height: 20),
_sectionLabel('Contacto'),
const SizedBox(height: 8),
SomaTextField(
controller: _mailCtrl,
labelText: 'Email',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
SomaTextField(
controller: _telefonoCtrl,
labelText: 'Teléfono',
prefixIcon: Icons.phone_outlined,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 20),
_sectionLabel('Acceso'),
const SizedBox(height: 8),
// El campo de contraseña es dinámico según el rol
// elegido: los clientes no tienen contraseña en este
// panel (usan el bot de WhatsApp). En edición, solo el
// superadmin puede ver/tocar la contraseña de otro
// usuario.
if (_showPasswordField) ...[
SomaTextField(
controller: _passwordCtrl,
labelText: isEditing
? 'Nueva contraseña (opcional)'
: 'Contraseña *',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
size: 20,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
),
validator: (v) {
final value = v?.trim() ?? '';
if (!isEditing && value.isEmpty) {
return 'Contraseña requerida';
}
if (value.isNotEmpty && value.length < 8) {
return 'Mínimo 8 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
],
_dropdownField<String>(
label: 'Rol',
value: _rol,
items: const [
DropdownMenuItem(
value: 'cliente',
child: Text('Cliente'),
),
DropdownMenuItem(
value: 'admin',
child: Text('Admin'),
),
DropdownMenuItem(
value: 'superadmin',
child: Text('Super Admin'),
),
],
onChanged: (v) => setState(() => _rol = v!),
),
const SizedBox(height: 20),
_sectionLabel('Datos físicos'),
const SizedBox(height: 8),
_dropdownField<String>(
label: 'Sexo',
value: _sexo,
items: const [
DropdownMenuItem(
value: 'Hombre',
child: Text('Hombre'),
),
DropdownMenuItem(
value: 'Mujer',
child: Text('Mujer'),
),
DropdownMenuItem(
value: 'Otro',
child: Text('Otro'),
),
],
onChanged: (v) => setState(() => _sexo = v!),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _pesoCtrl,
labelText: 'Peso (kg)',
prefixIcon: Icons.monitor_weight_outlined,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d{0,3}\.?\d{0,2}'),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _alturaCtrl,
labelText: 'Altura (cm)',
prefixIcon: Icons.height,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
),
),
],
),
const SizedBox(height: 12),
SomaTextField(
controller: _fuerzaMaxCtrl,
labelText: 'Fuerza máx. (kg)',
prefixIcon: Icons.fitness_center_outlined,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d{0,7}\.?\d{0,2}'),
),
],
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
Widget _sectionLabel(String text) {
return Text(
text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5,
),
);
}
Widget _dropdownField<T>({
required String label,
required T value,
required List<DropdownMenuItem<T>> items,
required ValueChanged<T?> onChanged,
}) {
return DropdownButtonFormField<T>(
initialValue: value,
items: items,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
);
}
}
@@ -0,0 +1,981 @@
import 'dart:ui';
import 'package:fl_chart/fl_chart.dart';
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/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class UsuariosOverview extends ConsumerWidget {
final VoidCallback? onVerTodos;
const UsuariosOverview({super.key, this.onVerTodos});
@override
Widget build(BuildContext context, WidgetRef ref) {
final usuariosAsync = ref.watch(usuariosProvider);
final deudaAsync = ref.watch(usuariosDeudaProvider);
final isWide =
MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint;
return usuariosAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Text(
e.toString().replaceFirst('Exception: ', ''),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
),
data: (usuarios) {
final deudaMap = deudaAsync.valueOrNull ?? {};
return _OverviewContent(
usuarios: usuarios,
deudaMap: deudaMap,
isWide: isWide,
onVerTodos: onVerTodos,
);
},
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Content
// ─────────────────────────────────────────────────────────────────────────────
class _OverviewContent extends StatelessWidget {
final List<Usuario> usuarios;
final Map<String, double> deudaMap;
final bool isWide;
final VoidCallback? onVerTodos;
const _OverviewContent({
required this.usuarios,
required this.deudaMap,
required this.isWide,
this.onVerTodos,
});
static const _kInactive = Color(0xFF9E9E9E);
static const _kSinPlan = Color(0xFFBDBDBD);
static const _kStaff = Color(0xFF42A5F5);
@override
Widget build(BuildContext context) {
final hPad = isWide ? 32.0 : 16.0;
// ── Metrics ──────────────────────────────────────────────────────────────
final total = usuarios.length;
final activos = usuarios.where((u) => u.isActive).length;
final inactivos = total - activos;
final conPlan = usuarios
.where((u) => u.isActive && u.tipoCuota != null)
.length;
final sinPlan = activos - conPlan;
final staff = usuarios
.where(
(u) =>
u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor',
)
.length;
final clientes = usuarios.where((u) => u.rol == 'cliente').length;
final alDia = deudaMap.values.where((v) => v <= 0).length;
final debe = deudaMap.values.where((v) => v > 0).length;
final ultimos =
([...usuarios]..sort((a, b) {
if (a.fechaCreacion == null && b.fechaCreacion == null) return 0;
if (a.fechaCreacion == null) return 1;
if (b.fechaCreacion == null) return -1;
return b.fechaCreacion!.compareTo(a.fechaCreacion!);
}))
.take(5)
.toList();
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(hPad, 12, hPad, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stat cards
_StatGrid(
total: total,
activos: activos,
conPlan: conPlan,
alDia: alDia,
isWide: isWide,
),
const SizedBox(height: 14),
// Membership pulse
_MembershipPulse(
alDia: alDia,
debe: debe,
sinPlan: sinPlan,
inactivos: inactivos,
total: total,
),
const SizedBox(height: 14),
// Donut charts
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _DonutCard(
title: 'Membresía',
sections: _membershipSections(activos, inactivos),
legend: [
_LegendItem('Activos', SomaColors.success, activos),
_LegendItem('Inactivos', _kInactive, inactivos),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _DonutCard(
title: 'Pagos del mes',
sections: _pageSections(alDia, debe, sinPlan),
legend: [
_LegendItem('Al día', SomaColors.success, alDia),
_LegendItem('Debe', SomaColors.error, debe),
_LegendItem('Sin plan', _kSinPlan, sinPlan),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _DonutCard(
title: 'Roles',
sections: _rolesSections(clientes, staff),
legend: [
_LegendItem('Clientes', SomaColors.primary, clientes),
_LegendItem('Staff', _kStaff, staff),
],
),
),
],
)
: Column(
children: [
_DonutCard(
title: 'Membresía',
sections: _membershipSections(activos, inactivos),
legend: [
_LegendItem('Activos', SomaColors.success, activos),
_LegendItem('Inactivos', _kInactive, inactivos),
],
),
const SizedBox(height: 12),
_DonutCard(
title: 'Pagos del mes',
sections: _pageSections(alDia, debe, sinPlan),
legend: [
_LegendItem('Al día', SomaColors.success, alDia),
_LegendItem('Debe', SomaColors.error, debe),
_LegendItem('Sin plan', _kSinPlan, sinPlan),
],
),
const SizedBox(height: 12),
_DonutCard(
title: 'Roles',
sections: _rolesSections(clientes, staff),
legend: [
_LegendItem('Clientes', SomaColors.primary, clientes),
_LegendItem('Staff', _kStaff, staff),
],
),
],
),
// Recent users
if (ultimos.isNotEmpty) ...[
const SizedBox(height: 14),
Row(
children: [
const _SectionLabel('Últimos ingresados'),
const Spacer(),
if (onVerTodos != null)
TextButton(
onPressed: onVerTodos,
style: TextButton.styleFrom(
foregroundColor: SomaColors.primary,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Ver todos'),
SizedBox(width: 4),
Icon(Icons.arrow_forward, size: 14),
],
),
),
],
),
const SizedBox(height: 10),
...ultimos.map(
(u) => _RecentUserStub(usuario: u, deuda: deudaMap[u.dni]),
),
],
],
),
);
}
List<PieChartSectionData> _membershipSections(int activos, int inactivos) {
final total = activos + inactivos;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (activos > 0)
PieChartSectionData(
color: SomaColors.success,
value: activos.toDouble(),
title: '',
radius: 30,
),
if (inactivos > 0)
PieChartSectionData(
color: _kInactive,
value: inactivos.toDouble(),
title: '',
radius: 30,
),
];
}
List<PieChartSectionData> _rolesSections(int clientes, int staff) {
final total = clientes + staff;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (clientes > 0)
PieChartSectionData(
color: SomaColors.primary,
value: clientes.toDouble(),
title: '',
radius: 30,
),
if (staff > 0)
PieChartSectionData(
color: _kStaff,
value: staff.toDouble(),
title: '',
radius: 30,
),
];
}
List<PieChartSectionData> _pageSections(int alDia, int debe, int sinPlan) {
final total = alDia + debe + sinPlan;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (alDia > 0)
PieChartSectionData(
color: SomaColors.success,
value: alDia.toDouble(),
title: '',
radius: 30,
),
if (debe > 0)
PieChartSectionData(
color: SomaColors.error,
value: debe.toDouble(),
title: '',
radius: 30,
),
if (sinPlan > 0)
PieChartSectionData(
color: _kSinPlan,
value: sinPlan.toDouble(),
title: '',
radius: 30,
),
];
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Glass card container
// ─────────────────────────────────────────────────────────────────────────────
class _GlassCard extends StatelessWidget {
final Widget child;
final EdgeInsets padding;
final double radius;
const _GlassCard({
required this.child,
this.padding = const EdgeInsets.all(16),
this.radius = 20,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: Container(
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: Colors.white.withAlpha(18),
border: Border.all(
color: Colors.white.withAlpha(38),
width: 0.8,
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(55),
blurRadius: 24,
spreadRadius: -4,
offset: const Offset(0, 6),
),
],
),
child: child,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Stat grid + card
// ─────────────────────────────────────────────────────────────────────────────
class _StatGrid extends StatelessWidget {
final int total;
final int activos;
final int conPlan;
final int alDia;
final bool isWide;
const _StatGrid({
required this.total,
required this.activos,
required this.conPlan,
required this.alDia,
required this.isWide,
});
@override
Widget build(BuildContext context) {
final items = [
(Icons.groups_outlined, 'Total', total, SomaColors.primary),
(Icons.how_to_reg_outlined, 'Activos', activos, SomaColors.success),
(
Icons.card_membership_outlined,
'Con plan',
conPlan,
const Color(0xFF42A5F5),
),
(Icons.check_circle_outline, 'Al día', alDia, SomaColors.success),
];
if (isWide) {
return Row(
children: [
for (int i = 0; i < items.length; i++) ...[
Expanded(
child: _StatCard(
icon: items[i].$1,
label: items[i].$2,
value: items[i].$3,
color: items[i].$4,
),
),
if (i < items.length - 1) const SizedBox(width: 10),
],
],
);
}
return Column(
children: [
Row(
children: [
Expanded(
child: _StatCard(
icon: items[0].$1,
label: items[0].$2,
value: items[0].$3,
color: items[0].$4,
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: items[1].$1,
label: items[1].$2,
value: items[1].$3,
color: items[1].$4,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _StatCard(
icon: items[2].$1,
label: items[2].$2,
value: items[2].$3,
color: items[2].$4,
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: items[3].$1,
label: items[3].$2,
value: items[3].$3,
color: items[3].$4,
),
),
],
),
],
);
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String label;
final int value;
final Color color;
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return _GlassCard(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(height: 10),
Text(
'$value',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: color,
height: 1.0,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Membership pulse
// ─────────────────────────────────────────────────────────────────────────────
class _MembershipPulse extends StatelessWidget {
final int alDia;
final int debe;
final int sinPlan;
final int inactivos;
final int total;
const _MembershipPulse({
required this.alDia,
required this.debe,
required this.sinPlan,
required this.inactivos,
required this.total,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final segments = <({Color color, int count, String label})>[
(color: SomaColors.success, count: alDia, label: 'Al día'),
(color: SomaColors.error, count: debe, label: 'Debe'),
(
color: SomaColors.primary.withAlpha(130),
count: sinPlan,
label: 'Sin plan',
),
(
color: theme.colorScheme.onSurface.withAlpha(45),
count: inactivos,
label: 'Inactivos',
),
].where((s) => s.count > 0).toList();
return _GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Pulso de membresía',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(200),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.primary.withAlpha(50),
width: 0.5,
),
),
child: Text(
'$total miembros',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
),
),
),
],
),
const SizedBox(height: 14),
// Proportional strip
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
height: 18,
child: total == 0
? Container(color: theme.colorScheme.surfaceContainerHighest)
: Row(
children: [
for (int i = 0; i < segments.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
Expanded(
flex: segments[i].count,
child: Container(color: segments[i].color),
),
],
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 14,
runSpacing: 6,
children: segments.map((s) {
final pct = total > 0 ? (s.count / total * 100).round() : 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: s.color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 5),
Text(
'${s.label} $pct%',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}).toList(),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Donut card — column layout fixes overflow
// ─────────────────────────────────────────────────────────────────────────────
class _LegendItem {
final String label;
final Color color;
final int count;
const _LegendItem(this.label, this.color, this.count);
}
class _DonutCard extends StatelessWidget {
final String title;
final List<PieChartSectionData> sections;
final List<_LegendItem> legend;
const _DonutCard({
required this.title,
required this.sections,
required this.legend,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final total = legend.fold<int>(0, (s, item) => s + item.count);
final leadColor = legend.isNotEmpty
? legend.first.color
: SomaColors.primary;
return _GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title + total
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(200),
),
),
),
Text(
'$total',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: leadColor,
height: 1.0,
),
),
],
),
const SizedBox(height: 16),
// Donut chart — constrained to avoid overflow
SizedBox(
height: 110,
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: PieChart(
PieChartData(
sections: sections,
centerSpaceRadius: 32,
sectionsSpace: 3,
startDegreeOffset: -90,
),
),
),
),
),
const SizedBox(height: 16),
// Legend below — no overflow possible
...legend.map((item) {
final pct = total > 0 ? (item.count / total * 100).round() : 0;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
),
Text(
'$pct%',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 8),
Text(
'${item.count}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: item.color,
),
),
],
),
);
}),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Section label
// ─────────────────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Recent user stub
// ─────────────────────────────────────────────────────────────────────────────
class _RecentUserStub extends StatelessWidget {
final Usuario usuario;
final double? deuda;
const _RecentUserStub({required this.usuario, required this.deuda});
String _initials() {
if (usuario.nombre.isNotEmpty) {
final ap = usuario.apellido;
if (ap != null && ap.isNotEmpty) {
return '${usuario.nombre[0]}${ap[0]}'.toUpperCase();
}
return usuario.nombre[0].toUpperCase();
}
if (usuario.dni.length >= 2) return usuario.dni.substring(0, 2);
return '?';
}
String _rolLabel() => switch (usuario.rol) {
'superadmin' => 'Super Admin',
'admin' => 'Admin',
'profesor' => 'Profesor',
_ => 'Cliente',
};
String _fechaLabel() {
final d = usuario.fechaCreacion;
if (d == null) return '';
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color statusColor;
final String statusLabel;
if (!usuario.isActive) {
statusColor = theme.colorScheme.onSurface.withAlpha(100);
statusLabel = 'Inactivo';
} else if (usuario.tipoCuota == null) {
statusColor = SomaColors.primary.withAlpha(160);
statusLabel = 'Sin plan';
} else if (deuda != null && deuda! > 0) {
statusColor = SomaColors.error;
statusLabel = 'Debe';
} else {
statusColor = SomaColors.success;
statusLabel = 'Al día';
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
radius: 14,
child: Row(
children: [
// Avatar with status dot
Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.primary.withAlpha(35),
child: Text(
_initials(),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
border: Border.all(
color: SomaColors.darkBackground,
width: 1.5,
),
),
),
),
],
),
const SizedBox(width: 12),
// Name + role
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
usuario.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
Text(
_rolLabel(),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
],
),
),
// Date
if (_fechaLabel().isNotEmpty) ...[
Text(
_fechaLabel(),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 10),
],
// Status badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: statusColor.withAlpha(60),
width: 0.5,
),
),
child: Text(
statusLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,756 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
enum SortColumn { nombre, dni, email, rol, deuda, estado, ultimoPago }
enum TableCol { avatar, nombre, dni, email, rol, estadoPago, ultimoPago, estado }
extension _ColProps on TableCol {
String get label => switch (this) {
TableCol.avatar => 'Avatar',
TableCol.nombre => 'Nombre',
TableCol.dni => 'DNI',
TableCol.email => 'Email',
TableCol.rol => 'Rol',
TableCol.estadoPago => 'Estado Pago',
TableCol.ultimoPago => 'Último Pago',
TableCol.estado => 'Estado',
};
int get flex => switch (this) {
TableCol.avatar => 0,
TableCol.nombre => 28,
TableCol.dni => 15,
TableCol.email => 23,
TableCol.rol => 13,
TableCol.estadoPago => 17,
TableCol.ultimoPago => 15,
TableCol.estado => 13,
};
bool get hideable => this != TableCol.nombre && this != TableCol.dni;
SortColumn? get sort => switch (this) {
TableCol.nombre => SortColumn.nombre,
TableCol.dni => SortColumn.dni,
TableCol.email => SortColumn.email,
TableCol.rol => SortColumn.rol,
TableCol.estadoPago => SortColumn.deuda,
TableCol.ultimoPago => SortColumn.ultimoPago,
TableCol.estado => SortColumn.estado,
_ => null,
};
}
const double _kAvatarColW = 44.0;
const double _kToggleBtnW = 36.0;
class UsuariosTableView extends StatefulWidget {
final List<Usuario> usuarios;
final Map<String, double> deudaMap;
final Map<String, DateTime?> ultimoPagoMap;
final void Function(Usuario) onTap;
final void Function(Usuario) onEdit;
final void Function(Usuario) onToggleStatus;
final void Function(Usuario) onDelete;
final void Function(Usuario) onRegistrarPago;
final void Function(Usuario) onAsignarRutina;
final void Function(Usuario) onVerHistorial;
final void Function(Usuario) onEditarPlan;
final bool actorIsSuperadmin;
final void Function(Usuario)? onResetPassword;
const UsuariosTableView({
super.key,
required this.usuarios,
required this.deudaMap,
required this.ultimoPagoMap,
required this.onTap,
required this.onEdit,
required this.onToggleStatus,
required this.onDelete,
required this.onRegistrarPago,
required this.onAsignarRutina,
required this.onVerHistorial,
required this.onEditarPlan,
this.actorIsSuperadmin = false,
this.onResetPassword,
});
@override
State<UsuariosTableView> createState() => _UsuariosTableViewState();
}
class _UsuariosTableViewState extends State<UsuariosTableView> {
SortColumn _sortColumn = SortColumn.nombre;
bool _sortAscending = true;
final _scrollCtrl = ScrollController();
bool _scrollbarVisible = false;
final _columnBtnKey = GlobalKey();
final Set<TableCol> _visibleCols = {
TableCol.nombre,
TableCol.dni,
TableCol.rol,
TableCol.estadoPago,
TableCol.ultimoPago,
TableCol.estado,
};
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
double? _getDeuda(Usuario u) {
if (u.tipoCuota == null || !u.isActive) return null;
return widget.deudaMap[u.dni] ?? 0.0;
}
List<Usuario> get _sorted {
final list = List<Usuario>.from(widget.usuarios);
list.sort((a, b) {
final int cmp;
switch (_sortColumn) {
case SortColumn.nombre:
cmp = a.displayName
.toLowerCase()
.compareTo(b.displayName.toLowerCase());
case SortColumn.dni:
cmp = a.dni.compareTo(b.dni);
case SortColumn.email:
cmp = (a.mail ?? '')
.toLowerCase()
.compareTo((b.mail ?? '').toLowerCase());
case SortColumn.rol:
cmp = a.rolDisplay.compareTo(b.rolDisplay);
case SortColumn.deuda:
cmp = (_getDeuda(a) ?? -999999)
.compareTo(_getDeuda(b) ?? -999999);
case SortColumn.ultimoPago:
final fa = widget.ultimoPagoMap[a.dni];
final fb = widget.ultimoPagoMap[b.dni];
if (fa == null && fb == null) {
cmp = 0;
} else if (fa == null) {
cmp = -1;
} else if (fb == null) {
cmp = 1;
} else {
cmp = fa.compareTo(fb);
}
case SortColumn.estado:
cmp =
(a.isActive ? 1 : 0).compareTo(b.isActive ? 1 : 0);
}
return _sortAscending ? cmp : -cmp;
});
return list;
}
void _onSort(SortColumn col) => setState(() {
if (_sortColumn == col) {
_sortAscending = !_sortAscending;
} else {
_sortColumn = col;
_sortAscending = true;
}
});
void _showColumnPicker() {
final ctx = _columnBtnKey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject() as RenderBox;
final offset = box.localToGlobal(Offset.zero);
final btnSize = box.size;
final screenWidth = MediaQuery.of(context).size.width;
showDialog(
context: context,
barrierColor: Colors.transparent,
builder: (dCtx) => StatefulBuilder(
builder: (dCtx, setLocal) => Stack(
children: [
GestureDetector(
onTap: () => Navigator.pop(dCtx),
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
Positioned(
top: offset.dy + btnSize.height + 4,
right: screenWidth - (offset.dx + btnSize.width),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(10),
color: Theme.of(dCtx).colorScheme.surface,
child: Container(
constraints: const BoxConstraints(minWidth: 175),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 8),
child: Text(
'COLUMNAS VISIBLES',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(dCtx)
.colorScheme
.onSurface
.withAlpha(100),
),
),
),
for (final col
in TableCol.values.where((c) => c.hideable))
InkWell(
onTap: () {
setState(() {
if (_visibleCols.contains(col)) {
_visibleCols.remove(col);
} else {
_visibleCols.add(col);
}
});
setLocal(() {});
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 9),
child: Row(
children: [
Icon(
_visibleCols.contains(col)
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded,
size: 17,
color: _visibleCols.contains(col)
? SomaColors.primary
: Theme.of(dCtx)
.colorScheme
.onSurface
.withAlpha(100),
),
const SizedBox(width: 10),
Text(col.label,
style: const TextStyle(fontSize: 13)),
],
),
),
),
],
),
),
),
),
],
),
),
);
}
ContextMenu<String> _contextMenuFor(Usuario u) => ContextMenu(
entries: [
MenuItem(
label: const Text('Registrar pago'),
icon: const Icon(Icons.payment, size: 16),
value: 'pago',
),
MenuItem(
label: const Text('Asignar rutina'),
icon: const Icon(Icons.fitness_center, size: 16),
value: 'rutina',
),
MenuItem(
label: const Text('Ver historial'),
icon: const Icon(Icons.history, size: 16),
value: 'historial',
),
MenuItem(
label: const Text('Editar plan'),
icon: const Icon(Icons.card_membership, size: 16),
value: 'plan',
),
const MenuDivider(),
MenuItem(
label: const Text('Editar'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
MenuItem(
label: Text(u.isActive ? 'Desactivar' : 'Activar'),
icon: Icon(
u.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 16,
),
value: 'toggle',
),
if (widget.actorIsSuperadmin && u.rol != 'cliente')
MenuItem(
label: const Text('Cambiar contraseña'),
icon: const Icon(Icons.lock_reset, size: 16),
value: 'password',
),
if (widget.actorIsSuperadmin)
MenuItem(
label: const Text('Eliminar',
style: TextStyle(color: SomaColors.error)),
icon: const Icon(Icons.delete_outline,
size: 16, color: SomaColors.error),
value: 'delete',
),
],
);
void _handleAction(String? value, Usuario u) {
if (value == null) return;
switch (value) {
case 'pago':
widget.onRegistrarPago(u);
case 'rutina':
widget.onAsignarRutina(u);
case 'historial':
widget.onVerHistorial(u);
case 'plan':
widget.onEditarPlan(u);
case 'edit':
widget.onEdit(u);
case 'toggle':
widget.onToggleStatus(u);
case 'password':
widget.onResetPassword?.call(u);
case 'delete':
widget.onDelete(u);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sorted = _sorted;
if (sorted.isEmpty) {
return const Center(child: Text('No hay usuarios para mostrar'));
}
final flexCols = TableCol.values
.where((c) => c != TableCol.avatar && _visibleCols.contains(c))
.toList();
return Container(
decoration: BoxDecoration(
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(70),
),
borderRadius: BorderRadius.circular(14),
),
clipBehavior: Clip.hardEdge,
child: Column(
children: [
// ── Header ───────────────────────────────────────────────────
Container(
decoration: BoxDecoration(
color:
theme.colorScheme.surfaceContainerHighest.withAlpha(50),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
),
),
),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
children: [
if (_visibleCols.contains(TableCol.avatar))
const SizedBox(width: _kAvatarColW),
for (final col in flexCols)
Expanded(
flex: col.flex,
child: _HeaderCell(
col: col,
sortColumn: _sortColumn,
ascending: _sortAscending,
onSort: () {
if (col.sort != null) _onSort(col.sort!);
},
),
),
SizedBox(
width: _kToggleBtnW,
child: Align(
alignment: Alignment.centerRight,
child: IconButton(
key: _columnBtnKey,
icon: const Icon(Icons.view_column_outlined,
size: 16),
tooltip: 'Columnas',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28, minHeight: 28),
style: IconButton.styleFrom(
foregroundColor:
theme.colorScheme.onSurface.withAlpha(110),
),
onPressed: _showColumnPicker,
),
),
),
],
),
),
// ── Rows ─────────────────────────────────────────────────────
Expanded(
child: MouseRegion(
onEnter: (_) =>
setState(() => _scrollbarVisible = true),
onExit: (_) =>
setState(() => _scrollbarVisible = false),
child: ScrollbarTheme(
data: ScrollbarThemeData(
thumbColor: WidgetStateProperty.all(
_scrollbarVisible
? theme.colorScheme.onSurface
.withValues(alpha: 0.35)
: Colors.transparent,
),
trackVisibility:
WidgetStateProperty.all(false),
trackColor:
WidgetStateProperty.all(Colors.transparent),
trackBorderColor:
WidgetStateProperty.all(Colors.transparent),
),
child: Scrollbar(
controller: _scrollCtrl,
thumbVisibility: true,
child: ListView.builder(
controller: _scrollCtrl,
itemCount: sorted.length,
itemBuilder: (context, i) {
final u = sorted[i];
return ContextMenuRegion(
contextMenu: _contextMenuFor(u),
onItemSelected: (v) =>
_handleAction(v, u),
child: _UserRow(
usuario: u,
deuda: _getDeuda(u),
ultimoPago: widget.ultimoPagoMap[u.dni],
visibleCols: _visibleCols,
flexCols: flexCols,
onTap: () => widget.onTap(u),
isLast: i == sorted.length - 1,
),
);
},
),
),
),
),
),
],
),
);
}
}
// ── Header cell ────────────────────────────────────────────────────────────────
class _HeaderCell extends StatelessWidget {
final TableCol col;
final SortColumn sortColumn;
final bool ascending;
final VoidCallback onSort;
const _HeaderCell({
required this.col,
required this.sortColumn,
required this.ascending,
required this.onSort,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isActive = col.sort != null && col.sort == sortColumn;
final label = Text(
col.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: isActive
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(110),
),
);
if (col.sort == null) return label;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onSort,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
label,
const SizedBox(width: 3),
if (isActive)
Icon(
ascending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 11,
color: SomaColors.primary,
)
else
Icon(
Icons.unfold_more_rounded,
size: 11,
color: theme.colorScheme.onSurface.withAlpha(55),
),
],
),
),
);
}
}
// ── Data row ───────────────────────────────────────────────────────────────────
class _UserRow extends StatefulWidget {
final Usuario usuario;
final double? deuda;
final DateTime? ultimoPago;
final Set<TableCol> visibleCols;
final List<TableCol> flexCols;
final VoidCallback onTap;
final bool isLast;
const _UserRow({
required this.usuario,
required this.deuda,
required this.ultimoPago,
required this.visibleCols,
required this.flexCols,
required this.onTap,
required this.isLast,
});
@override
State<_UserRow> createState() => _UserRowState();
}
class _UserRowState extends State<_UserRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final u = widget.usuario;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.onSurface.withAlpha(7)
: Colors.transparent,
border: widget.isLast
? null
: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(50),
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 52,
child: Row(
children: [
if (widget.visibleCols.contains(TableCol.avatar))
SizedBox(
width: _kAvatarColW,
child: CircleAvatar(
radius: 14,
backgroundColor: u.isActive
? SomaColors.primary.withAlpha(30)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
u.initials,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: u.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface
.withAlpha(100),
),
),
),
),
for (final col in widget.flexCols)
Expanded(
flex: col.flex,
child: _cellFor(col, u, theme),
),
const SizedBox(width: _kToggleBtnW),
],
),
),
),
);
}
Widget _cellFor(TableCol col, Usuario u, ThemeData theme) =>
switch (col) {
TableCol.nombre => Padding(
padding: const EdgeInsets.only(right: 12),
child: Text(
u.displayName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: u.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(100),
),
overflow: TextOverflow.ellipsis,
),
),
TableCol.dni => Text(
u.dni,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
TableCol.email => Padding(
padding: const EdgeInsets.only(right: 12),
child: Text(
u.mail ?? '-',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
overflow: TextOverflow.ellipsis,
),
),
TableCol.rol => _RolText(rol: u.rol),
TableCol.estadoPago => widget.deuda != null
? _DeudaText(monto: widget.deuda!)
: Text(
'Sin plan',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
TableCol.ultimoPago => () {
final d = widget.ultimoPago;
if (d == null) {
return Text(
'-',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(100),
),
);
}
return Text(
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
);
}(),
TableCol.estado => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
u.isActive ? SomaColors.success : SomaColors.error,
),
),
const SizedBox(width: 5),
Text(
u.isActive ? 'Activo' : 'Inactivo',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
_ => const SizedBox.shrink(),
};
}
// ── Role text ──────────────────────────────────────────────────────────────────
class _RolText extends StatelessWidget {
final String rol;
const _RolText({required this.rol});
@override
Widget build(BuildContext context) {
final (Color fg, String label) = switch (rol) {
'superadmin' => (const Color(0xFFCE93D8), 'Superadmin'),
'admin' => (SomaColors.primary, 'Admin'),
'profesor' => (const Color(0xFF90CAF9), 'Profesor'),
_ => (
Theme.of(context).colorScheme.onSurface.withAlpha(130),
'Cliente',
),
};
return Text(
label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg),
);
}
}
// ── Debt text ──────────────────────────────────────────────────────────────────
class _DeudaText extends StatelessWidget {
final double monto;
const _DeudaText({required this.monto});
@override
Widget build(BuildContext context) {
final isDebe = monto > 0;
final (Color fg, String label) = isDebe
? (SomaColors.error, 'Debe \$$monto')
: (SomaColors.success, 'Al día');
return Text(
label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg),
);
}
}