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