Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -0,0 +1,156 @@
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/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
class PagosRepositoryImpl implements PagosRepository {
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<Pago>> getPagos({
int pagina = 1,
int cantidad = 50,
String? dni,
bool incluirAnulados = false,
}) async {
final token = await _getToken();
final params = <String, dynamic>{
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
'p_incluir_anulados': incluirAnulados,
};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetPagos,
params: params,
);
if (response is List) {
return response
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<Pago>> getMisPagos({
int pagina = 1,
int cantidad = 20,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetMisPagos,
params: {
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
},
);
if (response is List) {
return response
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertPago(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertPago,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<Pago> editPago(String id, Map<String, dynamic> datos) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcEditarPago,
params: {
'p_id': id,
'p_datos': datos,
'p_token': token,
},
);
if (response is Map<String, dynamic>) {
return Pago.fromMap(response);
}
throw Exception('Respuesta inválida al editar el pago.');
}
@override
Future<Pago> anularPago(String id, String? motivo) async {
final token = await _getToken();
// El backend hace NULLIF(trim(p_motivo), ''); igualmente normalizamos
// a null antes de mandar para evitar enviar whitespace innecesario.
final motivoNormalizado =
(motivo == null || motivo.trim().isEmpty) ? null : motivo;
final response = await SupabaseConfig.rpc(
AppConstants.rpcAnularPago,
params: {
'p_id': id,
'p_motivo': motivoNormalizado,
'p_token': token,
},
);
if (response is Map<String, dynamic>) {
return Pago.fromMap(response);
}
throw Exception('Respuesta inválida al anular el pago.');
}
@override
Future<List<MetodoPago>> getMetodosPago() async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetMetodosPago,
params: {'p_token': token},
);
if (response is List) {
return response
.map((e) => MetodoPago.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> updateMetodoPago(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateMetodoPago,
params: {
'p_token': token,
'p_datos': datos,
},
);
}
}
@@ -0,0 +1,22 @@
class MetodoPago {
final int id;
final String descripcion;
final bool activo;
final String? icono;
const MetodoPago({
required this.id,
required this.descripcion,
this.activo = true,
this.icono,
});
factory MetodoPago.fromMap(Map<String, dynamic> map) {
return MetodoPago(
id: map['id'] as int,
descripcion: map['descripcion'] as String? ?? '',
activo: map['activo'] as bool? ?? true,
icono: map['icono'] as String?,
);
}
}
@@ -0,0 +1,158 @@
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
class Pago {
final String id;
final PagoTipo tipo;
// Cuando tipo == ajuste, anioMesPagado puede venir vacío (NULL en DB).
final String anioMesPagado; // date string "YYYY-MM-DD" (siempre día 1) o vacío
final DateTime? fechaPago;
final double montoTotal;
// Cuando tipo == ajuste, metodo puede venir vacío (NULL en DB).
final String metodo;
final Map<String, dynamic>? detalle;
// Sólo presente en fc_obtener_pagos (admin), no en fc_obtener_mis_pagos.
final PagoCliente? cliente;
// Auditoría de creación. createdBy es el UUID del autor — habilita
// gating local "este pago lo creé yo".
final DateTime? createdAt;
final String? createdBy;
final String? createdByNombre;
// Auditoría de última edición (null si nunca se editó).
final DateTime? updatedAt;
final String? updatedByNombre;
// Auditoría de anulación (soft-delete).
final DateTime? anuladoAt;
final String? anuladoPorNombre;
final String? motivoAnulacion;
const Pago({
required this.id,
required this.tipo,
required this.anioMesPagado,
this.fechaPago,
required this.montoTotal,
required this.metodo,
this.detalle,
this.cliente,
this.createdAt,
this.createdBy,
this.createdByNombre,
this.updatedAt,
this.updatedByNombre,
this.anuladoAt,
this.anuladoPorNombre,
this.motivoAnulacion,
});
factory Pago.fromMap(Map<String, dynamic> map) {
return Pago(
id: map['id'] as String,
tipo: PagoTipo.fromString(map['tipo'] as String?),
anioMesPagado: map['anio_mes_pagado'] as String? ?? '',
fechaPago: _parseDate(map['fecha_pago']),
montoTotal: (map['monto_total'] as num?)?.toDouble() ?? 0,
metodo: map['metodo'] as String? ?? '',
detalle: map['detalle'] as Map<String, dynamic>?,
cliente: map['cliente'] != null
? PagoCliente.fromMap(map['cliente'] as Map<String, dynamic>)
: null,
createdAt: _parseDate(map['created_at']),
createdBy: map['created_by'] as String?,
createdByNombre: map['created_by_nombre'] as String?,
updatedAt: _parseDate(map['updated_at']),
updatedByNombre: map['updated_by_nombre'] as String?,
anuladoAt: _parseDate(map['anulado_at']),
anuladoPorNombre: map['anulado_por_nombre'] as String?,
motivoAnulacion: map['motivo_anulacion'] as String?,
);
}
static DateTime? _parseDate(dynamic raw) {
if (raw == null) return null;
return DateTime.tryParse(raw.toString());
}
/// Mes y año formateado: "Marzo 2026"
String get mesPagadoDisplay {
final date = DateTime.tryParse(anioMesPagado);
if (date == null) return anioMesPagado;
const meses = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
return '${meses[date.month]} ${date.year}';
}
/// Fecha de pago formateada: "15/03/2026"
String get fechaPagoDisplay {
if (fechaPago == null) return '-';
final d = fechaPago!;
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
bool get isAnulado => anuladoAt != null;
bool get isEditado => updatedAt != null;
/// True si el pago todavía está dentro de la ventana de edición desde su
/// creación. La ventana se pasa como parámetro para no acoplar la entidad
/// a un provider; el caller obtiene el valor de
/// AppConstants.pagosVentanaEdicionMinutosDefault.
///
/// El backend sigue siendo la fuente de verdad: este getter sirve sólo
/// para gating local de UI.
bool isEditableWindow(int ventanaMinutos) {
if (createdAt == null) return false;
final diff = DateTime.now().difference(createdAt!).inMinutes;
return diff < ventanaMinutos;
}
/// True si el actor puede editar este pago. La ventana aplica a todos
/// (incluso superadmin); ownership sólo si NO es superadmin.
/// El backend es la fuente de verdad; este getter es para gating local.
bool puedeEditar(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
if (isAnulado) return false;
if (!isEditableWindow(ventanaMinutos)) return false;
if (isSuperadmin) return true;
if (actorUserId == null || createdBy == null) return false;
return actorUserId == createdBy;
}
/// True si el actor puede anular este pago. Superadmin bypassea ownership
/// y ventana; el resto necesita ser owner y estar dentro de ventana.
bool puedeAnular(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
if (isAnulado) return false;
if (isSuperadmin) return true;
if (actorUserId == null || createdBy == null) return false;
if (actorUserId != createdBy) return false;
return isEditableWindow(ventanaMinutos);
}
}
class PagoCliente {
final String nombre;
final String apellido;
final String dni;
const PagoCliente({
required this.nombre,
required this.apellido,
required this.dni,
});
factory PagoCliente.fromMap(Map<String, dynamic> map) {
return PagoCliente(
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String? ?? '',
dni: map['dni'] as String? ?? '',
);
}
String get displayName {
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
if (nombre.isNotEmpty) return nombre;
return dni;
}
}
@@ -0,0 +1,57 @@
/// Discriminador de la tabla pagos. El backend acepta los cuatro valores
/// del schema; el frontend de este sprint sólo CREA cuotaMensual (los
/// correctivos llegan desde SQL directo hasta que tengan sus propias
/// funciones fc_*).
enum PagoTipo {
cuotaMensual,
devolucion,
descuentoRetroactivo,
ajuste;
/// Parsea el valor del backend. Default defensivo: cuotaMensual.
/// El backfill de la migración garantiza que pagos.tipo nunca sea NULL,
/// pero ante un valor desconocido no rompemos el parseo.
factory PagoTipo.fromString(String? raw) {
switch (raw) {
case 'cuota_mensual':
return PagoTipo.cuotaMensual;
case 'devolucion':
return PagoTipo.devolucion;
case 'descuento_retroactivo':
return PagoTipo.descuentoRetroactivo;
case 'ajuste':
return PagoTipo.ajuste;
default:
return PagoTipo.cuotaMensual;
}
}
/// Valor snake_case que espera el backend.
String get backendValue {
switch (this) {
case PagoTipo.cuotaMensual:
return 'cuota_mensual';
case PagoTipo.devolucion:
return 'devolucion';
case PagoTipo.descuentoRetroactivo:
return 'descuento_retroactivo';
case PagoTipo.ajuste:
return 'ajuste';
}
}
/// Etiqueta legible en español. Vive con la entidad por simplicidad
/// (SOMA es app monolingüe).
String get displayName {
switch (this) {
case PagoTipo.cuotaMensual:
return 'Cuota mensual';
case PagoTipo.devolucion:
return 'Devolución';
case PagoTipo.descuentoRetroactivo:
return 'Descuento retroactivo';
case PagoTipo.ajuste:
return 'Ajuste';
}
}
}
@@ -0,0 +1,42 @@
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
abstract class PagosRepository {
/// Obtener pagos (admin: todos o filtrados por DNI).
/// [incluirAnulados] default false — equivale al toggle "Mostrar anulados"
/// que el backend gobierna con el parámetro p_incluir_anulados.
Future<List<Pago>> getPagos({
int pagina = 1,
int cantidad = 50,
String? dni,
bool incluirAnulados = false,
});
/// Obtener pagos propios del usuario logueado. Siempre incluye anulados:
/// el cliente puede haber visto el pago antes de la edición/anulación,
/// la app cliente marca visualmente los modificados.
Future<List<Pago>> getMisPagos({int pagina = 1, int cantidad = 20});
/// Registrar un nuevo pago.
Future<void> insertPago(Map<String, dynamic> datos);
/// Editar un pago existente. Devuelve el pago actualizado (shape de
/// fc_obtener_pagos para un único objeto).
/// El backend rechaza el llamado si:
/// - el pago no existe o está anulado.
/// - el actor no tiene gestionar_cualquier_pago y no es el creador.
/// - el actor no tiene gestionar_cualquier_pago y la ventana venció.
/// - se intenta editar cliente_id o tipo.
Future<Pago> editPago(String id, Map<String, dynamic> datos);
/// Anular (soft-delete) un pago. [motivo] puede ser null o vacío;
/// el backend lo trimea y persiste como NULL en ese caso. Devuelve
/// el pago actualizado. Misma matriz de permisos que [editPago].
Future<Pago> anularPago(String id, String? motivo);
/// Obtener métodos de pago activos.
Future<List<MetodoPago>> getMetodosPago();
/// Actualizar un método de pago (descripción, activo, icono).
Future<void> updateMetodoPago(Map<String, dynamic> datos);
}
@@ -0,0 +1,89 @@
import 'package:flutter_riverpod/flutter_riverpod.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/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class PagoConUsuario {
final Pago pago;
final Usuario usuario;
const PagoConUsuario({required this.pago, required this.usuario});
}
class PagosEstadoMes {
final String mes;
final List<PagoConUsuario> pagaron;
final List<Usuario> unMesSinPagar;
final List<Usuario> masDe1MesSinPagar;
const PagosEstadoMes({
required this.mes,
required this.pagaron,
required this.unMesSinPagar,
required this.masDe1MesSinPagar,
});
int get total => pagaron.length + unMesSinPagar.length + masDe1MesSinPagar.length;
double get progressValue => total == 0 ? 0 : pagaron.length / total;
}
/// Clasifica usuarios activos con plan en 3 grupos para el mes dado ("YYYY-MM"):
/// - pagaron: tienen pago registrado ese mes
/// - unMesSinPagar: no pagaron ese mes pero sí el anterior
/// - masDe1MesSinPagar: no pagaron ese mes ni el anterior
final pagosEstadoProvider =
FutureProvider.autoDispose.family<PagosEstadoMes, String>((ref, mes) async {
final usuarios = await ref.watch(allUsuariosProvider.future);
final pagosValue = ref.watch(pagosProvider);
final pagos = pagosValue.valueOrNull ?? [];
// Mes anterior
final parts = mes.split('-');
final mesDate = DateTime(int.parse(parts[0]), int.parse(parts[1]));
final mesAnteriorDate = DateTime(mesDate.year, mesDate.month - 1);
final mesAnterior =
'${mesAnteriorDate.year}-${mesAnteriorDate.month.toString().padLeft(2, '0')}';
// Indexar pagos por DNI para el mes seleccionado y el anterior
final pagosMes = <String, Pago>{};
final pagosAnterior = <String, bool>{};
for (final p in pagos) {
if (p.cliente == null) continue;
if (p.anioMesPagado.startsWith('$mes-')) {
pagosMes[p.cliente!.dni] = p;
}
if (p.anioMesPagado.startsWith('$mesAnterior-')) {
pagosAnterior[p.cliente!.dni] = true;
}
}
final conPlan =
usuarios.where((u) => u.isActive && u.tipoCuota != null).toList();
final pagaron = <PagoConUsuario>[];
final unMes = <Usuario>[];
final masDe1Mes = <Usuario>[];
for (final u in conPlan) {
final pago = pagosMes[u.dni];
if (pago != null) {
pagaron.add(PagoConUsuario(pago: pago, usuario: u));
} else if (pagosAnterior.containsKey(u.dni)) {
unMes.add(u);
} else {
masDe1Mes.add(u);
}
}
pagaron.sort((a, b) => a.usuario.displayName.compareTo(b.usuario.displayName));
unMes.sort((a, b) => a.displayName.compareTo(b.displayName));
masDe1Mes.sort((a, b) => a.displayName.compareTo(b.displayName));
return PagosEstadoMes(
mes: mes,
pagaron: pagaron,
unMesSinPagar: unMes,
masDe1MesSinPagar: masDe1Mes,
);
});
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PagosFilter {
final String? selectedMonth; // "YYYY-MM" o null para todos
final String? selectedMetodo; // Nombre del método o null para todos
const PagosFilter({
this.selectedMonth,
this.selectedMetodo,
});
PagosFilter copyWith({
String? Function()? selectedMonth,
String? Function()? selectedMetodo,
}) {
return PagosFilter(
selectedMonth:
selectedMonth != null ? selectedMonth() : this.selectedMonth,
selectedMetodo:
selectedMetodo != null ? selectedMetodo() : this.selectedMetodo,
);
}
bool get hasActiveFilters =>
selectedMonth != null || selectedMetodo != null;
}
final pagosFilterProvider =
StateNotifierProvider.autoDispose<PagosFilterNotifier, PagosFilter>((ref) {
return PagosFilterNotifier();
});
class PagosFilterNotifier extends StateNotifier<PagosFilter> {
PagosFilterNotifier() : super(const PagosFilter());
void setMonth(String? month) {
state = state.copyWith(selectedMonth: () => month);
}
void setMetodo(String? metodo) {
state = state.copyWith(selectedMetodo: () => metodo);
}
void clearFilters() {
state = const PagosFilter();
}
}
@@ -0,0 +1,259 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/pagos/data/repositories/pagos_repository_impl.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
final pagosRepositoryProvider = Provider<PagosRepository>((ref) {
return PagosRepositoryImpl();
});
/// Métodos de pago con CRUD. Dato de configuración, persiste en sesión.
final metodosPagoProvider = StateNotifierProvider<MetodosPagoNotifier,
AsyncValue<List<MetodoPago>>>((ref) {
return MetodosPagoNotifier(ref.read(pagosRepositoryProvider));
});
class MetodosPagoNotifier
extends StateNotifier<AsyncValue<List<MetodoPago>>> {
final PagosRepository _repository;
MetodosPagoNotifier(this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.getMetodosPago();
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> update(Map<String, dynamic> datos) async {
try {
await _repository.updateMetodoPago(datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
/// Últimos pagos de un usuario por DNI (para detalle de usuario).
/// autoDispose: se descarta al salir del detalle y refetchea al volver.
/// [incluirAnulados] default false; cambiar a true para el toggle
/// "Mostrar anulados" en el detalle.
final userPagosProvider = FutureProvider.autoDispose
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
final repo = ref.read(pagosRepositoryProvider);
return repo.getPagos(
dni: args.dni,
cantidad: 10,
incluirAnulados: args.incluirAnulados,
);
});
/// Historial completo de pagos por DNI.
/// cantidad=200 cubre 2 años de cuotas + correctivos teóricos máximos (96)
/// con margen 2x. Si en algún momento un usuario supera esto, hay que pensar
/// en paginación dedicada.
/// autoDispose: se descarta al salir del historial y refetchea al volver.
final userHistorialProvider = FutureProvider.autoDispose
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
final repo = ref.read(pagosRepositoryProvider);
return repo.getPagos(
dni: args.dni,
cantidad: 200,
incluirAnulados: args.incluirAnulados,
);
});
/// Lista de pagos. autoDispose: al navegar fuera de la pantalla de pagos
/// los datos se descartan; al volver se cargan frescos del backend.
final pagosProvider =
StateNotifierProvider.autoDispose<PagosNotifier, AsyncValue<List<Pago>>>((ref) {
final user = ref.read(authStateProvider).valueOrNull;
final isAdmin = user != null && user.isStaff;
return PagosNotifier(ref.read(pagosRepositoryProvider), isAdmin);
});
class PagosNotifier extends StateNotifier<AsyncValue<List<Pago>>> {
final PagosRepository _repository;
String? _searchDni;
bool _viewingOwn;
bool _incluirAnulados = false;
PagosNotifier(this._repository, bool isAdmin)
: _viewingOwn = !isAdmin,
super(const AsyncValue.loading()) {
if (isAdmin) {
loadPagos();
} else {
loadMisPagos();
}
}
bool get incluirAnulados => _incluirAnulados;
/// Cargar pagos como admin (todos o filtrados por DNI).
Future<void> loadPagos() async {
_viewingOwn = false;
state = const AsyncValue.loading();
try {
final pagos = await _repository.getPagos(
dni: _searchDni,
incluirAnulados: _incluirAnulados,
);
if (!mounted) return;
state = AsyncValue.data(pagos);
} catch (e, st) {
if (!mounted) return;
state = AsyncValue.error(e, st);
}
}
/// Cargar pagos propios del usuario logueado.
/// El backend siempre incluye anulados para el cliente.
Future<void> loadMisPagos() async {
_viewingOwn = true;
state = const AsyncValue.loading();
try {
final pagos = await _repository.getMisPagos();
if (!mounted) return;
state = AsyncValue.data(pagos);
} catch (e, st) {
if (!mounted) return;
state = AsyncValue.error(e, st);
}
}
Future<void> search(String? dni) async {
_searchDni = (dni == null || dni.isEmpty) ? null : dni;
await loadPagos();
}
/// Toggle "Mostrar anulados". Sólo afecta la vista admin
/// (loadMisPagos siempre los incluye).
Future<void> setIncluirAnulados(bool value) async {
if (_incluirAnulados == value) return;
_incluirAnulados = value;
if (!_viewingOwn) {
await loadPagos();
}
}
Future<String?> insertPago(Map<String, dynamic> datos) async {
try {
await _repository.insertPago(datos);
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> editPago(String id, Map<String, dynamic> datos) async {
try {
await _repository.editPago(id, datos);
// Refetch para consistencia: el backend devuelve el pago actualizado
// pero la lista puede haber cambiado de orden (fecha_pago editada).
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> anularPago(String id, String? motivo) async {
try {
await _repository.anularPago(id, motivo);
if (_viewingOwn) {
await loadMisPagos();
} else {
await loadPagos();
}
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
/// Mapa DNI → fecha del último pago (fechaPago del pago más reciente).
/// Filtra por tipo == cuotaMensual: un correctivo (devolución, descuento o
/// ajuste) no representa "haber pagado el mes" — esto matchea el filtro
/// que aplica fc_reservar_turno en la regla de los 2 meses.
/// Se invalida automáticamente cuando la lista de usuarios cambia.
final ultimoPagoMapProvider =
FutureProvider.autoDispose<Map<String, DateTime?>>((ref) async {
ref.watch(allUsuariosProvider);
final repo = ref.read(pagosRepositoryProvider);
final pagos = await repo.getPagos(cantidad: 500);
final map = <String, DateTime?>{};
for (final p in pagos) {
if (p.tipo != PagoTipo.cuotaMensual) continue;
if (p.cliente == null) continue;
final dni = p.cliente!.dni;
if (!map.containsKey(dni)) {
// La lista viene ordenada más reciente primero
map[dni] = p.fechaPago ?? DateTime.tryParse(p.anioMesPagado);
}
}
return map;
});
// ── Deudores ───────────────────────────────────────────────────────────────────
class Deudor {
final Usuario usuario;
final int mesesAdeudados;
const Deudor({required this.usuario, required this.mesesAdeudados});
}
int _calcMesesAdeudados(DateTime? ultimoPago, DateTime currentMonth) {
if (ultimoPago == null) return 24;
final lastMonth = DateTime(ultimoPago.year, ultimoPago.month);
final diff = (currentMonth.year - lastMonth.year) * 12 +
currentMonth.month -
lastMonth.month;
return diff.clamp(0, 24);
}
/// Lista de socios activos con plan que adeudan al menos un mes, ordenados
/// de mayor a menor cantidad de meses sin pagar.
final deudoresProvider = FutureProvider.autoDispose<List<Deudor>>((ref) async {
final usuarios = await ref.watch(allUsuariosProvider.future);
final ultimoPagoMap = await ref.watch(ultimoPagoMapProvider.future);
final now = DateTime.now();
final currentMonth = DateTime(now.year, now.month);
final deudores = <Deudor>[];
for (final u in usuarios) {
if (!u.isActive || u.tipoCuota == null) continue;
final meses = _calcMesesAdeudados(ultimoPagoMap[u.dni], currentMonth);
if (meses <= 0) continue;
deudores.add(Deudor(usuario: u, mesesAdeudados: meses));
}
deudores.sort((a, b) => b.mesesAdeudados.compareTo(a.mesesAdeudados));
return deudores;
});
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
enum PagosViewMode { overview, list, estado }
final pagosViewModeProvider =
StateNotifierProvider<PagosViewModeNotifier, PagosViewMode>((ref) {
return PagosViewModeNotifier();
});
class PagosViewModeNotifier extends StateNotifier<PagosViewMode> {
PagosViewModeNotifier() : super(PagosViewMode.overview) {
_loadViewMode();
}
Future<void> _loadViewMode() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(AppConstants.pagosViewModeKey);
state = switch (stored) {
'list' => PagosViewMode.list,
'estado' => PagosViewMode.estado,
_ => PagosViewMode.overview,
};
}
Future<void> setMode(PagosViewMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
AppConstants.pagosViewModeKey,
switch (mode) {
PagosViewMode.list => 'list',
PagosViewMode.estado => 'estado',
PagosViewMode.overview => 'overview',
},
);
}
Future<void> toggle() async {
await setMode(switch (state) {
PagosViewMode.overview => PagosViewMode.list,
PagosViewMode.list => PagosViewMode.estado,
PagosViewMode.estado => PagosViewMode.overview,
});
}
}
@@ -0,0 +1,457 @@
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/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
class MetodosPagoScreen extends ConsumerWidget {
const MetodosPagoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(metodosPagoProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
return Scaffold(
body: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Métodos de pago',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.touch_app_outlined,
text: 'Tocá un método para activarlo, desactivarlo o '
'cambiarle el ícono.',
),
SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga la lista de métodos de pago.',
),
],
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () =>
ref.read(metodosPagoProvider.notifier).load(),
),
],
),
),
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(metodosPagoProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (metodos) {
if (metodos.isEmpty) {
return Center(
child: Text(
'No hay métodos de pago',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: metodos.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _MetodoCard(metodo: metodos[index]);
},
);
},
),
),
],
),
);
}
}
class _MetodoCard extends ConsumerWidget {
final MetodoPago metodo;
const _MetodoCard({required this.metodo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final railColor = metodo.activo
? SomaColors.success.withAlpha(180)
: theme.colorScheme.surfaceContainerHighest;
final card = InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showEditDialog(context, ref),
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
Container(width: 4, color: railColor),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Ícono
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: metodo.activo
? SomaColors.primary.withAlpha(20)
: theme.colorScheme.onSurface.withAlpha(12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_iconForMetodo(metodo.icono),
size: 20,
color: metodo.activo
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 14),
// Descripción + badge
Expanded(
child: Row(
children: [
Expanded(
child: Text(
metodo.descripcion,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_EstadoBadge(activo: metodo.activo),
],
),
),
const SizedBox(width: 4),
// Toggle activo
Switch(
value: metodo.activo,
activeTrackColor: SomaColors.success,
activeThumbColor: Colors.white,
onChanged: (value) async {
final error =
await ref.read(metodosPagoProvider.notifier).update({
'id': metodo.id,
'activo': value,
});
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context,
message: error, type: ToastType.error);
}
},
),
// Edit
IconButton(
icon: Icon(
Icons.edit_outlined,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(130),
),
tooltip: 'Editar',
onPressed: () => _showEditDialog(context, ref),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 34,
minHeight: 34,
),
),
],
),
),
),
],
),
),
),
);
if (!metodo.activo) {
return Opacity(opacity: 0.6, child: card);
}
return card;
}
static const _iconOptions = [
(null, Icons.payment_outlined, 'Sin ícono'),
('efectivo', Icons.payments_outlined, 'Efectivo'),
('transferencia', Icons.account_balance_outlined, 'Transferencia'),
('tarjeta', Icons.credit_card_outlined, 'Tarjeta'),
('qr', Icons.qr_code, 'QR'),
];
Future<void> _showEditDialog(BuildContext context, WidgetRef ref) async {
final ctrl = TextEditingController(text: metodo.descripcion);
String? selectedIcon = metodo.icono;
final result = await showDialog<({String descripcion, String? icono})>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) {
final theme = Theme.of(ctx);
return AlertDialog(
title: const Text('Editar método de pago'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: 'Descripción',
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
autofocus: true,
maxLength: 50,
),
const SizedBox(height: 4),
Text(
'Ícono',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _iconOptions.map((opt) {
final (key, icon, label) = opt;
final isSelected = selectedIcon == key;
return GestureDetector(
onTap: () => setLocal(() => selectedIcon = key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? SomaColors.primary
: theme.colorScheme.surfaceContainerHighest,
width: isSelected ? 1.5 : 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(150),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? SomaColors.primary
: theme.colorScheme.onSurface
.withAlpha(150),
),
),
],
),
),
);
}).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
ElevatedButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) {
Navigator.of(ctx)
.pop((descripcion: text, icono: selectedIcon));
}
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
child: const Text('Guardar'),
),
],
);
},
),
);
ctrl.dispose();
if (result == null || !context.mounted) return;
final changed = result.descripcion != metodo.descripcion ||
result.icono != metodo.icono;
if (!changed) return;
final error = await ref.read(metodosPagoProvider.notifier).update({
'id': metodo.id,
'descripcion': result.descripcion,
'icono': result.icono,
});
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Método actualizado', type: ToastType.success);
}
}
IconData _iconForMetodo(String? icono) {
switch (icono) {
case 'efectivo':
case 'cash':
return Icons.payments_outlined;
case 'transferencia':
case 'transfer':
return Icons.account_balance_outlined;
case 'tarjeta':
case 'card':
return Icons.credit_card_outlined;
case 'qr':
return Icons.qr_code;
default:
return Icons.payment_outlined;
}
}
}
class _EstadoBadge extends StatelessWidget {
final bool activo;
const _EstadoBadge({required this.activo});
@override
Widget build(BuildContext context) {
final color = activo ? SomaColors.success : SomaColors.error;
final label = activo ? 'Activo' : 'Inactivo';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
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/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
/// Dialog de anulación de un pago. Devuelve `true` en éxito y `null` /
/// `false` al cancelar. Errores del backend se muestran inline; el dialog
/// no se cierra hasta éxito o cancelación explícita.
///
/// Presets de motivo: orientados a errores de carga. NO incluye
/// "Cliente devolvió plata" — devolución no es anulación
/// (ver plan: "Anular vs correctivos").
class AnularPagoDialog extends ConsumerStatefulWidget {
final Pago pago;
const AnularPagoDialog({super.key, required this.pago});
@override
ConsumerState<AnularPagoDialog> createState() => _AnularPagoDialogState();
}
class _AnularPagoDialogState extends ConsumerState<AnularPagoDialog> {
static const _presets = <String>[
'Cobro duplicado',
'Cliente equivocado',
'Error de monto',
'Error de mes',
];
final _motivoCtrl = TextEditingController();
String? _selectedPreset;
bool _submitting = false;
String? _error;
@override
void dispose() {
_motivoCtrl.dispose();
super.dispose();
}
void _onPresetTap(String preset) {
setState(() {
_selectedPreset = preset;
_motivoCtrl.text = preset;
_motivoCtrl.selection = TextSelection.collapsed(
offset: preset.length,
);
});
}
void _onMotivoChanged(String value) {
// Si el texto deja de coincidir con el preset seleccionado, deselecciono.
if (_selectedPreset != null && value.trim() != _selectedPreset) {
setState(() => _selectedPreset = null);
}
}
Future<void> _submit() async {
if (_submitting) return;
setState(() {
_submitting = true;
_error = null;
});
final motivo = _motivoCtrl.text.trim();
final err = await ref
.read(pagosProvider.notifier)
.anularPago(widget.pago.id, motivo.isEmpty ? null : motivo);
if (!mounted) return;
if (err == null) {
Navigator.of(context).pop(true);
} else {
setState(() {
_submitting = false;
_error = err;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final pago = widget.pago;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 480,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(pago: pago, submitting: _submitting),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PagoResumenCard(pago: pago),
const SizedBox(height: 14),
_Warning(),
const SizedBox(height: 18),
Text(
'Motivo (opcional)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.onSurface.withAlpha(160),
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: _presets.map((p) {
final selected = _selectedPreset == p;
return FilterChip(
label: Text(p),
selected: selected,
onSelected: _submitting
? null
: (_) => _onPresetTap(p),
selectedColor: SomaColors.primary.withAlpha(40),
checkmarkColor: SomaColors.primaryText,
labelStyle: TextStyle(
fontSize: 12,
fontWeight:
selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? SomaColors.primaryText
: cs.onSurface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: selected
? SomaColors.primary
: cs.surfaceContainerHighest,
width: 0.8,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
);
}).toList(),
),
const SizedBox(height: 10),
SomaTextField(
controller: _motivoCtrl,
labelText: null,
hintText:
'Escribí un motivo o seleccioná uno arriba',
maxLines: 2,
enabled: !_submitting,
onChanged: _onMotivoChanged,
),
if (_error != null) ...[
const SizedBox(height: 14),
_ErrorBanner(message: _error!),
],
],
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(false),
child: Text(
'Cancelar',
style: TextStyle(
color: cs.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.cancel_outlined, size: 18),
label: Text(_submitting ? 'Anulando…' : 'Anular pago'),
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 42),
),
),
],
),
),
],
),
),
);
}
}
class _Header extends StatelessWidget {
final Pago pago;
final bool submitting;
const _Header({required this.pago, required this.submitting});
String get _initials {
final c = pago.cliente;
if (c == null) return '?';
final n = c.nombre.isNotEmpty ? c.nombre[0] : '';
final a = c.apellido.isNotEmpty ? c.apellido[0] : '';
final combo = (n + a).toUpperCase();
return combo.isEmpty ? '?' : combo;
}
String get _displayName =>
pago.cliente?.displayName ?? 'Pago';
String get _subtitle =>
pago.cliente != null ? 'DNI ${pago.cliente!.dni}' : '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(30),
shape: BoxShape.circle,
),
child: Center(
child: Text(
_initials,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: SomaColors.error,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Anular pago',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
_subtitle.isEmpty ? _displayName : '$_displayName · $_subtitle',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(130),
),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: submitting
? null
: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor: cs.surfaceContainerHighest,
),
),
],
),
);
}
}
class _PagoResumenCard extends StatelessWidget {
final Pago pago;
const _PagoResumenCard({required this.pago});
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
'${pago.metodo} · Cargado ${pago.fechaPagoDisplay}',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: SomaColors.success,
),
),
],
),
);
}
}
class _Warning extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.errorContainer.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.error.withAlpha(60),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: SomaColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Esta acción no elimina el pago. Queda registrado como '
'anulado con tu nombre y, si dejás motivo, también con esa nota.',
style: TextStyle(
fontSize: 12,
color: cs.onSurface,
height: 1.35,
),
),
),
],
),
);
}
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.error.withAlpha(120),
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline,
size: 16,
color: SomaColors.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 12,
color: SomaColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
class PagoCard extends StatelessWidget {
final Pago pago;
final bool showCliente;
final VoidCallback? onTap;
final VoidCallback? onHistorial;
// Callbacks de acción admin. Si ambos null y pago no anulado → no se
// muestra el menú 3-puntos (vista cliente o admin sin permiso).
final VoidCallback? onEdit;
final VoidCallback? onAnular;
const PagoCard({
super.key,
required this.pago,
this.showCliente = true,
this.onTap,
this.onHistorial,
this.onEdit,
this.onAnular,
});
bool get _showMenu => !pago.isAnulado && (onEdit != null || onAnular != null);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final anulado = pago.isAnulado;
final editado = pago.isEditado && !anulado;
final railColor = anulado
? SomaColors.error.withAlpha(180)
: SomaColors.success.withAlpha(180);
final montoColor = anulado
? SomaColors.error.withAlpha(160)
: SomaColors.success;
final montoDecoration = anulado ? TextDecoration.lineThrough : null;
final mainTextColor = anulado
? cs.onSurface.withAlpha(140)
: cs.onSurface;
return GestureDetector(
onSecondaryTap: onHistorial,
onLongPress: onHistorial,
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: [
Container(width: 4, color: railColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 11, 8, 11),
child: Row(
children: [
Opacity(
opacity: anulado ? 0.55 : 1,
child: _MonthStamp(anioMes: pago.anioMesPagado),
),
const SizedBox(width: 14),
// Info central
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
showCliente && pago.cliente != null
? pago.cliente!.displayName
: pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: mainTextColor,
decoration: montoDecoration,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
if (anulado)
const _AnuladoBadge()
else
_MetodoBadge(metodo: pago.metodo),
if (!anulado &&
showCliente &&
pago.cliente != null) ...[
const SizedBox(width: 6),
Text(
pago.mesPagadoDisplay,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(110),
),
),
],
if (!anulado &&
pago.detalle?['tipo_cuota'] != null) ...[
const SizedBox(width: 6),
Flexible(
child: Text(
pago.detalle!['tipo_cuota'].toString(),
style: const TextStyle(
fontSize: 11,
color: SomaColors.primaryText,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
if (anulado) ...[
const SizedBox(height: 3),
Text(
_anuladoSubline(pago),
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(130),
fontStyle: FontStyle.italic,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: montoColor,
decoration: montoDecoration,
),
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (editado) ...[
Tooltip(
message: _editadoTooltip(pago),
child: Icon(
Icons.edit_outlined,
size: 12,
color: cs.onSurface.withAlpha(120),
),
),
const SizedBox(width: 4),
],
Text(
pago.fechaPagoDisplay,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(110),
),
),
],
),
],
),
if (_showMenu) ...[
const SizedBox(width: 4),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: cs.onSurface.withAlpha(140),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 44,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 8,
tooltip: 'Acciones',
itemBuilder: (_) => [
if (onEdit != null)
PopupMenuItem(
value: 'edit',
height: 44,
child: Row(
children: [
Icon(
Icons.edit_outlined,
size: 18,
color: cs.onSurface.withAlpha(180),
),
const SizedBox(width: 10),
const Text('Editar'),
],
),
),
if (onAnular != null)
PopupMenuItem(
value: 'anular',
height: 44,
child: Row(
children: [
Icon(
Icons.cancel_outlined,
size: 18,
color: SomaColors.error,
),
const SizedBox(width: 10),
Text(
'Anular',
style: TextStyle(
color: SomaColors.error,
),
),
],
),
),
],
onSelected: (val) {
if (val == 'edit') onEdit?.call();
if (val == 'anular') onAnular?.call();
},
),
],
],
),
),
),
],
),
),
),
),
);
}
static String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
static String _anuladoSubline(Pago p) {
final motivo = (p.motivoAnulacion?.trim().isNotEmpty ?? false)
? p.motivoAnulacion!.trim()
: 'Sin motivo';
final autor = p.anuladoPorNombre ?? 'admin';
final hace = p.anuladoAt != null ? _timeagoEs(p.anuladoAt!) : '';
return hace.isEmpty
? '$motivo · por $autor'
: '$motivo · por $autor · $hace';
}
static String _editadoTooltip(Pago p) {
final autor = p.updatedByNombre ?? 'admin';
final cuando = p.updatedAt;
if (cuando == null) return 'Editado por $autor';
final f =
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
return 'Editado por $autor el $f';
}
static String _timeagoEs(DateTime when) {
final diff = DateTime.now().difference(when);
if (diff.inSeconds < 60) return 'hace unos segundos';
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
if (diff.inDays < 30) {
final d = diff.inDays;
return d == 1 ? 'hace 1 día' : 'hace $d días';
}
if (diff.inDays < 365) {
final m = (diff.inDays / 30).floor();
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
}
final y = (diff.inDays / 365).floor();
return y == 1 ? 'hace 1 año' : 'hace $y años';
}
}
/// Badge "ANULADO" en lugar del método cuando el pago está soft-deleted.
class _AnuladoBadge extends StatelessWidget {
const _AnuladoBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'ANULADO',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: SomaColors.error,
letterSpacing: 0.5,
),
),
);
}
}
/// Stamp de mes estilo mini-calendario.
class _MonthStamp extends StatelessWidget {
final String anioMes; // 'YYYY-MM-DD' o 'YYYY-MM'
const _MonthStamp({required this.anioMes});
static const _meses = [
'',
'ENE',
'FEB',
'MAR',
'ABR',
'MAY',
'JUN',
'JUL',
'AGO',
'SEP',
'OCT',
'NOV',
'DIC',
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final date = DateTime.tryParse(anioMes);
final mes = date != null ? _meses[date.month] : '??';
final anio = date != null ? date.year.toString().substring(2) : '';
return Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SomaColors.primary.withAlpha(45), width: 0.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.fromLTRB(5, 5, 5, 3),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
mes,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
color: SomaColors.onPrimary,
letterSpacing: 0.4,
height: 1,
),
),
),
Text(
anio,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
height: 1.1,
),
),
],
),
);
}
}
/// Badge del método de pago.
class _MetodoBadge extends StatelessWidget {
final String metodo;
const _MetodoBadge({required this.metodo});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
metodo,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
);
}
}
@@ -0,0 +1,525 @@
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/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
class PagoDetailDialog extends ConsumerWidget {
final Pago pago;
// Callbacks opcionales para acciones admin. Si ambos null, no se muestra
// el footer de acciones (vista cliente, o admin sin permiso sobre este pago).
// El call-site decide cerrar el detail dialog antes de abrir el siguiente.
final VoidCallback? onEdit;
final VoidCallback? onAnular;
const PagoDetailDialog({
super.key,
required this.pago,
this.onEdit,
this.onAnular,
});
bool get _showActions =>
!pago.isAnulado && (onEdit != null || onAnular != null);
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final user = ref.watch(authStateProvider).valueOrNull;
final isAdmin = user?.isStaff ?? false;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: BoxConstraints(
maxWidth: 500,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
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: pago.isAnulado
? SomaColors.error.withAlpha(30)
: SomaColors.primary.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
pago.isAnulado
? Icons.cancel_outlined
: Icons.receipt_long,
color: pago.isAnulado
? SomaColors.error
: SomaColors.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pago.isAnulado ? 'Pago anulado' : 'Detalle de Pago',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
pago.mesPagadoDisplay,
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
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner de auditoría arriba de todo si está anulado o editado.
if (pago.isAnulado)
_AnuladoBanner(pago: pago)
else if (pago.isEditado)
_EditadoBanner(pago: pago),
if (pago.isAnulado || pago.isEditado)
const SizedBox(height: 16),
// Cliente (solo si es admin)
if (isAdmin && pago.cliente != null) ...[
_DetailRow(
icon: Icons.person_outline,
label: 'Cliente',
value: pago.cliente!.displayName,
valueStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
_DetailRow(
icon: Icons.badge_outlined,
label: 'DNI',
value: pago.cliente!.dni,
),
const SizedBox(height: 16),
],
// Monto (destacado)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 20,
horizontal: 16,
),
decoration: BoxDecoration(
color: (pago.isAnulado ? SomaColors.error : SomaColors.success)
.withAlpha(14),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (pago.isAnulado
? SomaColors.error
: SomaColors.success)
.withAlpha(50),
width: 0.8,
),
),
child: Column(
children: [
Text(
pago.isAnulado ? 'MONTO ANULADO' : 'MONTO TOTAL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: (pago.isAnulado
? SomaColors.error
: SomaColors.success)
.withAlpha(180),
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: pago.isAnulado
? SomaColors.error
: SomaColors.success,
decoration: pago.isAnulado
? TextDecoration.lineThrough
: null,
height: 1,
),
),
],
),
),
const SizedBox(height: 24),
_SectionTitle(label: 'Detalle'),
const SizedBox(height: 14),
_DetailRow(
icon: Icons.payment_outlined,
label: 'Método de Pago',
value: pago.metodo,
valueStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
_DetailRow(
icon: Icons.calendar_today_outlined,
label: 'Fecha de Pago',
value: pago.fechaPagoDisplay,
),
const SizedBox(height: 16),
_DetailRow(
icon: Icons.event_note_outlined,
label: 'Mes Pagado',
value: pago.mesPagadoDisplay,
),
if (pago.detalle != null && pago.detalle!.isNotEmpty) ...[
const SizedBox(height: 24),
_SectionTitle(label: 'Información Adicional'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: pago.detalle!.entries.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
'${entry.key}:',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
),
Expanded(
flex: 3,
child: Text(
entry.value.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}).toList(),
),
),
],
],
),
),
),
if (_showActions) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onAnular != null)
OutlinedButton.icon(
onPressed: onAnular,
icon: const Icon(Icons.cancel_outlined, size: 16),
label: const Text('Anular'),
style: OutlinedButton.styleFrom(
foregroundColor: SomaColors.error,
side: BorderSide(
color: SomaColors.error.withAlpha(140),
width: 1,
),
minimumSize: const Size(0, 40),
),
),
if (onAnular != null && onEdit != null)
const SizedBox(width: 10),
if (onEdit != null)
ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('Editar'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
),
],
),
),
],
],
),
),
);
}
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
}
class _AnuladoBanner extends StatelessWidget {
final Pago pago;
const _AnuladoBanner({required this.pago});
String get _fecha {
final a = pago.anuladoAt;
if (a == null) return '';
return '${a.day.toString().padLeft(2, '0')}/${a.month.toString().padLeft(2, '0')}/${a.year}';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final autor = pago.anuladoPorNombre ?? 'admin';
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
? pago.motivoAnulacion!.trim()
: null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(20),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: SomaColors.error.withAlpha(80), width: 0.8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.cancel_outlined,
size: 18,
color: SomaColors.error,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_fecha.isEmpty
? 'Anulado por $autor'
: 'Anulado por $autor el $_fecha',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: SomaColors.error,
),
),
if (motivo != null) ...[
const SizedBox(height: 4),
Text(
'Motivo: $motivo',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(180),
height: 1.35,
),
),
],
],
),
),
],
),
);
}
}
class _EditadoBanner extends StatelessWidget {
final Pago pago;
const _EditadoBanner({required this.pago});
String get _fecha {
final u = pago.updatedAt;
if (u == null) return '';
return '${u.day.toString().padLeft(2, '0')}/${u.month.toString().padLeft(2, '0')}/${u.year}';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final autor = pago.updatedByNombre ?? 'admin';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: cs.tertiaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
Icons.edit_outlined,
size: 16,
color: cs.onTertiaryContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_fecha.isEmpty
? 'Última edición: $autor'
: 'Última edición: $autor el $_fecha',
style: TextStyle(
fontSize: 12,
color: cs.onTertiaryContainer,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String label;
const _SectionTitle({required this.label});
@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(
label.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(130),
letterSpacing: 0.6,
),
),
],
);
}
}
class _DetailRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final TextStyle? valueStyle;
const _DetailRow({
required this.icon,
required this.label,
required this.value,
this.valueStyle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 2),
Text(
value,
style: valueStyle ??
TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,392 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.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:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class PagosDeudoresView extends ConsumerWidget {
const PagosDeudoresView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final deudoresAsync = ref.watch(deudoresProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
return deudoresAsync.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.invalidate(deudoresProvider),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (deudores) {
if (deudores.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 60,
color: SomaColors.success.withAlpha(160)),
const SizedBox(height: 16),
const Text(
'Todos al día',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'No hay socios con cuotas pendientes',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
final unMes = deudores.where((d) => d.mesesAdeudados == 1).length;
final masDe1 = deudores.where((d) => d.mesesAdeudados > 1).length;
return Column(
children: [
// Resumen
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 8,
),
child: Row(
children: [
_SummaryChip(
label: '${deudores.length} deudor${deudores.length == 1 ? '' : 'es'}',
color: theme.colorScheme.onSurface,
),
if (unMes > 0) ...[
const SizedBox(width: 8),
_SummaryChip(
label: '$unMes × 1 mes',
color: Colors.orange,
),
],
if (masDe1 > 0) ...[
const SizedBox(width: 8),
_SummaryChip(
label: '$masDe1 × 2+ meses',
color: SomaColors.error,
),
],
],
),
),
// Lista
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 0, isWide ? 32 : 16, 80,
),
itemCount: deudores.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) => _DeudorCard(deudor: deudores[i]),
),
),
],
);
},
);
}
}
// ── Chip de resumen ────────────────────────────────────────────────────────────
class _SummaryChip extends StatelessWidget {
final String label;
final Color color;
const _SummaryChip({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(16),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withAlpha(50), width: 0.8),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color.withAlpha(200),
),
),
);
}
}
// ── Tarjeta de deudor ──────────────────────────────────────────────────────────
class _DeudorCard extends ConsumerWidget {
final Deudor deudor;
const _DeudorCard({required this.deudor});
@override
Widget build(BuildContext context, WidgetRef ref) {
final u = deudor.usuario;
final meses = deudor.mesesAdeudados;
final theme = Theme.of(context);
final railColor =
meses == 1 ? Colors.orange : SomaColors.error;
return 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 severidad
Container(width: 4, color: railColor.withAlpha(180)),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 18,
backgroundColor: railColor.withAlpha(30),
child: Text(
u.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: railColor,
),
),
),
const SizedBox(width: 12),
// Nombre + DNI + badge
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
u.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
u.dni,
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MesesBadge(meses: meses),
],
),
],
),
),
// Acciones
IconButton(
icon: Icon(
Icons.history,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(130),
),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _verHistorial(context),
),
IconButton(
icon: const Icon(
Icons.chat_outlined,
size: 18,
color: Color(0xFF25D366),
),
tooltip: 'Enviar WhatsApp',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _abrirWhatsApp(context),
),
IconButton(
icon: const Icon(
Icons.payment_outlined,
size: 18,
color: SomaColors.primary,
),
tooltip: 'Registrar pago',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _registrarPago(context, ref),
),
],
),
),
),
],
),
),
);
}
void _verHistorial(BuildContext context) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: deudor.usuario.dni,
nombre: deudor.usuario.displayName,
initials: deudor.usuario.initials,
),
);
}
Future<void> _abrirWhatsApp(BuildContext context) async {
final meses = deudor.mesesAdeudados;
final nombre = deudor.usuario.nombre;
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
final ok = await WhatsAppService.abrirChat(
telefono: deudor.usuario.telefono,
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
'Tenés $mesesStr de cuota pendiente. '
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
SomaToast.show(
context,
message: WhatsAppService.normalizarNumeroAr(deudor.usuario.telefono) == null
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
: 'No se pudo abrir WhatsApp',
type: ToastType.error,
);
}
}
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: deudor.usuario.dni),
);
if (result == null || !context.mounted) return;
final planUpdate =
result.remove('actualizar_plan') as Map<String, dynamic>?;
final error = await ref.read(pagosProvider.notifier).insertPago(result);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
if (planUpdate != null) {
final planError =
await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (context.mounted && planError != null) {
SomaToast.show(
context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info,
);
return;
}
}
if (context.mounted) {
SomaToast.show(
context,
message:
planUpdate != null ? 'Pago registrado y plan actualizado' : 'Pago registrado',
type: ToastType.success,
);
}
}
}
// ── Badge de meses ─────────────────────────────────────────────────────────────
class _MesesBadge extends StatelessWidget {
final int meses;
const _MesesBadge({required this.meses});
@override
Widget build(BuildContext context) {
final color = meses == 1 ? Colors.orange : SomaColors.error;
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withAlpha(55), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,823 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_estado_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_detail_dialog.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:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
const _amberColor = Color(0xFFFF8F00);
class PagosEstadoView extends ConsumerStatefulWidget {
const PagosEstadoView({super.key, required this.initialMes});
final String initialMes;
@override
ConsumerState<PagosEstadoView> createState() => _PagosEstadoViewState();
}
class _PagosEstadoViewState extends ConsumerState<PagosEstadoView> {
late String _selectedMes;
@override
void initState() {
super.initState();
_selectedMes = widget.initialMes;
}
List<String> _getLast12Months() {
final now = DateTime.now();
return List.generate(12, (i) {
final date = DateTime(now.year, now.month - i, 1);
return '${date.year}-${date.month.toString().padLeft(2, '0')}';
});
}
String _formatMonth(String yearMonth) {
final parts = yearMonth.split('-');
if (parts.length != 2) return yearMonth;
final month = int.tryParse(parts[1]) ?? 0;
const meses = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
return '${meses[month]} ${parts[0]}';
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final hPad = isWide ? 32.0 : 16.0;
final months = _getLast12Months();
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 8),
child: Row(
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<String>(
value: _selectedMes,
selectedItemBuilder: (ctx) => months
.map(
(m) => Align(
alignment: Alignment.centerLeft,
child: Text(
_formatMonth(m),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(ctx).colorScheme.onSurface,
),
),
),
)
.toList(),
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
items: months
.map(
(m) => DropdownMenuItem<String>(
value: m,
child: Text(
_formatMonth(m),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
)
.toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedMes = v);
},
),
),
),
],
),
),
Expanded(
child: _EstadoContent(mes: _selectedMes, hPad: hPad),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _EstadoContent extends ConsumerWidget {
const _EstadoContent({required this.mes, required this.hPad});
final String mes;
final double hPad;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagosLoading = ref.watch(pagosProvider).isLoading;
final estadoAsync = ref.watch(pagosEstadoProvider(mes));
final deudoresAsync = ref.watch(deudoresProvider);
if (pagosLoading || estadoAsync.isLoading) {
return const Center(child: CircularProgressIndicator(color: SomaColors.primary));
}
if (estadoAsync.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48,
color: Theme.of(context).colorScheme.onSurface.withAlpha(100)),
const SizedBox(height: 12),
Text(
estadoAsync.error.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153)),
),
],
),
);
}
final estado = estadoAsync.valueOrNull;
if (estado == null) return const SizedBox.shrink();
if (estado.total == 0) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.group_outlined, size: 56,
color: Theme.of(context).colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay socios con plan activo',
style: TextStyle(
fontSize: 15,
color: Theme.of(context).colorScheme.onSurface.withAlpha(130)),
),
],
),
);
}
// Build meses map from deudoresProvider to show exact count for +1 month debtors
final mesesMap = <String, int>{};
if (deudoresAsync.hasValue) {
for (final d in deudoresAsync.requireValue) {
mesesMap[d.usuario.id] = d.mesesAdeudados;
}
}
return ListView(
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 80),
children: [
_SummaryCard(estado: estado),
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
const SizedBox(height: 16),
_Section(
title: 'Más de un mes sin pagar',
count: estado.masDe1MesSinPagar.length,
accentColor: SomaColors.error,
initiallyExpanded: true,
children: estado.masDe1MesSinPagar
.map((u) => _NoPageCard(
usuario: u,
meses: mesesMap[u.id] ?? 2,
accentColor: SomaColors.error,
))
.toList(),
),
],
if (estado.unMesSinPagar.isNotEmpty) ...[
const SizedBox(height: 4),
_Section(
title: 'Sin pagar este mes',
count: estado.unMesSinPagar.length,
accentColor: _amberColor,
initiallyExpanded: true,
children: estado.unMesSinPagar
.map((u) => _NoPageCard(
usuario: u,
meses: 1,
accentColor: _amberColor,
))
.toList(),
),
],
if (estado.pagaron.isNotEmpty) ...[
const SizedBox(height: 4),
_Section(
title: 'Pagaron',
count: estado.pagaron.length,
accentColor: SomaColors.success,
initiallyExpanded: false,
children: estado.pagaron
.map((e) => _PagaronCard(pagoConUsuario: e))
.toList(),
),
],
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _Section extends StatefulWidget {
final String title;
final int count;
final Color accentColor;
final bool initiallyExpanded;
final List<Widget> children;
const _Section({
required this.title,
required this.count,
required this.accentColor,
required this.initiallyExpanded,
required this.children,
});
@override
State<_Section> createState() => _SectionState();
}
class _SectionState extends State<_Section> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: widget.accentColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
widget.title.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: cs.onSurface.withAlpha(153),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: widget.accentColor.withAlpha(25),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${widget.count}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: widget.accentColor,
),
),
),
const Spacer(),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: cs.onSurface.withAlpha(120),
),
],
),
),
),
if (_expanded) ...[
const SizedBox(height: 6),
...widget.children.map(
(c) => Padding(padding: const EdgeInsets.only(bottom: 8), child: c),
),
],
const SizedBox(height: 4),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.estado});
final PagosEstadoMes estado;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${estado.pagaron.length}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: SomaColors.success,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 4, left: 6),
child: Text(
'de ${estado.total} socios pagaron',
style: TextStyle(fontSize: 15, color: cs.onSurface.withAlpha(180)),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: estado.progressValue,
minHeight: 8,
backgroundColor: cs.surfaceContainerHighest,
valueColor: const AlwaysStoppedAnimation<Color>(SomaColors.success),
),
),
if (estado.unMesSinPagar.isNotEmpty ||
estado.masDe1MesSinPagar.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
children: [
if (estado.unMesSinPagar.isNotEmpty) ...[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: _amberColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'${estado.unMesSinPagar.length} ${estado.unMesSinPagar.length == 1 ? 'debe' : 'deben'} este mes',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
),
],
if (estado.unMesSinPagar.isNotEmpty &&
estado.masDe1MesSinPagar.isNotEmpty)
Text(
' · ',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(80)),
),
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: SomaColors.error,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'${estado.masDe1MesSinPagar.length} ${estado.masDe1MesSinPagar.length == 1 ? 'moroso' : 'morosos'}',
style:
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
),
],
],
),
],
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _NoPageCard extends ConsumerWidget {
final Usuario usuario;
final int meses;
final Color accentColor;
const _NoPageCard({
required this.usuario,
required this.meses,
required this.accentColor,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return 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: [
Container(width: 4, color: accentColor.withAlpha(180)),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: accentColor.withAlpha(30),
child: Text(
usuario.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accentColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
usuario.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
usuario.dni,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MesesBadge(meses: meses),
],
),
],
),
),
IconButton(
icon: Icon(Icons.history, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _verHistorial(context),
),
IconButton(
icon: const Icon(Icons.chat_outlined, size: 18,
color: Color(0xFF25D366)),
tooltip: 'Enviar WhatsApp',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _abrirWhatsApp(context),
),
IconButton(
icon: const Icon(Icons.payment_outlined, size: 18,
color: SomaColors.primary),
tooltip: 'Registrar pago',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => _registrarPago(context, ref),
),
],
),
),
),
],
),
),
);
}
void _verHistorial(BuildContext context) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: usuario.dni,
nombre: usuario.displayName,
initials: usuario.initials,
),
);
}
Future<void> _abrirWhatsApp(BuildContext context) async {
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
final ok = await WhatsAppService.abrirChat(
telefono: usuario.telefono,
mensaje: 'Hola ${usuario.nombre}, te contactamos desde el gimnasio SOMA. '
'Tenés $mesesStr de cuota pendiente. '
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
SomaToast.show(
context,
message: WhatsAppService.normalizarNumeroAr(usuario.telefono) == null
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
: 'No se pudo abrir WhatsApp',
type: ToastType.error,
);
}
}
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: usuario.dni),
);
if (result == null || !context.mounted) return;
final planUpdate = result.remove('actualizar_plan') as Map<String, dynamic>?;
final error = await ref.read(pagosProvider.notifier).insertPago(result);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
if (planUpdate != null) {
final planError = await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (context.mounted && planError != null) {
SomaToast.show(context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info);
return;
}
}
if (context.mounted) {
SomaToast.show(
context,
message: planUpdate != null
? 'Pago registrado y plan actualizado'
: 'Pago registrado',
type: ToastType.success,
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _PagaronCard extends StatelessWidget {
final dynamic pagoConUsuario;
const _PagaronCard({required this.pagoConUsuario});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final u = pagoConUsuario.usuario as Usuario;
final pago = pagoConUsuario.pago as Pago;
return 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: [
Container(width: 4, color: SomaColors.success.withAlpha(180)),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.success.withAlpha(30),
child: Text(
u.initials,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.success,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
u.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Row(
children: [
Text(
u.dni,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
const SizedBox(width: 8),
_MontoChip(monto: pago.montoTotal),
],
),
],
),
),
IconButton(
icon: Icon(Icons.history, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver historial',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: u.dni,
nombre: u.displayName,
initials: u.initials,
),
),
),
IconButton(
icon: Icon(Icons.receipt_outlined, size: 18,
color: theme.colorScheme.onSurface.withAlpha(130)),
tooltip: 'Ver pago',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
onPressed: () => showDialog(
context: context,
builder: (_) => PagoDetailDialog(pago: pago),
),
),
],
),
),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MontoChip extends StatelessWidget {
final double monto;
const _MontoChip({required this.monto});
String get _label {
if (monto >= 1000) {
final k = monto / 1000;
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
}
return '\$${monto.toStringAsFixed(0)}';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: SomaColors.success.withAlpha(55), width: 0.5),
),
child: Text(
_label,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.success,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MesesBadge extends StatelessWidget {
final int meses;
const _MesesBadge({required this.meses});
@override
Widget build(BuildContext context) {
final color = meses == 1 ? _amberColor : SomaColors.error;
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withAlpha(55), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,397 @@
import 'package:file_picker/file_picker.dart';
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/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/utils/pagos_import.dart';
class PagosImportDialog extends ConsumerStatefulWidget {
const PagosImportDialog({super.key});
@override
ConsumerState<PagosImportDialog> createState() => _PagosImportDialogState();
}
class _PagosImportDialogState extends ConsumerState<PagosImportDialog> {
_Step _step = _Step.idle;
PagosImportResult? _result;
int _imported = 0;
int _failed = 0;
String? _currentError;
Future<void> _pickAndParse() async {
setState(() => _step = _Step.picking);
final picked = await FilePicker.platform.pickFiles(
dialogTitle: 'Seleccionar CSV de pagos',
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (picked == null || picked.files.isEmpty) {
setState(() => _step = _Step.idle);
return;
}
final bytes = picked.files.first.bytes;
if (bytes == null) {
setState(() {
_step = _Step.idle;
_currentError = 'No se pudo leer el archivo';
});
return;
}
final result = parsePagosCsv(bytes);
setState(() {
_result = result;
_step = result.parseErrors.isNotEmpty ? _Step.idle : _Step.preview;
_currentError = result.parseErrors.isNotEmpty ? result.parseErrors.first : null;
});
}
Future<void> _runImport() async {
final result = _result!;
final metodosAsync = ref.read(metodosPagoProvider);
final metodos = metodosAsync.valueOrNull ?? [];
// Construir mapa nombre (lowercase) → id
final metodoMap = <String, int>{
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
};
setState(() {
_step = _Step.importing;
_imported = 0;
_failed = 0;
});
for (final row in result.valid) {
final metodoId = _resolveMetodo(row.metodoNombre, metodoMap);
if (metodoId == null) {
setState(() => _failed++);
continue;
}
final datos = <String, dynamic>{
'dni': row.dni,
'metodo_id': metodoId,
'anio_mes_pagado': row.anioMesPagado,
'monto_total': row.montoTotal,
if (row.fechaPago != null) 'fecha_pago': row.fechaPago,
};
final error = await ref.read(pagosProvider.notifier).insertPago(datos);
if (mounted) {
setState(() {
if (error == null) {
_imported++;
} else {
_failed++;
}
});
}
}
if (mounted) setState(() => _step = _Step.done);
}
int? _resolveMetodo(String nombre, Map<String, int> metodoMap) {
// Exact match (case-insensitive)
return metodoMap[nombre.toLowerCase().trim()];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Importar pagos desde CSV'),
content: SizedBox(
width: 480,
child: _buildContent(theme),
),
actions: _buildActions(theme),
);
}
Widget _buildContent(ThemeData theme) {
switch (_step) {
case _Step.idle:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleccioná un archivo CSV exportado desde esta app. '
'Las columnas requeridas son: dni, anio_mes_pagado, '
'monto_total, metodo.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(180),
),
),
if (_currentError != null) ...[
const SizedBox(height: 12),
_ErrorChip(message: _currentError!),
],
],
);
case _Step.picking:
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: CircularProgressIndicator(color: SomaColors.primary),
),
);
case _Step.preview:
final valid = _result!.valid;
final invalid = _result!.invalid;
final metodos = ref.watch(metodosPagoProvider).valueOrNull ?? [];
final metodoMap = <String, int>{
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
};
final sinMetodo = valid
.where((r) => _resolveMetodo(r.metodoNombre, metodoMap) == null)
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SummaryRow(
icon: Icons.check_circle_outline,
color: SomaColors.success,
label: '${valid.length} filas válidas',
),
if (sinMetodo.isNotEmpty) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.warning_amber_outlined,
color: Colors.orange,
label: '${sinMetodo.length} con método de pago no reconocido '
'(se saltarán)',
),
],
if (invalid.isNotEmpty) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.error_outline,
color: SomaColors.error,
label: '${invalid.length} filas con errores (se saltarán)',
),
],
if (invalid.isNotEmpty || sinMetodo.isNotEmpty) ...[
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: ListView(
shrinkWrap: true,
children: [
...sinMetodo.map((r) => _ErrorRow(
rowNum: r.rowNumber,
msg: 'Método no reconocido: "${r.metodoNombre}"',
)),
...invalid.map((r) => _ErrorRow(
rowNum: r.rowNumber,
msg: r.validationError ?? 'Error desconocido',
)),
],
),
),
],
],
);
case _Step.importing:
final total = _result!.valid.length;
final done = _imported + _failed;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
LinearProgressIndicator(
value: total > 0 ? done / total : null,
color: SomaColors.primary,
),
const SizedBox(height: 12),
Text(
'Importando $done / $total...',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
],
);
case _Step.done:
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
if (_imported > 0)
_SummaryRow(
icon: Icons.check_circle_outline,
color: SomaColors.success,
label: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'} correctamente',
),
if (_failed > 0) ...[
const SizedBox(height: 4),
_SummaryRow(
icon: Icons.error_outline,
color: SomaColors.error,
label: '$_failed fila${_failed == 1 ? '' : 's'} con error',
),
],
const SizedBox(height: 8),
],
);
}
}
List<Widget> _buildActions(ThemeData theme) {
switch (_step) {
case _Step.idle:
return [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
FilledButton.icon(
onPressed: _pickAndParse,
icon: const Icon(Icons.folder_open_outlined, size: 18),
label: const Text('Seleccionar archivo'),
),
];
case _Step.picking:
case _Step.importing:
return const [];
case _Step.preview:
final importable = _result!.valid.isNotEmpty;
return [
TextButton(
onPressed: () {
setState(() {
_step = _Step.idle;
_result = null;
});
},
child: const Text('Cambiar archivo'),
),
FilledButton(
onPressed: importable ? _runImport : null,
child: Text(
'Importar ${_result!.valid.length} pago${_result!.valid.length == 1 ? '' : 's'}',
),
),
];
case _Step.done:
return [
FilledButton(
onPressed: () {
Navigator.pop(context);
if (_imported > 0) {
SomaToast.show(
context,
message: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'}',
type: ToastType.success,
);
}
},
child: const Text('Cerrar'),
),
];
}
}
}
// ── Helpers visuales ──────────────────────────────────────────────────────────
enum _Step { idle, picking, preview, importing, done }
class _SummaryRow extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
const _SummaryRow({
required this.icon,
required this.color,
required this.label,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: TextStyle(fontSize: 13, color: color),
),
),
],
);
}
}
class _ErrorRow extends StatelessWidget {
final int rowNum;
final String msg;
const _ErrorRow({required this.rowNum, required this.msg});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'Fila $rowNum: $msg',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
),
),
);
}
}
class _ErrorChip extends StatelessWidget {
final String message;
const _ErrorChip({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(14),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: SomaColors.error.withAlpha(50), width: 0.8),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 14, color: SomaColors.error),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(fontSize: 12, color: SomaColors.error),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
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/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
/// Muestra el historial de pagos de un usuario en dos layers:
/// 1. Grilla calendar de meses (verde = pagó, gris = no estuvo).
/// Pinta sólo en base a pagos NO anulados (un pago anulado no
/// cuenta el mes como pagado).
/// 2. Lista cronológica de pagos individuales debajo, con toggle
/// "Mostrar anulados" para que Juani vea exactamente qué se cargó,
/// qué se editó y qué se anuló (con motivo y autor).
///
/// El fetch al backend pide siempre incluirAnulados=true (un único request);
/// el toggle de la sección filtra localmente.
class UsuarioHistorialDialog extends ConsumerStatefulWidget {
const UsuarioHistorialDialog({
super.key,
required this.dni,
required this.nombre,
required this.initials,
});
final String dni;
final String nombre;
final String initials;
@override
ConsumerState<UsuarioHistorialDialog> createState() =>
_UsuarioHistorialDialogState();
}
class _UsuarioHistorialDialogState
extends ConsumerState<UsuarioHistorialDialog> {
bool _incluirAnulados = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final historialAsync = ref.watch(
userHistorialProvider((dni: widget.dni, incluirAnulados: true)),
);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 20),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(30),
shape: BoxShape.circle,
),
child: Center(
child: Text(
widget.initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: SomaColors.primary,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.nombre,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
Text(
'DNI ${widget.dni}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(130),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor: cs.surfaceContainerHighest,
),
),
],
),
),
// Contenido scrollable
Flexible(
fit: FlexFit.loose,
child: historialAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(40),
child: Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'Error al cargar historial',
style: TextStyle(color: cs.onSurface.withAlpha(130)),
),
),
),
data: (pagosAll) {
// pagosAll viene del backend con anulados incluidos.
// Para la grilla calendar: sólo los efectivos (no anulados).
// Para la lista cronológica: filtra según toggle local.
final pagosEfectivos =
pagosAll.where((p) => !p.isAnulado).toList();
final pagosLista = _incluirAnulados
? pagosAll
: pagosEfectivos;
final paidMonths = <String>{};
for (final p in pagosEfectivos) {
if (p.anioMesPagado.length >= 7) {
paidMonths.add(p.anioMesPagado.substring(0, 7));
}
}
final totalEfectivo = pagosEfectivos.fold<double>(
0,
(sum, p) => sum + p.montoTotal,
);
final anuladosCount =
pagosAll.where((p) => p.isAnulado).length;
final now = DateTime.now();
final currentMonth =
'${now.year}-${now.month.toString().padLeft(2, '0')}';
final months = _buildMonthRange(pagosEfectivos, currentMonth);
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stats
_StatsRow(
pagoCount: pagosEfectivos.length,
totalMonto: totalEfectivo,
firstMonth: months.isNotEmpty ? months.first : null,
anuladosCount: anuladosCount,
),
const SizedBox(height: 20),
// Section: Historial calendar
_sectionLabel(context, 'Historial'),
const SizedBox(height: 12),
if (months.isEmpty)
Center(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Sin pagos registrados',
style: TextStyle(
color: cs.onSurface.withAlpha(130),
),
),
),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: months
.map(
(m) => _MonthTile(
yearMonth: m,
paid: paidMonths.contains(m),
isCurrent: m == currentMonth,
),
)
.toList(),
),
const SizedBox(height: 14),
// Leyenda
Row(
children: [
_LegendDot(
color: SomaColors.success,
label: 'Pagó',
),
const SizedBox(width: 16),
_LegendDot(
color: cs.onSurface.withAlpha(40),
label: 'No estuvo',
),
],
),
const SizedBox(height: 24),
// Section: Detalle de pagos + toggle anulados
Row(
children: [
Expanded(
child:
_sectionLabel(context, 'Detalle de pagos'),
),
FilterChip(
label: const Text('Mostrar anulados'),
selected: _incluirAnulados,
onSelected: (v) =>
setState(() => _incluirAnulados = v),
avatar: anuladosCount > 0
? CircleAvatar(
radius: 9,
backgroundColor: _incluirAnulados
? SomaColors.error
: SomaColors.error.withAlpha(120),
child: Text(
'$anuladosCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
)
: null,
labelStyle: TextStyle(
fontSize: 11,
color: cs.onSurface,
),
selectedColor:
SomaColors.error.withAlpha(30),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: _incluirAnulados
? SomaColors.error.withAlpha(160)
: cs.surfaceContainerHighest,
width: 0.8,
),
),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 10),
if (pagosLista.isEmpty)
Center(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Sin pagos para mostrar',
style: TextStyle(
color: cs.onSurface.withAlpha(130),
),
),
),
)
else
Column(
children: pagosLista
.map((p) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _PagoListItem(pago: p),
))
.toList(),
),
],
),
);
},
),
),
],
),
),
);
}
/// Genera la lista de meses desde el primero pagado hasta el mes actual.
/// Recibe sólo los pagos efectivos (anulados ya filtrados).
List<String> _buildMonthRange(List<Pago> pagos, String currentMonth) {
if (pagos.isEmpty) return [];
String? firstMonth;
for (final p in pagos) {
if (p.anioMesPagado.length < 7) continue;
final m = p.anioMesPagado.substring(0, 7);
if (firstMonth == null || m.compareTo(firstMonth) < 0) {
firstMonth = m;
}
}
if (firstMonth == null) return [];
final result = <String>[];
final startParts = firstMonth.split('-');
var cursor = DateTime(int.parse(startParts[0]), int.parse(startParts[1]));
final endParts = currentMonth.split('-');
final end = DateTime(int.parse(endParts[0]), int.parse(endParts[1]));
while (!cursor.isAfter(end)) {
result.add('${cursor.year}-${cursor.month.toString().padLeft(2, '0')}');
cursor = DateTime(cursor.year, cursor.month + 1);
}
return result;
}
}
// ─────────────────────────────────────────────────────────────────────────────
Widget _sectionLabel(BuildContext context, String text) {
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),
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────────
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pagoCount,
required this.totalMonto,
required this.firstMonth,
required this.anuladosCount,
});
final int pagoCount;
final double totalMonto;
final String? firstMonth;
final int anuladosCount;
String _formatMonto(double n) {
if (n >= 1000000) return '\$${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) {
final k = n / 1000;
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
}
return '\$${n.toStringAsFixed(0)}';
}
String? _formatFirstMonth(String? m) {
if (m == null) return null;
final parts = m.split('-');
if (parts.length != 2) return m;
const abrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
final month = int.tryParse(parts[1]) ?? 0;
final year = parts[0].substring(2);
return "${abrev[month]} '$year";
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
),
child: Row(
children: [
_StatCell(
value: '$pagoCount',
label: pagoCount == 1 ? 'pago' : 'pagos',
),
_StatDivider(),
_StatCell(
value: _formatMonto(totalMonto),
label: 'total acumulado',
),
if (firstMonth != null) ...[
_StatDivider(),
_StatCell(
value: _formatFirstMonth(firstMonth) ?? firstMonth!,
label: 'primer pago',
),
],
if (anuladosCount > 0) ...[
_StatDivider(),
_StatCell(
value: '$anuladosCount',
label: anuladosCount == 1 ? 'anulado' : 'anulados',
valueColor: SomaColors.error,
),
],
],
),
);
}
}
class _StatCell extends StatelessWidget {
const _StatCell({
required this.value,
required this.label,
this.valueColor,
});
final String value;
final String label;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: valueColor ?? cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
color: cs.onSurface.withAlpha(120),
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _StatDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: VerticalDivider(
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _MonthTile extends StatelessWidget {
const _MonthTile({
required this.yearMonth,
required this.paid,
required this.isCurrent,
});
final String yearMonth; // "YYYY-MM"
final bool paid;
final bool isCurrent;
static const _mesAbrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final parts = yearMonth.split('-');
final month = int.tryParse(parts[1]) ?? 0;
final year = parts[0].substring(2);
final Color bg;
final Color textColor;
final Color borderColor;
final Widget icon;
if (paid) {
bg = SomaColors.success.withAlpha(18);
textColor = SomaColors.success;
borderColor = SomaColors.success.withAlpha(90);
icon = Icon(Icons.check_rounded, size: 14, color: SomaColors.success);
} else if (isCurrent) {
bg = SomaColors.primary.withAlpha(12);
textColor = cs.onSurface;
borderColor = SomaColors.primary.withAlpha(120);
icon = Icon(
Icons.radio_button_unchecked,
size: 12,
color: cs.onSurface.withAlpha(80),
);
} else {
bg = cs.surfaceContainerHighest.withAlpha(80);
textColor = cs.onSurface.withAlpha(100);
borderColor = cs.surfaceContainerHighest;
icon = SizedBox(
height: 14,
child: Center(
child: Container(
width: 12,
height: 1.5,
color: cs.onSurface.withAlpha(40),
),
),
);
}
return Container(
width: 52,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor, width: 0.8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_mesAbrev[month],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: textColor,
),
),
const SizedBox(height: 2),
Text(
"'$year",
style: TextStyle(
fontSize: 9,
color: textColor.withAlpha(180),
),
),
const SizedBox(height: 4),
icon,
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withAlpha(120),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// Item compacto de la lista cronológica de pagos. Si el pago está anulado
/// muestra sub-línea VISIBLE con motivo + autor + tiempo. Si está editado
/// muestra mini ícono lápiz con tooltip (info secundaria).
class _PagoListItem extends StatelessWidget {
final Pago pago;
const _PagoListItem({required this.pago});
static const _mesAbrev = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
String _mesAbreviado() {
final d = DateTime.tryParse(pago.anioMesPagado);
if (d == null) return pago.anioMesPagado;
return "${_mesAbrev[d.month]} '${d.year.toString().substring(2)}";
}
String _formatMonto(double n) =>
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
String _cargadoLabel() {
final f = pago.fechaPago;
if (f == null) return 'Cargado —';
return 'Cargado ${f.day.toString().padLeft(2, '0')}/${f.month.toString().padLeft(2, '0')}';
}
String _anuladoSubline() {
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
? pago.motivoAnulacion!.trim()
: 'Sin motivo';
final autor = pago.anuladoPorNombre ?? 'admin';
final hace = pago.anuladoAt != null ? _timeagoEs(pago.anuladoAt!) : '';
return hace.isEmpty
? '$motivo · por $autor'
: '$motivo · por $autor · $hace';
}
String _editadoTooltip() {
final autor = pago.updatedByNombre ?? 'admin';
final cuando = pago.updatedAt;
if (cuando == null) return 'Editado por $autor';
final f =
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
return 'Editado por $autor el $f';
}
static String _timeagoEs(DateTime when) {
final diff = DateTime.now().difference(when);
if (diff.inSeconds < 60) return 'hace unos segundos';
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
if (diff.inDays < 30) {
final d = diff.inDays;
return d == 1 ? 'hace 1 día' : 'hace $d días';
}
if (diff.inDays < 365) {
final m = (diff.inDays / 30).floor();
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
}
final y = (diff.inDays / 365).floor();
return y == 1 ? 'hace 1 año' : 'hace $y años';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final anulado = pago.isAnulado;
final editado = pago.isEditado && !anulado;
final mainTextColor =
anulado ? cs.onSurface.withAlpha(140) : cs.onSurface;
final montoColor =
anulado ? SomaColors.error.withAlpha(160) : SomaColors.success;
final decoration = anulado ? TextDecoration.lineThrough : null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.surfaceContainerHighest, width: 0.6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 60,
child: Text(
_mesAbreviado(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: mainTextColor,
decoration: decoration,
),
),
),
SizedBox(
width: 70,
child: Text(
'\$${_formatMonto(pago.montoTotal)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: montoColor,
decoration: decoration,
),
),
),
Expanded(
child: Text(
pago.metodo,
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
overflow: TextOverflow.ellipsis,
),
),
if (editado) ...[
Tooltip(
message: _editadoTooltip(),
child: Icon(
Icons.edit_outlined,
size: 12,
color: cs.onSurface.withAlpha(140),
),
),
const SizedBox(width: 6),
],
Text(
_cargadoLabel(),
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120),
),
),
],
),
if (anulado) ...[
const SizedBox(height: 6),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: SomaColors.error.withAlpha(28),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'ANULADO',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: SomaColors.error,
letterSpacing: 0.5,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_anuladoSubline(),
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: cs.onSurface.withAlpha(130),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class PagosExport {
// ── CSV ───────────────────────────────────────────────────────────────────
static Future<void> exportToCsv(
BuildContext context,
List<Pago> pagos, {
String? filtroMes,
}) async {
final csvBytes = _buildCsvBytes(pagos);
final stamp = DateTime.now();
final defaultName =
'pagos_${stamp.year}${stamp.month.toString().padLeft(2, '0')}${stamp.day.toString().padLeft(2, '0')}.csv';
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Guardar pagos como CSV',
fileName: defaultName,
type: FileType.custom,
allowedExtensions: ['csv'],
);
if (outputPath == null) return; // usuario canceló
await File(outputPath).writeAsBytes(csvBytes);
if (context.mounted) {
SomaToast.show(context, message: 'CSV guardado correctamente', type: ToastType.success);
}
}
static List<int> _buildCsvBytes(List<Pago> pagos) {
final buf = StringBuffer();
buf.writeln('dni,nombre_apellido,anio_mes_pagado,monto_total,metodo,fecha_pago');
for (final p in pagos) {
final c = p.cliente;
final dni = _csvCell(c?.dni ?? '');
final nombre = _csvCell('${c?.nombre ?? ''} ${c?.apellido ?? ''}'.trim());
// anio_mes: usar solo YYYY-MM para reimportar
final mes = p.anioMesPagado.length >= 7 ? p.anioMesPagado.substring(0, 7) : p.anioMesPagado;
final monto = p.montoTotal.toStringAsFixed(2);
final metodo = _csvCell(p.metodo);
final fecha = p.fechaPago != null
? '${p.fechaPago!.year}-'
'${p.fechaPago!.month.toString().padLeft(2, '0')}-'
'${p.fechaPago!.day.toString().padLeft(2, '0')}'
: '';
buf.writeln('$dni,$nombre,$mes,$monto,$metodo,$fecha');
}
// BOM para compatibilidad con Excel (UTF-8)
return [0xEF, 0xBB, 0xBF, ...utf8.encode(buf.toString())];
}
// Envuelve la celda en comillas si contiene coma, comilla o salto de línea.
static String _csvCell(String value) {
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
// ── PDF ───────────────────────────────────────────────────────────────────
static Future<void> exportToPdf(
BuildContext context,
List<Pago> pagos, {
String? filtroMes,
}) async {
final doc = _buildPdfDocument(pagos, filtroMes: filtroMes);
await Printing.layoutPdf(
onLayout: (_) => doc.save(),
name: filtroMes != null ? 'Pagos $filtroMes' : 'Pagos',
);
}
static pw.Document _buildPdfDocument(List<Pago> pagos, {String? filtroMes}) {
final doc = pw.Document();
final totalMonto = pagos.fold<double>(0, (sum, p) => sum + p.montoTotal);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 36),
header: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'SOMA Listado de Pagos',
style: pw.TextStyle(
fontSize: 16,
fontWeight: pw.FontWeight.bold,
),
),
if (filtroMes != null)
pw.Text(
filtroMes,
style: const pw.TextStyle(fontSize: 11),
),
],
),
pw.SizedBox(height: 4),
pw.Divider(thickness: 0.5),
],
),
footer: (ctx) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Total: \$${totalMonto.toStringAsFixed(2)} · ${pagos.length} pago${pagos.length == 1 ? '' : 's'}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
),
pw.Text(
'Pág. ${ctx.pageNumber} / ${ctx.pagesCount}',
style: const pw.TextStyle(fontSize: 9),
),
],
),
build: (ctx) => [
pw.TableHelper.fromTextArray(
headers: ['DNI', 'Socio', 'Mes pagado', 'Método', 'Monto'],
headerStyle: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
),
cellStyle: const pw.TextStyle(fontSize: 9),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerLeft,
2: pw.Alignment.centerLeft,
3: pw.Alignment.centerLeft,
4: pw.Alignment.centerRight,
},
columnWidths: {
0: const pw.FixedColumnWidth(72),
1: const pw.FlexColumnWidth(2.5),
2: const pw.FlexColumnWidth(1.8),
3: const pw.FlexColumnWidth(1.8),
4: const pw.FixedColumnWidth(68),
},
data: pagos.map((p) {
final c = p.cliente;
return [
c?.dni ?? '',
c != null ? '${c.apellido}, ${c.nombre}'.trim() : '',
p.mesPagadoDisplay,
p.metodo,
'\$${p.montoTotal.toStringAsFixed(2)}',
];
}).toList(),
),
],
),
);
return doc;
}
}
@@ -0,0 +1,169 @@
import 'dart:convert';
/// Fila parseada de un CSV de pagos.
class PagosImportRow {
final int rowNumber;
final String dni;
final String anioMesPagado; // formato YYYY-MM-01
final double montoTotal;
final String metodoNombre;
final String? fechaPago; // YYYY-MM-DD, opcional
final String? validationError;
const PagosImportRow({
required this.rowNumber,
required this.dni,
required this.anioMesPagado,
required this.montoTotal,
required this.metodoNombre,
this.fechaPago,
this.validationError,
});
bool get isValid => validationError == null;
}
/// Resultado del parseo de un CSV exportado por la app.
class PagosImportResult {
final List<PagosImportRow> rows; // incluye válidas e inválidas
final List<String> parseErrors; // errores que impidieron leer el archivo
const PagosImportResult({required this.rows, this.parseErrors = const []});
List<PagosImportRow> get valid => rows.where((r) => r.isValid).toList();
List<PagosImportRow> get invalid => rows.where((r) => !r.isValid).toList();
}
/// Parsea el contenido de un CSV exportado con [PagosExport.exportToCsv].
/// Columnas esperadas: dni, nombre_apellido, anio_mes_pagado, monto_total, metodo, fecha_pago
PagosImportResult parsePagosCsv(List<int> bytes) {
// Quitar BOM UTF-8 si está presente
final content = bytes.length >= 3 &&
bytes[0] == 0xEF &&
bytes[1] == 0xBB &&
bytes[2] == 0xBF
? utf8.decode(bytes.sublist(3))
: utf8.decode(bytes);
final lines = content
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) {
return const PagosImportResult(
rows: [],
parseErrors: ['El archivo está vacío'],
);
}
// Verificar encabezado
final headerCells = _splitCsvLine(lines[0]);
const expectedHeaders = [
'dni',
'nombre_apellido',
'anio_mes_pagado',
'monto_total',
'metodo',
'fecha_pago',
];
final missingHeaders = expectedHeaders
.where((h) => !headerCells.map((c) => c.toLowerCase()).contains(h))
.toList();
if (missingHeaders.isNotEmpty) {
return PagosImportResult(
rows: const [],
parseErrors: [
'Formato de archivo incorrecto. Columnas faltantes: ${missingHeaders.join(', ')}',
],
);
}
final headerIndex = {
for (var i = 0; i < headerCells.length; i++) headerCells[i].toLowerCase(): i
};
final rows = <PagosImportRow>[];
for (var i = 1; i < lines.length; i++) {
final cells = _splitCsvLine(lines[i]);
if (cells.length < 4) continue;
int col(String name) => headerIndex[name] ?? -1;
String get(String name) {
final idx = col(name);
return (idx >= 0 && idx < cells.length) ? cells[idx].trim() : '';
}
final rowNum = i;
final dni = get('dni');
final mesRaw = get('anio_mes_pagado'); // YYYY-MM o YYYY-MM-DD
final montoStr = get('monto_total');
final metodo = get('metodo');
final fechaRaw = get('fecha_pago');
// Validaciones
String? error;
if (dni.isEmpty) {
error = 'DNI vacío';
} else if (mesRaw.isEmpty || !RegExp(r'^\d{4}-\d{2}').hasMatch(mesRaw)) {
error = 'Mes inválido: "$mesRaw"';
} else if (double.tryParse(montoStr) == null ||
(double.tryParse(montoStr) ?? 0) <= 0) {
error = 'Monto inválido: "$montoStr"';
} else if (metodo.isEmpty) {
error = 'Método vacío';
}
// Normalizar anio_mes_pagado a YYYY-MM-01
final anioMes = mesRaw.length >= 7
? '${mesRaw.substring(0, 7)}-01'
: mesRaw;
// Normalizar fecha_pago (aceptar YYYY-MM-DD, dejar null si vacío/inválido)
String? fechaFinal;
if (fechaRaw.isNotEmpty &&
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(fechaRaw)) {
fechaFinal = fechaRaw;
}
rows.add(PagosImportRow(
rowNumber: rowNum,
dni: dni,
anioMesPagado: anioMes,
montoTotal: double.tryParse(montoStr) ?? 0,
metodoNombre: metodo,
fechaPago: fechaFinal,
validationError: error,
));
}
return PagosImportResult(rows: rows);
}
/// Divide una línea CSV respetando celdas entre comillas.
List<String> _splitCsvLine(String line) {
final result = <String>[];
final buf = StringBuffer();
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
final ch = line[i];
if (ch == '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
buf.write('"');
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch == ',' && !inQuotes) {
result.add(buf.toString());
buf.clear();
} else {
buf.write(ch);
}
}
result.add(buf.toString());
return result;
}