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,65 @@
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/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
class ActividadesRepositoryImpl implements ActividadesRepository {
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<Actividad>> getActividades({bool? soloActivas}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (soloActivas != null) params['p_solo_activas'] = soloActivas;
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetActividades,
params: params,
);
if (response is List) {
return response
.map((e) => Actividad.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertActividad(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertActividad,
params: {'p_token': token, 'p_datos': datos},
);
}
@override
Future<void> updateActividad(int id, Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateActividad,
params: {'p_token': token, 'p_actividad_id': id, 'p_datos': datos},
);
}
@override
Future<bool> deleteActividad(int id) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteActividad,
params: {'p_token': token, 'p_actividad_id': id},
);
return response == true;
}
}
@@ -0,0 +1,47 @@
class Actividad {
final int id;
final String nombre;
final int duracion; // minutos
final int capacidadPorDefecto;
final bool libre;
final bool activo;
const Actividad({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidadPorDefecto,
this.libre = false,
this.activo = true,
});
factory Actividad.fromMap(Map<String, dynamic> map) {
return Actividad(
id: map['id'] as int,
nombre: map['nombre'] as String? ?? '',
duracion: (map['duracion'] as num?)?.toInt() ?? 0,
capacidadPorDefecto: (map['capacidad_por_defecto'] as num?)?.toInt() ?? 0,
libre: map['libre'] as bool? ?? false,
activo: map['activo'] as bool? ?? true,
);
}
Map<String, dynamic> toMap() {
return {
'nombre': nombre,
'duracion': duracion,
'capacidad_por_defecto': capacidadPorDefecto,
'libre': libre,
'activo': activo,
};
}
String get duracionDisplay {
if (duracion >= 60) {
final h = duracion ~/ 60;
final m = duracion % 60;
return m > 0 ? '${h}h ${m}min' : '${h}h';
}
return '${duracion}min';
}
}
@@ -0,0 +1,8 @@
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
abstract class ActividadesRepository {
Future<List<Actividad>> getActividades({bool? soloActivas});
Future<void> insertActividad(Map<String, dynamic> datos);
Future<void> updateActividad(int id, Map<String, dynamic> datos);
Future<bool> deleteActividad(int id);
}
@@ -0,0 +1,67 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/actividades/data/repositories/actividades_repository_impl.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final actividadesRepositoryProvider = Provider<ActividadesRepository>((ref) {
return ActividadesRepositoryImpl();
});
final actividadesProvider =
StateNotifierProvider<ActividadesNotifier, AsyncValue<List<Actividad>>>((ref) {
return ActividadesNotifier(ref.read(actividadesRepositoryProvider));
});
class ActividadesNotifier extends StateNotifier<AsyncValue<List<Actividad>>> {
final ActividadesRepository _repository;
ActividadesNotifier(this._repository) : super(const AsyncValue.loading()) {
loadActividades();
}
Future<void> loadActividades() async {
state = const AsyncValue.loading();
try {
final actividades = await _repository.getActividades();
state = AsyncValue.data(actividades);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertActividad(Map<String, dynamic> datos) async {
try {
await _repository.insertActividad(datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> updateActividad(int id, Map<String, dynamic> datos) async {
try {
await _repository.updateActividad(id, datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> deleteActividad(int id) async {
try {
await _repository.deleteActividad(id);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
}
@@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
import 'package:gimnasio_soma/features/actividades/presentation/widgets/actividad_form_dialog.dart';
class ActividadesScreen extends ConsumerStatefulWidget {
const ActividadesScreen({super.key});
@override
ConsumerState<ActividadesScreen> createState() => _ActividadesScreenState();
}
class _ActividadesScreenState extends ConsumerState<ActividadesScreen> {
Future<void> _showCreateDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const ActividadFormDialog(),
);
if (result == null || !mounted) return;
final error =
await ref.read(actividadesProvider.notifier).insertActividad(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad creada', type: ToastType.success);
}
}
Future<void> _showEditDialog(Actividad actividad) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => ActividadFormDialog(actividad: actividad),
);
if (result == null || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.updateActividad(actividad.id, result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad actualizada', type: ToastType.success);
}
}
Future<void> _deleteActividad(Actividad actividad) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar actividad'),
content: Text(
'¿Estás seguro de que querés eliminar "${actividad.nombre}"?\n'
'Esta acción no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.deleteActividad(actividad.id);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad eliminada', type: ToastType.success);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(actividadesProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Actividades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const Spacer(),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (actividades) {
if (actividades.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fitness_center_outlined,
size: 56,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay actividades',
style: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: actividades.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final act = actividades[index];
return _ActividadCard(
actividad: act,
onEdit: () => _showEditDialog(act),
onDelete: () => _deleteActividad(act),
);
},
),
);
},
),
),
],
),
);
}
}
class _ActividadCard extends StatelessWidget {
final Actividad actividad;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _ActividadCard({
required this.actividad,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
// Ícono
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: actividad.activo
? ActivityColors.forId(actividad.id).withAlpha(30)
: theme.colorScheme.onSurface.withAlpha(15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.fitness_center,
size: 20,
color: actividad.activo
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(100),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!actividad.activo)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Inactiva',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
),
if (actividad.libre)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: SomaColors.success,
),
),
),
),
],
),
const SizedBox(height: 3),
Row(
children: [
Icon(Icons.timer_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
actividad.duracionDisplay,
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
Text(
'',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(80),
),
),
Icon(Icons.group_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
'${actividad.capacidadPorDefecto} personas',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
],
),
),
// Actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100),
),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline, size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Eliminar',
style: TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEdit();
if (v == 'delete') onDelete();
},
),
],
),
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nueva'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
class ActividadFormDialog extends StatefulWidget {
final Actividad? actividad;
const ActividadFormDialog({super.key, this.actividad});
@override
State<ActividadFormDialog> createState() => _ActividadFormDialogState();
}
class _ActividadFormDialogState extends State<ActividadFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nombreCtrl;
late final TextEditingController _duracionCtrl;
late final TextEditingController _capacidadCtrl;
late bool _libre;
bool get _isEditing => widget.actividad != null;
@override
void initState() {
super.initState();
_nombreCtrl = TextEditingController(text: widget.actividad?.nombre ?? '');
_duracionCtrl = TextEditingController(
text: widget.actividad != null ? widget.actividad!.duracion.toString() : '',
);
_capacidadCtrl = TextEditingController(
text: widget.actividad != null
? widget.actividad!.capacidadPorDefecto.toString()
: '',
);
_libre = widget.actividad?.libre ?? false;
}
@override
void dispose() {
_nombreCtrl.dispose();
_duracionCtrl.dispose();
_capacidadCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'nombre': _nombreCtrl.text.trim(),
'duracion': int.tryParse(_duracionCtrl.text.trim()) ?? 0,
'capacidad_por_defecto': int.tryParse(_capacidadCtrl.text.trim()) ?? 0,
'libre': _libre,
};
Navigator.of(context).pop(data);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 440) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
_isEditing ? 'Editar Actividad' : 'Nueva Actividad',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.fitness_center,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _duracionCtrl,
labelText: 'Duración (min) *',
prefixIcon: Icons.timer_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _capacidadCtrl,
labelText: 'Capacidad *',
prefixIcon: Icons.group_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text(
'Actividad libre',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: const Text(
'No requiere turno previo',
style: TextStyle(fontSize: 12),
),
value: _libre,
activeThumbColor: SomaColors.primary,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _libre = v),
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(_isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,137 @@
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthRepositoryImpl implements AuthRepository {
@override
Future<UserSession> login(String dni, String password) async {
// fc_ingresar(dni_input, password_plain_input) → TABLE(token uuid, rol text)
final loginResponse = await SupabaseConfig.rpc(
AppConstants.rpcLogin,
params: {
'dni_input': dni,
'password_plain_input': password,
},
);
if (loginResponse == null ||
(loginResponse is List && loginResponse.isEmpty)) {
throw Exception('DNI o contraseña incorrectos');
}
// Supabase devuelve TABLE como List<Map>
final row = (loginResponse as List)[0] as Map<String, dynamic>;
final token = row['token'] as String;
final rol = row['rol'] as String;
// Persistir token
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConstants.tokenKey, token);
// Intentar obtener datos completos del usuario
// fc_obtener_usuario_por_token(p_token, p_user_token) → jsonb
try {
final userData = await SupabaseConfig.rpc(
AppConstants.rpcGetUserByToken,
params: {
'p_token': token,
'p_user_token': token,
},
);
if (userData != null && userData is Map<String, dynamic>) {
return UserSession.fromUserData(userData, token);
}
} catch (_) {
// Si falla (ej: sin permiso 'ver_usuarios'), usamos datos básicos
}
// Fallback: datos básicos del login
return UserSession.fromLogin(token: token, role: rol, dni: dni);
}
@override
Future<UserSession?> validateSession() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null || token.isEmpty) return null;
try {
// fc_iniciar_sesion_por_token(p_token) → text (rol), extiende TTL
final rol = await SupabaseConfig.rpc(
AppConstants.rpcIniciarSesionPorToken,
params: {'p_token': token},
);
if (rol == null || (rol is String && rol.isEmpty)) {
await prefs.remove(AppConstants.tokenKey);
return null;
}
// Intentar obtener datos completos
try {
final userData = await SupabaseConfig.rpc(
AppConstants.rpcGetUserByToken,
params: {
'p_token': token,
'p_user_token': token,
},
);
if (userData != null && userData is Map<String, dynamic>) {
return UserSession.fromUserData(userData, token);
}
} catch (_) {
// Sin permiso, usamos datos básicos
}
return UserSession(token: token, role: rol as String);
} catch (_) {
await prefs.remove(AppConstants.tokenKey);
return null;
}
}
@override
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token != null) {
try {
// fc_eliminar_sesion(p_token) → void
await SupabaseConfig.rpc(
AppConstants.rpcDestroySession,
params: {'p_token': token},
);
} catch (_) {
// Silently fail - limpiamos el token local de todas formas
}
}
await prefs.remove(AppConstants.tokenKey);
}
@override
Future<void> cambiarPropiaContrasena({
required String passwordActual,
required String passwordNueva,
}) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
// fc_cambiar_propia_contrasena(p_token, p_password_actual, p_password_nueva) → void
await SupabaseConfig.rpc(
AppConstants.rpcCambiarPropiaContrasena,
params: {
'p_token': token,
'p_password_actual': passwordActual,
'p_password_nueva': passwordNueva,
},
);
}
}
@@ -0,0 +1,62 @@
class UserSession {
final String token;
final String role;
// UUID del usuario. Null al recién loguear (fc_ingresar no lo devuelve);
// se rellena al primer fc_obtener_usuario_por_token. Necesario para
// ownership checks (ej. "este pago lo creé yo").
final String? id;
final String nombre;
final String apellido;
final String dni;
final String? mail;
final String? telefono;
const UserSession({
required this.token,
required this.role,
this.id,
this.nombre = '',
this.apellido = '',
this.dni = '',
this.mail,
this.telefono,
});
/// Crear desde fc_ingresar (solo token + rol) + el DNI que ingresó el usuario.
factory UserSession.fromLogin({
required String token,
required String role,
required String dni,
}) {
return UserSession(token: token, role: role, dni: dni);
}
/// Crear desde fc_obtener_usuario_por_token (jsonb completo).
factory UserSession.fromUserData(Map<String, dynamic> map, String token) {
return UserSession(
token: token,
role: map['rol'] as String? ?? '',
id: map['id'] as String?,
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String? ?? '',
dni: map['dni'] as String? ?? '',
mail: map['mail'] as String?,
telefono: map['telefono'] as String?,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
if (nombre.isNotEmpty) return nombre;
return dni;
}
/// Getter unificado: staff incluye superadmin, admin y profesor
bool get isStaff => role == 'superadmin' || role == 'admin' || role == 'profesor';
bool get isUsuario => role == 'usuario' || role == 'cliente';
/// True sólo para el rol 'superadmin'. Habilita en UI las acciones
/// que el backend gobierna con el permiso 'gestionar_cualquier_pago'.
bool get isSuperadmin => role == 'superadmin';
}
@@ -0,0 +1,11 @@
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
abstract class AuthRepository {
Future<UserSession> login(String dni, String password);
Future<UserSession?> validateSession();
Future<void> logout();
Future<void> cambiarPropiaContrasena({
required String passwordActual,
required String passwordNueva,
});
}
@@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/auth/data/repositories/auth_repository_impl.dart';
import 'package:gimnasio_soma/features/auth/domain/entities/user_session.dart';
import 'package:gimnasio_soma/features/auth/domain/repositories/auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepositoryImpl();
});
final authStateProvider =
StateNotifierProvider<AuthNotifier, AsyncValue<UserSession?>>((ref) {
return AuthNotifier(ref.read(authRepositoryProvider));
});
/// Listenable que notifica al GoRouter cuando cambia el estado de auth.
/// Solo notifica cuando el estado pasa de logueado a no-logueado o viceversa,
/// no cuando pasa a loading (para evitar recrear el router innecesariamente).
final authChangeNotifierProvider = Provider<AuthChangeNotifier>((ref) {
final notifier = AuthChangeNotifier();
ref.listen<AsyncValue<UserSession?>>(authStateProvider, (prev, next) {
final wasLoggedIn = prev?.valueOrNull != null;
final isLoggedIn = next.valueOrNull != null;
if (wasLoggedIn != isLoggedIn) {
notifier.notify();
}
});
return notifier;
});
class AuthChangeNotifier extends ChangeNotifier {
void notify() => notifyListeners();
}
class AuthNotifier extends StateNotifier<AsyncValue<UserSession?>> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AsyncValue.data(null));
Future<void> checkSession() async {
state = const AsyncValue.loading();
try {
final session = await _repository.validateSession();
state = AsyncValue.data(session);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> login(String dni, String password) async {
try {
final session = await _repository.login(dni, password);
state = AsyncValue.data(session);
return null;
} catch (e) {
state = const AsyncValue.data(null);
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<void> logout() async {
await _repository.logout();
state = const AsyncValue.data(null);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_desktop.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_mobile.dart';
/// Breakpoint para cambiar entre mobile y desktop layout.
const _kDesktopBreakpoint = 800.0;
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<double> _fadeIn;
late final Animation<Offset> _slideMobile;
late final Animation<Offset> _slideDesktop;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeIn = CurvedAnimation(
parent: _animController,
curve: Curves.easeOut,
);
// Mobile: form slides up
_slideMobile = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
));
// Desktop: form slides in from right
_slideDesktop = Tween<Offset>(
begin: const Offset(0.08, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
));
_animController.forward();
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
Future<void> _handleLogin(String dni, String password) async {
final error = await ref
.read(authStateProvider.notifier)
.login(dni, password)
.timeout(
const Duration(seconds: 15),
onTimeout: () => 'Tiempo de espera agotado. Verificá tu conexión.',
);
if (!mounted || error == null) return;
SomaToast.show(context, message: error, type: ToastType.error);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isDesktop = width >= _kDesktopBreakpoint;
return Scaffold(
body: isDesktop
? LoginDesktop(
onSubmit: _handleLogin,
fadeIn: _fadeIn,
slideIn: _slideDesktop,
)
: LoginMobile(
onSubmit: _handleLogin,
fadeIn: _fadeIn,
slideUp: _slideMobile,
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkSession());
}
Future<void> _checkSession() async {
try {
await ref.read(authStateProvider.notifier).checkSession();
} catch (_) {
// Error en la validación, ir a login
}
if (!mounted) return;
final user = ref.read(authStateProvider).valueOrNull;
if (user == null) {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SomaLogo(width: 160),
const SizedBox(height: 32),
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: SomaColors.primary,
),
),
],
),
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
class LoginDesktop extends StatelessWidget {
final Future<void> Function(String dni, String password) onSubmit;
final Animation<double> fadeIn;
final Animation<Offset> slideIn;
const LoginDesktop({
super.key,
required this.onSubmit,
required this.fadeIn,
required this.slideIn,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
// ── Hero (left half) ──
const Expanded(
flex: 5,
child: LoginHero(),
),
// ── Form (right half) ──
Expanded(
flex: 4,
child: FadeTransition(
opacity: fadeIn,
child: SlideTransition(
position: slideIn,
child: Container(
height: double.infinity,
color: Theme.of(context).colorScheme.surface,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 48,
vertical: 40,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LoginForm(onSubmit: onSubmit),
const SizedBox(height: 32),
Center(
child: Text(
'v0.1.0',
style: TextStyle(
fontSize: 11,
color: SomaColors.darkOnSurfaceVariant
.withAlpha(100),
),
),
),
],
),
),
),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
class LoginForm extends StatefulWidget {
final Future<void> Function(String dni, String password) onSubmit;
const LoginForm({super.key, required this.onSubmit});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _dniController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
@override
void dispose() {
_dniController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
await widget.onSubmit(
_dniController.text.trim(),
_passwordController.text,
);
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Iniciar sesión',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
'Ingresá tus datos para continuar',
style: TextStyle(
fontSize: 14,
color: SomaColors.darkOnSurfaceVariant,
),
),
const SizedBox(height: 28),
SomaTextField(
controller: _dniController,
hintText: 'Ej: 12345678',
labelText: 'DNI',
prefixIcon: Icons.badge_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'El DNI es requerido';
}
if (value.length < 7) {
return 'DNI inválido';
}
return null;
},
),
const SizedBox(height: 16),
SomaTextField(
controller: _passwordController,
hintText: 'Tu contraseña',
labelText: 'Contraseña',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
size: 20,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'La contraseña es requerida';
}
if (value.length < 4) {
return 'La contraseña es muy corta';
}
return null;
},
),
const SizedBox(height: 32),
SomaPrimaryButton(
text: 'Ingresar',
onPressed: _handleSubmit,
isLoading: _isLoading,
),
],
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
class LoginHero extends StatelessWidget {
const LoginHero({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1A1A1A), Color(0xFF121212)],
),
),
child: Stack(
children: [
// Decorative circles
Positioned(
top: -60,
left: -60,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SomaColors.primary.withAlpha(15),
),
),
),
Positioned(
bottom: -40,
right: -40,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SomaColors.primary.withAlpha(10),
),
),
),
// Content
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
width: 180,
),
const SizedBox(height: 12),
Text(
'Gimnasio',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w300,
letterSpacing: 6,
color: SomaColors.darkOnSurfaceVariant,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_form.dart';
import 'package:gimnasio_soma/features/auth/presentation/widgets/login_hero.dart';
class LoginMobile extends StatelessWidget {
final Future<void> Function(String dni, String password) onSubmit;
final Animation<double> fadeIn;
final Animation<Offset> slideUp;
const LoginMobile({
super.key,
required this.onSubmit,
required this.fadeIn,
required this.slideUp,
});
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: SingleChildScrollView(
child: SizedBox(
height: screenHeight,
child: Column(
children: [
// ── Hero (top 35%) ──
const Expanded(
flex: 35,
child: SafeArea(
bottom: false,
child: LoginHero(),
),
),
// ── Form (bottom 65%) ──
Expanded(
flex: 65,
child: FadeTransition(
opacity: fadeIn,
child: SlideTransition(
position: slideUp,
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(28, 36, 28, 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(32),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(80),
blurRadius: 20,
offset: const Offset(0, -4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: LoginForm(onSubmit: onSubmit),
),
const SizedBox(height: 12),
Center(
child: Text(
'v0.1.0',
style: TextStyle(
fontSize: 11,
color: SomaColors.darkOnSurfaceVariant
.withAlpha(100),
),
),
),
],
),
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/services/soma_logger.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@override
State<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
LogLevel? _filter;
String _search = '';
List<LogEntry> get _filteredEntries {
var entries = SomaLogger.instance.entries.reversed.toList();
if (_filter != null) {
entries = entries.where((e) => e.level == _filter).toList();
}
if (_search.isNotEmpty) {
final q = _search.toLowerCase();
entries = entries
.where((e) =>
e.message.toLowerCase().contains(q) ||
e.tag.toLowerCase().contains(q) ||
(e.detail?.toLowerCase().contains(q) ?? false))
.toList();
}
return entries;
}
void _copyAll() {
final text = SomaLogger.instance.export();
Clipboard.setData(ClipboardData(text: text));
SomaToast.show(context,
message: 'Logs copiados al portapapeles', type: ToastType.success);
}
void _clearLogs() {
SomaLogger.instance.clear();
setState(() {});
SomaToast.show(context, message: 'Logs limpiados', type: ToastType.info);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
final entries = _filteredEntries;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
8,
),
child: Column(
children: [
Row(
children: [
const Text(
'Logs',
style:
TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(25),
borderRadius: BorderRadius.circular(6),
),
child: Text(
SomaLogger.instance.platform,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
const Spacer(),
IconButton(
onPressed: _copyAll,
icon: const Icon(Icons.copy, size: 20),
tooltip: 'Copiar logs',
),
IconButton(
onPressed: _clearLogs,
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Limpiar logs',
),
IconButton(
onPressed: () => setState(() {}),
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refrescar',
),
],
),
const SizedBox(height: 8),
// Filtros
Row(
children: [
Expanded(
child: SizedBox(
height: 36,
child: TextField(
onChanged: (v) => setState(() => _search = v),
decoration: InputDecoration(
hintText: 'Buscar...',
prefixIcon:
const Icon(Icons.search, size: 18),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.surfaceContainerHighest,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.surfaceContainerHighest,
),
),
),
style: const TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Todos',
selected: _filter == null,
onTap: () => setState(() => _filter = null),
),
const SizedBox(width: 4),
_FilterChip(
label: 'RPC',
selected: _search == 'RPC',
onTap: () => setState(() {
_search = _search == 'RPC' ? '' : 'RPC';
}),
color: SomaColors.primary,
),
const SizedBox(width: 4),
_FilterChip(
label: 'Error',
selected: _filter == LogLevel.error,
onTap: () => setState(() {
_filter =
_filter == LogLevel.error ? null : LogLevel.error;
}),
color: SomaColors.error,
),
],
),
],
),
),
// Contador
Padding(
padding: EdgeInsets.symmetric(horizontal: isWide ? 32 : 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'${entries.length} entradas',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
),
const SizedBox(height: 4),
// Lista
Expanded(
child: entries.isEmpty
? Center(
child: Text(
'Sin logs',
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
)
: ListView.builder(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 12,
0,
isWide ? 32 : 12,
80,
),
itemCount: entries.length,
itemBuilder: (context, index) {
return _LogEntryTile(entry: entries[index]);
},
),
),
],
),
);
}
}
class _LogEntryTile extends StatelessWidget {
final LogEntry entry;
const _LogEntryTile({required this.entry});
Color _levelColor() {
switch (entry.level) {
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return SomaColors.error;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ts =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:'
'${entry.timestamp.minute.toString().padLeft(2, '0')}:'
'${entry.timestamp.second.toString().padLeft(2, '0')}';
final dur = entry.duration != null
? ' ${entry.duration!.inMilliseconds}ms'
: '';
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: entry.detail != null
? () => _showDetail(context)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: entry.level == LogLevel.error
? SomaColors.error.withAlpha(8)
: Colors.transparent,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timestamp
Text(
ts,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 6),
// Level badge
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _levelColor(),
),
),
const SizedBox(width: 6),
// Tag
Text(
'[${entry.tag}]',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface.withAlpha(140),
),
),
const SizedBox(width: 6),
// Message
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: theme.colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Duration
if (dur.isNotEmpty)
Text(
dur,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
// Detail indicator
if (entry.detail != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.info_outline,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(60),
),
),
],
),
),
),
);
}
void _showDetail(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
content: SingleChildScrollView(
child: SelectableText(
entry.detail ?? '',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: '${entry.formatted}\n${entry.detail}'));
Navigator.of(ctx).pop();
},
child: const Text('Copiar'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cerrar'),
),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final Color? color;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final c = color ?? SomaColors.primary;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: selected ? c.withAlpha(25) : Colors.transparent,
border: Border.all(
color: selected ? c : theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? c : theme.colorScheme.onSurface.withAlpha(130),
),
),
),
);
}
}
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/widgets/soma_logo.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_secondary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class WidgetGalleryScreen extends ConsumerStatefulWidget {
const WidgetGalleryScreen({super.key});
@override
ConsumerState<WidgetGalleryScreen> createState() =>
_WidgetGalleryScreenState();
}
class _WidgetGalleryScreenState extends ConsumerState<WidgetGalleryScreen> {
final _textController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
@override
void dispose() {
_textController.dispose();
_passwordController.dispose();
super.dispose();
}
void _simulateLoading() async {
setState(() => _isLoading = true);
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider);
final user = authState.valueOrNull;
return Scaffold(
appBar: AppBar(
title: const Text('Widget Gallery'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => ref.read(authStateProvider.notifier).logout(),
tooltip: 'Cerrar sesión',
),
],
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
// User Info
if (user != null) ...[
_SectionTitle('Usuario Logueado'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nombre: ${user.displayName}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'DNI: ${user.dni}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Rol: ${user.role}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 32),
],
// Logo
_SectionTitle('Logo'),
const Center(child: SomaLogo(width: 200)),
const SizedBox(height: 32),
// Primary Buttons
_SectionTitle('Botones Primarios'),
SomaPrimaryButton(
text: 'Botón Normal',
onPressed: () => SomaToast.show(
context,
message: 'Botón presionado',
type: ToastType.info,
),
),
const SizedBox(height: 12),
SomaPrimaryButton(
text: 'Con Ícono',
icon: Icons.check,
onPressed: () => SomaToast.show(
context,
message: 'Botón con ícono presionado',
type: ToastType.success,
),
),
const SizedBox(height: 12),
SomaPrimaryButton(
text: 'Loading...',
isLoading: _isLoading,
onPressed: _simulateLoading,
),
const SizedBox(height: 12),
const SomaPrimaryButton(text: 'Deshabilitado', onPressed: null),
const SizedBox(height: 32),
// Secondary Buttons
_SectionTitle('Botones Secundarios'),
SomaSecondaryButton(
text: 'Botón Outline',
onPressed: () => SomaToast.show(
context,
message: 'Botón secundario presionado',
type: ToastType.info,
),
),
const SizedBox(height: 12),
SomaSecondaryButton(
text: 'Con Ícono',
icon: Icons.edit,
onPressed: () {},
),
const SizedBox(height: 12),
SomaSecondaryButton(
text: 'Loading...',
isLoading: _isLoading,
onPressed: _simulateLoading,
),
const SizedBox(height: 12),
const SomaSecondaryButton(text: 'Deshabilitado', onPressed: null),
const SizedBox(height: 32),
// Text Fields
_SectionTitle('Campos de Texto'),
SomaTextField(
controller: _textController,
hintText: 'Ingresá tu nombre',
labelText: 'Nombre',
prefixIcon: Icons.person,
),
const SizedBox(height: 16),
SomaTextField(
controller: _passwordController,
hintText: 'Ingresá tu contraseña',
labelText: 'Contraseña',
prefixIcon: Icons.lock,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
const SizedBox(height: 16),
const SomaTextField(
hintText: 'Campo deshabilitado',
labelText: 'Deshabilitado',
prefixIcon: Icons.block,
enabled: false,
),
const SizedBox(height: 32),
// Toasts
_SectionTitle('Notificaciones (Toasts)'),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Operación exitosa',
type: ToastType.success,
),
child: const Text('Toast Success'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Error en la operación',
type: ToastType.error,
),
child: const Text('Toast Error'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => SomaToast.show(
context,
message: 'Información importante',
type: ToastType.info,
),
child: const Text('Toast Info'),
),
const SizedBox(height: 32),
// Colors
_SectionTitle('Paleta de Colores'),
Row(
children: [
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.primary,
label: 'Primary',
),
),
const SizedBox(width: 12),
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.surface,
label: 'Surface',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.error,
label: 'Error',
),
),
const SizedBox(width: 12),
Expanded(
child: _ColorBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
label: 'Surface Variant',
),
),
],
),
const SizedBox(height: 48),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _ColorBox extends StatelessWidget {
final Color color;
final String label;
const _ColorBox({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Container(
height: 80,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.onSurface.withAlpha(51),
),
),
child: Center(
child: Text(
label,
style: TextStyle(
color: _getContrastColor(color),
fontWeight: FontWeight.w600,
),
),
),
);
}
Color _getContrastColor(Color background) {
final luminance = background.computeLuminance();
return luminance > 0.5 ? Colors.black87 : Colors.white;
}
}
@@ -0,0 +1,175 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
class HorariosRepositoryImpl implements HorariosRepository {
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;
}
String _formatDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaHorarios> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map<String, dynamic>) {
return SemanaHorarios.fromResponse(weekStart, response);
}
return SemanaHorarios.fromResponse(weekStart, {});
}
@override
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final token = await _getToken();
final datos = <String, dynamic>{
'fecha': _formatDate(fecha),
'es_especial': esEspecial,
'rangos': bloques,
};
if (esEspecial && motivo != null && motivo.isNotEmpty) {
datos['motivo'] = motivo;
}
if (!esEspecial) {
if (validoDesde != null) {
datos['valido_desde'] = _formatDate(validoDesde);
}
if (alcance != null) {
datos['alcance'] = alcance.toJson();
}
}
await SupabaseConfig.rpc(
AppConstants.rpcInsertHorarioConActividades,
params: {
'p_token': token,
'p_datos': datos,
},
);
}
@override
Future<void> eliminarDiaEspecial(DateTime fecha) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcEliminarDiaEspecial,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
},
);
}
@override
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90}) async {
final token = await _getToken();
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
// fc_obtener_horarios acepta p_cantidad_dias entre 1 y 31, así que partimos
// el rango pedido en chunks de hasta 31 días y disparamos las llamadas en
// paralelo.
const chunkSize = 31;
final futures = <Future<dynamic>>[];
for (var offset = 0; offset < dias; offset += chunkSize) {
final restantes = dias - offset;
final tamano = restantes < chunkSize ? restantes : chunkSize;
futures.add(
SupabaseConfig.rpc(
AppConstants.rpcObtenerHorarios,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(hoy.add(Duration(days: offset))),
'p_cantidad_dias': tamano,
},
),
);
}
final responses = await Future.wait(futures);
final resumen = <DiaEspecialResumen>[];
for (final response in responses) {
if (response is! Map<String, dynamic>) continue;
for (final entry in response.entries) {
final dayMap = entry.value;
if (dayMap is! Map<String, dynamic>) continue;
final tipo = dayMap['tipo'] as String?;
if (tipo == null || tipo == 'normal') continue;
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
resumen.add(DiaEspecialResumen.fromHorariosResponse(fecha, dayMap));
}
}
resumen.sort((a, b) => a.fecha.compareTo(b.fecha));
return resumen;
}
@override
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerPlanificacionesFuturas,
params: {
'p_token': token,
'p_dia_semana': diaSemana,
'p_desde': _formatDate(desde),
'p_meses': meses,
},
);
if (response is List) {
return response
.map((e) => PlanificacionFutura.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<int> contarHuerfanasDesde(DateTime instante) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: {
'p_token': token,
'p_creada_desde': instante.toIso8601String(),
},
);
if (response is List) return response.length;
return 0;
}
}
@@ -0,0 +1,44 @@
/// Cómo interactúa una edición de plantilla regular con planificaciones
/// futuras existentes para el mismo día de la semana.
///
/// El backend define tres variantes (ver §5.3 del brief de horarios):
/// * `indefinido` → borra las planificaciones futuras posteriores y deja el
/// nuevo horario sin fecha de fin.
/// * `hasta_proximo` (default backend) → respeta la próxima planificación
/// futura, cerrando el nuevo horario justo antes.
/// * `hasta` + fecha → cierra el nuevo horario en una fecha específica. Si
/// hay planificaciones futuras dentro del intervalo, el
/// backend rechaza con `ConflictoAlcance`.
sealed class Alcance {
const Alcance();
/// Serialización aceptada por `fc_insertar_horario_con_actividades`.
Map<String, dynamic> toJson();
}
class AlcanceIndefinido extends Alcance {
const AlcanceIndefinido();
@override
Map<String, dynamic> toJson() => const {'tipo': 'indefinido'};
}
class AlcanceHastaProximo extends Alcance {
const AlcanceHastaProximo();
@override
Map<String, dynamic> toJson() => const {'tipo': 'hasta_proximo'};
}
class AlcanceHasta extends Alcance {
final DateTime fecha;
const AlcanceHasta(this.fecha);
@override
Map<String, dynamic> toJson() {
final f = '${fecha.year}-${fecha.month.toString().padLeft(2, '0')}-'
'${fecha.day.toString().padLeft(2, '0')}';
return {'tipo': 'hasta', 'fecha': f};
}
}
@@ -0,0 +1,63 @@
class BloqueActividadEspecial {
final int id;
final String horaInicio;
final String horaFin;
final int actividadId;
final String actividadNombre;
final int actividadDuracion;
const BloqueActividadEspecial({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividadId,
required this.actividadNombre,
required this.actividadDuracion,
});
factory BloqueActividadEspecial.fromMap(Map<String, dynamic> m) {
final act = m['actividad'] as Map<String, dynamic>;
return BloqueActividadEspecial(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividadId: act['id'] as int,
actividadNombre: act['nombre'] as String,
actividadDuracion: act['duracion'] as int,
);
}
}
class DiaEspecialResumen {
final DateTime fecha;
final String tipo; // 'cerrado' | 'horario_diferente'
final String? motivo;
final List<BloqueActividadEspecial> rangos;
const DiaEspecialResumen({
required this.fecha,
required this.tipo,
this.motivo,
required this.rangos,
});
bool get esCerrado => tipo == 'cerrado';
/// Construye un resumen a partir del item de día devuelto por
/// `fc_obtener_horarios` (donde la fecha viene como clave del objeto raíz y
/// el valor trae `tipo`, `motivo`, `horarios`).
factory DiaEspecialResumen.fromHorariosResponse(
DateTime fecha,
Map<String, dynamic> m,
) {
final rawHorarios = m['horarios'] as List<dynamic>? ?? [];
return DiaEspecialResumen(
fecha: fecha,
tipo: m['tipo'] as String,
motivo: m['motivo'] as String?,
rangos: rawHorarios
.map((e) => BloqueActividadEspecial.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,127 @@
import 'package:supabase_flutter/supabase_flutter.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _fmt(DateTime d) => '${d.day} ${_mesesCortos[d.month]} ${d.year}';
/// Errores tipados que el módulo de horarios produce al consumir la API de
/// backend. El traductor [fromException] mapea mensajes conocidos de las
/// funciones PL/pgSQL a una variante específica; mensajes no reconocidos
/// caen en [Desconocido] conservando el texto original.
sealed class HorarioError {
const HorarioError();
/// Texto en español listo para mostrar al usuario. Distinto del raw del
/// backend: explica el problema y, donde aplica, sugiere la acción.
String mensajeUsuario();
static final RegExp _conflictoRegex = RegExp(
r'Conflicto: existe una planificación con valido_desde = '
r'(\d{4}-\d{2}-\d{2}) dentro del intervalo '
r'\[(\d{4}-\d{2}-\d{2}), (\d{4}-\d{2}-\d{2})\]',
);
static final RegExp _alcanceFechaRegex = RegExp(
r'alcance\.fecha \((\d{4}-\d{2}-\d{2})\) no puede ser anterior a '
r'valido_desde \((\d{4}-\d{2}-\d{2})\)',
);
factory HorarioError.fromException(Object e) {
final raw = e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', '');
final mConflicto = _conflictoRegex.firstMatch(raw);
if (mConflicto != null) {
final vd = DateTime.tryParse(mConflicto.group(1)!);
final id = DateTime.tryParse(mConflicto.group(2)!);
final ih = DateTime.tryParse(mConflicto.group(3)!);
if (vd != null && id != null && ih != null) {
return ConflictoAlcance(
validoDesdeConflicto: vd,
intervaloDesde: id,
intervaloHasta: ih,
);
}
}
final mAlcance = _alcanceFechaRegex.firstMatch(raw);
if (mAlcance != null) {
final af = DateTime.tryParse(mAlcance.group(1)!);
final vd = DateTime.tryParse(mAlcance.group(2)!);
if (af != null && vd != null) {
return AlcanceFechaAnterior(alcanceFecha: af, validoDesde: vd);
}
}
if (raw.contains('valido_desde no puede ser una fecha pasada')) {
return const FechaPasada();
}
if (raw.contains('No se pueden alterar horarios en fechas pasadas')) {
return const FechaPasadaEspecial();
}
return Desconocido(raw);
}
}
class ConflictoAlcance extends HorarioError {
final DateTime validoDesdeConflicto;
final DateTime intervaloDesde;
final DateTime intervaloHasta;
const ConflictoAlcance({
required this.validoDesdeConflicto,
required this.intervaloDesde,
required this.intervaloHasta,
});
@override
String mensajeUsuario() =>
'Ya hay un horario planificado para el ${_fmt(validoDesdeConflicto)}, '
'que cae dentro del rango elegido (${_fmt(intervaloDesde)}'
'${_fmt(intervaloHasta)}). Cambiá el alcance o eliminá esa '
'planificación antes de continuar.';
}
class FechaPasada extends HorarioError {
const FechaPasada();
@override
String mensajeUsuario() =>
'La fecha de vigencia no puede ser anterior a hoy.';
}
class AlcanceFechaAnterior extends HorarioError {
final DateTime alcanceFecha;
final DateTime validoDesde;
const AlcanceFechaAnterior({
required this.alcanceFecha,
required this.validoDesde,
});
@override
String mensajeUsuario() =>
'La fecha de fin (${_fmt(alcanceFecha)}) no puede ser anterior al '
'inicio de vigencia (${_fmt(validoDesde)}).';
}
class FechaPasadaEspecial extends HorarioError {
const FechaPasadaEspecial();
@override
String mensajeUsuario() =>
'No se pueden modificar horarios en fechas pasadas.';
}
class Desconocido extends HorarioError {
final String raw;
const Desconocido(this.raw);
@override
String mensajeUsuario() => raw;
}
@@ -0,0 +1,131 @@
class BloqueActividadInfo {
final int id;
final String nombre;
final int duracion;
final int capacidad;
const BloqueActividadInfo({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidad,
});
factory BloqueActividadInfo.fromMap(Map<String, dynamic> m) {
return BloqueActividadInfo(
id: m['id'] as int,
nombre: m['nombre'] as String,
duracion: m['duracion'] as int,
capacidad: m['capacidad'] as int,
);
}
}
class BloqueHorario {
final int id;
final String horaInicio;
final String horaFin;
final BloqueActividadInfo actividad;
const BloqueHorario({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.actividad,
});
factory BloqueHorario.fromMap(Map<String, dynamic> m) {
return BloqueHorario(
id: m['id'] as int,
horaInicio: m['hora_inicio'] as String,
horaFin: m['hora_fin'] as String,
actividad:
BloqueActividadInfo.fromMap(m['actividad'] as Map<String, dynamic>),
);
}
}
enum TipoDia { normal, horarioDiferente, cerrado }
class DiaHorarios {
final DateTime fecha;
final int diaSemana;
final TipoDia tipo;
final String? motivo;
final List<BloqueHorario> bloques;
/// Solo presente para días `normal`: cuándo entró a regir la plantilla.
final DateTime? validoDesde;
/// Solo presente para días `normal`: cuándo deja de regir la plantilla
/// (`null` = vigencia indefinida).
final DateTime? validoHasta;
const DiaHorarios({
required this.fecha,
required this.diaSemana,
required this.tipo,
this.motivo,
required this.bloques,
this.validoDesde,
this.validoHasta,
});
bool get esCerrado => tipo == TipoDia.cerrado;
bool get esEspecial => tipo != TipoDia.normal;
factory DiaHorarios.fromMap(DateTime fecha, Map<String, dynamic> m) {
final tipoStr = m['tipo'] as String;
final tipo = switch (tipoStr) {
'cerrado' => TipoDia.cerrado,
'horario_diferente' => TipoDia.horarioDiferente,
_ => TipoDia.normal,
};
final rawBloques = m['horarios'] as List<dynamic>? ?? [];
final validoDesdeStr = m['valido_desde'] as String?;
final validoHastaStr = m['valido_hasta'] as String?;
return DiaHorarios(
fecha: fecha,
diaSemana: m['dia_semana'] as int,
tipo: tipo,
motivo: m['motivo'] as String?,
bloques: rawBloques
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
validoDesde:
validoDesdeStr != null ? DateTime.tryParse(validoDesdeStr) : null,
validoHasta:
validoHastaStr != null ? DateTime.tryParse(validoHastaStr) : null,
);
}
}
class SemanaHorarios {
final DateTime weekStart;
final Map<String, DiaHorarios> _byKey;
SemanaHorarios({required this.weekStart, required Map<String, DiaHorarios> byKey})
: _byKey = byKey;
factory SemanaHorarios.fromResponse(DateTime weekStart, Map<String, dynamic> raw) {
final byKey = <String, DiaHorarios>{};
for (final entry in raw.entries) {
final fecha = DateTime.tryParse(entry.key);
if (fecha == null) continue;
final diaMap = entry.value;
if (diaMap is Map<String, dynamic>) {
byKey[entry.key] = DiaHorarios.fromMap(fecha, diaMap);
}
}
return SemanaHorarios(weekStart: weekStart, byKey: byKey);
}
String _key(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
DiaHorarios? diaPara(DateTime fecha) => _byKey[_key(fecha)];
List<DiaHorarios> get dias => _byKey.values.toList()
..sort((a, b) => a.fecha.compareTo(b.fecha));
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_key(fecha));
}
@@ -0,0 +1,27 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
/// Plantilla regular futura para un día de la semana, tal como la devuelve
/// `fc_obtener_planificaciones_futuras`. Sólo se incluyen plantillas cuyo
/// `valido_desde` es estrictamente posterior al `p_desde` consultado.
class PlanificacionFutura {
final DateTime validoDesde;
final DateTime? validoHasta;
final List<BloqueHorario> rangos;
const PlanificacionFutura({
required this.validoDesde,
this.validoHasta,
required this.rangos,
});
factory PlanificacionFutura.fromMap(Map<String, dynamic> m) {
final vh = m['valido_hasta'] as String?;
return PlanificacionFutura(
validoDesde: DateTime.parse(m['valido_desde'] as String),
validoHasta: vh != null ? DateTime.tryParse(vh) : null,
rangos: (m['rangos'] as List<dynamic>? ?? [])
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -0,0 +1,48 @@
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
abstract class HorariosRepository {
/// Obtener horarios de una semana (7 días desde [weekStart]).
Future<SemanaHorarios> obtenerSemana(DateTime weekStart);
/// Guardar horario para un día (PUT: reemplaza todos los bloques).
/// [esEspecial]=false → SCD upsert en horario regular.
/// [esEspecial]=true + bloques vacíos → marca el día como cerrado.
/// [esEspecial]=true + bloques → crea horario_diferente.
///
/// [validoDesde] y [alcance] **sólo aplican a día regular**; el backend
/// los ignora cuando [esEspecial] es true. Si vienen `null`, no se mandan
/// y el backend usa sus defaults (`valido_desde = hoy`,
/// `alcance = hasta_proximo`).
Future<void> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
});
/// Eliminar día especial (restaura el horario normal para esa fecha).
Future<void> eliminarDiaEspecial(DateTime fecha);
/// Listar los días especiales programados en una ventana hacia adelante.
/// [dias] = cantidad de días a inspeccionar desde hoy (default 90).
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90});
/// Plantillas regulares con `valido_desde` estrictamente posterior a [desde]
/// para el [diaSemana] dado (1=lunes ... 7=domingo, ISODOW). Ventana de
/// inspección controlada por [meses] (default 6, máx 24).
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
required int diaSemana,
required DateTime desde,
int meses = 6,
});
/// Cuenta las reservas huérfanas generadas desde [instante] (UTC).
/// Usado para informar al admin cuántas reservas quedaron sin turno
/// tras una operación de escritura en horarios.
Future<int> contarHuerfanasDesde(DateTime instante);
}
@@ -0,0 +1,177 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/horarios/data/repositories/horarios_repository_impl.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/repositories/horarios_repository.dart';
final horariosRepositoryProvider = Provider<HorariosRepository>((ref) {
return HorariosRepositoryImpl();
});
/// Semana actual de horarios.
final horariosProvider =
StateNotifierProvider<HorariosNotifier, AsyncValue<SemanaHorarios?>>((ref) {
return HorariosNotifier(ref, ref.read(horariosRepositoryProvider));
});
class HorariosNotifier extends StateNotifier<AsyncValue<SemanaHorarios?>> {
final Ref _ref;
final HorariosRepository _repository;
DateTime? _currentWeekStart;
HorariosNotifier(this._ref, this._repository)
: super(const AsyncValue.data(null));
/// Refresca las vistas que dependen de los mismos datos que la semana pero
/// que viven en otros providers: la lista de días especiales (puntos
/// naranjas del calendario + pestaña "Especiales") y el mapa de cambios de
/// plantilla a futuro (puntos azules). Se llama tras cada escritura para que
/// ninguna vista quede desincronizada.
///
/// Usa `invalidate` y no `load()` a propósito: si se hacen varias escrituras
/// seguidas —p. ej. copiar un día a varios destinos— las invalidaciones se
/// fusionan en una sola recarga por vista en vez de una por escritura.
void _refrescarDerivados() {
_ref.invalidate(diasEspecialesProvider);
_ref.invalidate(diasCambioProvider);
}
Future<void> cargarSemana(DateTime weekStart, {bool force = false}) async {
final monday = _toMonday(weekStart);
if (!force && _currentWeekStart != null && _currentWeekStart == monday) {
return;
}
_currentWeekStart = monday;
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerSemana(monday);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> refrescar() async {
if (_currentWeekStart == null) return;
await cargarSemana(_currentWeekStart!, force: true);
}
/// Guarda el horario de un día y refresca la semana.
///
/// Retorna `(null, N)` si tuvo éxito, donde N es la cantidad de reservas
/// que quedaron huérfanas a raíz de la operación (0 = ninguna).
/// Retorna `(HorarioError, 0)` si hubo error.
///
/// [validoDesde] y [alcance] sólo aplican a día regular; el backend los
/// ignora cuando [esEspecial] es true. Si vienen null se usan los defaults
/// del backend (hoy, `hasta_proximo`).
Future<(HorarioError?, int)> guardarDia({
required DateTime fecha,
required bool esEspecial,
String? motivo,
required List<Map<String, dynamic>> bloques,
DateTime? validoDesde,
Alcance? alcance,
}) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.guardarDia(
fecha: fecha,
esEspecial: esEspecial,
motivo: motivo,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
/// Elimina la excepción de un día especial y refresca.
///
/// Retorna `(null, N)` si tuvo éxito (N = huérfanas generadas),
/// o `(HorarioError, 0)` si hubo error.
Future<(HorarioError?, int)> eliminarDiaEspecial(DateTime fecha) async {
final preOp = DateTime.now().toUtc();
try {
await _repository.eliminarDiaEspecial(fecha);
await refrescar();
_refrescarDerivados();
final n = await _repository.contarHuerfanasDesde(preOp);
return (null, n);
} catch (e) {
return (HorarioError.fromException(e), 0);
}
}
DateTime _toMonday(DateTime d) =>
d.subtract(Duration(days: d.weekday - 1));
}
/// Lista de días especiales para la vista auxiliar.
final diasEspecialesProvider =
StateNotifierProvider<DiasEspecialesNotifier, AsyncValue<List<DiaEspecialResumen>>>(
(ref) {
return DiasEspecialesNotifier(ref.read(horariosRepositoryProvider));
});
class DiasEspecialesNotifier
extends StateNotifier<AsyncValue<List<DiaEspecialResumen>>> {
final HorariosRepository _repository;
DiasEspecialesNotifier(this._repository) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.listarDiasEspeciales();
data.sort((a, b) => a.fecha.compareTo(b.fecha));
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
/// Días (normalizados a medianoche) en los que arranca una nueva plantilla
/// regular dentro de los próximos 12 meses. Alimenta los puntos azules del
/// calendario.
///
/// Es un [FutureProvider] para que el panel lo lea de forma perezosa (sólo al
/// abrirse) y para que [HorariosNotifier] pueda invalidarlo tras cada
/// escritura, manteniéndolo en sync con el resto de las vistas.
final diasCambioProvider = FutureProvider<Set<DateTime>>((ref) async {
final repo = ref.watch(horariosRepositoryProvider);
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final results = await Future.wait(
List.generate(
7,
(i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: hoy,
meses: 12,
),
),
);
final cambios = <DateTime>{};
for (final list in results) {
for (final p in list) {
cambios.add(DateTime(
p.validoDesde.year,
p.validoDesde.month,
p.validoDesde.day,
));
}
}
return cambios;
});
@@ -0,0 +1,597 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dias_especiales_view.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/copiar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horarios_calendar_panel.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/semana_tabla_view.dart';
enum _HorariosTab { semanal, especiales }
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class HorariosScreen extends ConsumerStatefulWidget {
const HorariosScreen({super.key});
@override
ConsumerState<HorariosScreen> createState() => _HorariosScreenState();
}
class _HorariosScreenState extends ConsumerState<HorariosScreen> {
_HorariosTab _currentTab = _HorariosTab.semanal;
late DateTime _weekStart;
late DateTime _selectedDay;
List<int> _diasVisibles = [0, 1, 2, 3, 4];
@override
void initState() {
super.initState();
final today = DateTime.now();
_weekStart = _toMonday(today);
_selectedDay = today;
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
}
DateTime _toMonday(DateTime d) => d.subtract(Duration(days: d.weekday - 1));
String _fmtShort(DateTime d) => '${d.day} ${_meses[d.month]}';
String _weekLabel() {
final end = _weekStart.add(const Duration(days: 6));
return '${_fmtShort(_weekStart)} ${_fmtShort(end)} ${end.year}';
}
void _cargarSemana() {
ref.read(horariosProvider.notifier).cargarSemana(_weekStart);
}
void _prevWeek() {
setState(() {
_weekStart = _weekStart.subtract(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
void _nextWeek() {
setState(() {
_weekStart = _weekStart.add(const Duration(days: 7));
_selectedDay = _weekStart;
});
_cargarSemana();
}
bool get _isAdmin {
final user = ref.read(authStateProvider).valueOrNull;
return user != null && user.isStaff;
}
Future<void> _editarDia(DateTime fecha, DiaHorarios? dia) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: dia,
fecha: fecha,
weekStart: _weekStart,
),
);
if (!mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(n);
}
void _showHuerfanasToast(int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _eliminarBloque(
DiaHorarios dia, BloqueHorario bloque) async {
final seen = <String>{};
final restantes = <Map<String, dynamic>>[];
for (final b in dia.bloques) {
if (b.id == bloque.id) continue;
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
restantes.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
final (error, huerfanas) = await ref.read(horariosProvider.notifier).guardarDia(
fecha: dia.fecha,
esEspecial: dia.esEspecial,
motivo: dia.motivo,
bloques: restantes,
);
if (!mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else if (huerfanas > 0) {
_showHuerfanasToast(huerfanas);
}
}
void _navigateToWeek(DateTime fecha) {
setState(() {
_weekStart = _toMonday(fecha);
_selectedDay = fecha;
_currentTab = _HorariosTab.semanal;
});
_cargarSemana();
}
Future<void> _copiarDia(DiaHorarios origen, SemanaHorarios semana) async {
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => CopiarDiaDialog(
origen: origen,
weekStart: _weekStart,
semana: semana,
),
);
if (!mounted) return;
if ((huerfanas ?? 0) > 0) _showHuerfanasToast(huerfanas!);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 800;
final isAdmin = _isAdmin;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Horarios',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
SomaHeaderHelp(
items: [
if (isAdmin)
const SomaHelpItem(
icon: Icons.calendar_view_week_outlined,
text: 'Semanal / Especiales: cambiá entre la grilla '
'semanal y los días especiales (feriados, eventos).',
),
const SomaHelpItem(
icon: Icons.chevron_left,
text: 'Las flechas navegan entre semanas.',
),
const SomaHelpItem(
icon: Icons.tune,
text: 'Días visibles: elegí qué días de la semana se '
'muestran en la tabla.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.edit_outlined,
text: 'Editar día: modificá los horarios y '
'actividades del día seleccionado.',
),
if (isAdmin)
const SomaHelpItem(
icon: Icons.copy_outlined,
text: 'Copiar a...: replica los bloques del día '
'seleccionado a otros días.',
),
const SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga los horarios de la semana actual.',
),
],
),
const Spacer(),
if (isAdmin) ...[
_ViewToggle(
currentTab: _currentTab,
onChanged: (tab) => setState(() => _currentTab = tab),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () {
if (_currentTab == _HorariosTab.semanal) {
ref.read(horariosProvider.notifier).refrescar();
} else {
ref.read(diasEspecialesProvider.notifier).load();
}
},
),
],
),
),
// Content
Expanded(
child: Stack(
children: [
_currentTab == _HorariosTab.especiales && isAdmin
? const DiasEspecialesView()
: _SemanalView(
weekStart: _weekStart,
selectedDay: _selectedDay,
isAdmin: isAdmin,
isWide: isWide,
weekLabel: _weekLabel(),
onPrevWeek: _prevWeek,
onNextWeek: _nextWeek,
onDaySelected: (d) => setState(() => _selectedDay = d),
onEditarDia: _editarDia,
onCopiarDia: _copiarDia,
onEliminarBloque: isAdmin ? _eliminarBloque : null,
diasVisibles: _diasVisibles,
onDiasVisiblesChanged: (v) =>
setState(() => _diasVisibles = v),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: HorariosCalendarPanel(
onNavigateToWeek: _navigateToWeek,
),
),
],
),
),
],
),
);
}
}
// ── View Toggle ────────────────────────────────────────────────────────────────
class _ViewToggle extends StatelessWidget {
final _HorariosTab currentTab;
final ValueChanged<_HorariosTab> onChanged;
const _ViewToggle({required this.currentTab, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.inputDecorationTheme.fillColor,
),
padding: const EdgeInsets.all(2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToggleItem(
label: 'Semanal',
selected: currentTab == _HorariosTab.semanal,
onTap: () => onChanged(_HorariosTab.semanal),
),
_ToggleItem(
label: 'Especiales',
selected: currentTab == _HorariosTab.especiales,
onTap: () => onChanged(_HorariosTab.especiales),
),
],
),
);
}
}
class _ToggleItem extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ToggleItem({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: selected ? theme.colorScheme.surface : Colors.transparent,
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withAlpha(15),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
);
}
}
// ── Vista Semanal ──────────────────────────────────────────────────────────────
class _SemanalView extends ConsumerWidget {
final DateTime weekStart;
final DateTime selectedDay;
final bool isAdmin;
final bool isWide;
final String weekLabel;
final VoidCallback onPrevWeek;
final VoidCallback onNextWeek;
final ValueChanged<DateTime> onDaySelected;
final Future<void> Function(DateTime, DiaHorarios?) onEditarDia;
final Future<void> Function(DiaHorarios, SemanaHorarios) onCopiarDia;
final Future<void> Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
final List<int> diasVisibles;
final ValueChanged<List<int>> onDiasVisiblesChanged;
const _SemanalView({
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.isWide,
required this.weekLabel,
required this.onPrevWeek,
required this.onNextWeek,
required this.onDaySelected,
required this.onEditarDia,
required this.onCopiarDia,
this.onEliminarBloque,
required this.diasVisibles,
required this.onDiasVisiblesChanged,
});
void _openDiasConfig(BuildContext context) {
var local = List<int>.from(diasVisibles);
showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('Días visibles'),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: SizedBox(
width: 260,
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final checked = local.contains(i);
return CheckboxListTile(
title: Text(_diasSemana[i]),
value: checked,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
onChanged: (local.length == 1 && checked)
? null
: (val) {
setLocal(() {
if (val == true) {
local = ([...local, i])..sort();
} else {
local =
local.where((d) => d != i).toList();
}
});
onDiasVisiblesChanged(local);
},
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Listo'),
),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(horariosProvider);
final theme = Theme.of(context);
final hPad = isWide ? 32.0 : 16.0;
return Column(
children: [
// Week navigation
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevWeek,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Text(
weekLabel,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNextWeek,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, size: 20),
tooltip: 'Días visibles',
visualDensity: VisualDensity.compact,
onPressed: () => _openDiasConfig(context),
),
],
),
),
// Tabla semanal
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(horariosProvider.notifier).refrescar(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (semana) {
if (semana == null) {
return const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
);
}
final dia = semana.diaPara(selectedDay);
return Column(
children: [
Expanded(
child: SemanaTablaView(
semana: semana,
diasVisibles: diasVisibles,
weekStart: weekStart,
selectedDay: selectedDay,
isAdmin: isAdmin,
onSelectDia: onDaySelected,
onEditarDia: (fecha, dia) {
onDaySelected(fecha);
onEditarDia(fecha, dia);
},
onCopiarDia: isAdmin
? (d) => onCopiarDia(d, semana)
: null,
onEliminarBloque: onEliminarBloque,
),
),
if (isAdmin)
Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => onEditarDia(selectedDay, dia),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar día'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: SomaColors.primary.withAlpha(120)),
),
),
),
if (dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => onCopiarDia(dia, semana),
icon: const Icon(Icons.copy_outlined, size: 18),
label: const Text('Copiar a...'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
side: BorderSide(
color: theme.colorScheme.onSurface
.withAlpha(60)),
foregroundColor:
theme.colorScheme.onSurface.withAlpha(160),
),
),
],
],
),
),
],
);
},
),
),
],
);
}
}
@@ -0,0 +1,358 @@
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/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
class AgregarBloqueDialog extends ConsumerStatefulWidget {
const AgregarBloqueDialog({super.key});
@override
ConsumerState<AgregarBloqueDialog> createState() =>
_AgregarBloqueDialogState();
}
class _AgregarBloqueDialogState extends ConsumerState<AgregarBloqueDialog> {
Actividad? _actividad;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int get _duracion => _actividad?.duracion ?? 0;
int get _totalMinutos =>
_timeToMinutes(_horaFin) - _timeToMinutes(_horaInicio);
int get _sobrante => _duracion > 0 ? _totalMinutos % _duracion : 0;
bool get _esFaltante =>
_duracion > 0 && _totalMinutos > 0 && _totalMinutos < _duracion;
bool get _haySobrante =>
_duracion > 0 && _totalMinutos > 0 && !_esFaltante && _sobrante > 0;
TimeOfDay get _horaFinEfectiva {
if (!_haySobrante) return _horaFin;
final mins = _timeToMinutes(_horaFin) - _sobrante;
return TimeOfDay(hour: mins ~/ 60, minute: mins % 60);
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
);
},
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
// Auto-ajustar hora fin si es menor
if (_timeToMinutes(picked) >= _timeToMinutes(_horaFin)) {
_horaFin = TimeOfDay(
hour: (picked.hour + 1) % 24,
minute: picked.minute,
);
}
} else {
_horaFin = picked;
}
});
}
int _timeToMinutes(TimeOfDay t) => t.hour * 60 + t.minute;
String _formatTime(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
void _submit() {
if (_actividad == null) return;
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin)) return;
if (_esFaltante) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividad!.id,
'_nombre': _actividad!.nombre,
'hora_inicio': _formatTime(_horaInicio),
'hora_fin': _formatTime(_horaFinEfectiva),
});
}
@override
Widget build(BuildContext context) {
final actividadesAsync = ref.watch(actividadesProvider);
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 420) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text(
'Agregar bloque',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad dropdown
actividadesAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (e, _) => Text(
'Error cargando actividades',
style: TextStyle(color: SomaColors.error, fontSize: 13),
),
data: (actividades) {
final activas =
actividades.where((a) => a.activo).toList();
return DropdownButtonFormField<int>(
initialValue: _actividad?.id,
items: activas
.map((a) => DropdownMenuItem(
value: a.id,
child: Text(a.nombre),
))
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() => _actividad =
activas.firstWhere((a) => a.id == v));
},
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
validator: (v) =>
v == null ? 'Seleccioná una actividad' : null,
);
},
),
const SizedBox(height: 20),
// Hora inicio / fin
Row(
children: [
Expanded(
child: _TimePickerField(
label: 'Desde',
value: _formatTime(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100)),
),
Expanded(
child: _TimePickerField(
label: 'Hasta',
value: _formatTime(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_timeToMinutes(_horaInicio) >= _timeToMinutes(_horaFin))
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message: 'La hora de fin debe ser mayor a la de inicio',
)
else if (_esFaltante)
_BloqueWarning(
icon: Icons.error_outline,
color: SomaColors.error,
message:
'El rango (${_totalMinutos}min) es menor a la duración mínima de ${_actividad!.nombre} (${_duracion}min). No se puede insertar.',
)
else if (_haySobrante)
_BloqueWarning(
icon: Icons.info_outline,
color: Colors.amber.shade700,
message:
'Se recortarán ${_sobrante}min — se insertará hasta ${_formatTime(_horaFinEfectiva)}.',
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _actividad != null &&
_timeToMinutes(_horaInicio) <
_timeToMinutes(_horaFin) &&
!_esFaltante
? _submit
: null,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _BloqueWarning extends StatelessWidget {
final IconData icon;
final Color color;
final String message;
const _BloqueWarning({
required this.icon,
required this.color,
required this.message,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
style: TextStyle(color: color, fontSize: 12),
),
),
],
),
);
}
}
class _TimePickerField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimePickerField({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.inputDecorationTheme.fillColor,
),
child: Row(
children: [
Icon(
Icons.schedule,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(130),
),
const SizedBox(width: 10),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _fmt(DateTime d) =>
'${d.day} ${_mesesCortos[d.month]} ${d.year}';
/// Selector tipado para el campo `alcance` del upsert regular.
///
/// Sólo tiene sentido cuando hay [futuros]; el caller debe encargarse de
/// ocultarlo cuando la lista está vacía. La fecha límite válida para el
/// caso `hasta` se calcula a partir del primer elemento de [futuros] menos
/// un día.
class AlcanceSelector extends StatelessWidget {
final List<PlanificacionFutura> futuros;
final DateTime validoDesde;
final Alcance alcance;
final ValueChanged<Alcance> onChanged;
const AlcanceSelector({
super.key,
required this.futuros,
required this.validoDesde,
required this.alcance,
required this.onChanged,
}) : assert(futuros.length > 0,
'AlcanceSelector debe recibir al menos una planificación futura');
DateTime get _proximo => futuros.first.validoDesde;
DateTime get _lastDateHasta => _proximo.subtract(const Duration(days: 1));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hayMultiples = futuros.length > 1;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: SomaColors.primary.withAlpha(10),
border: Border.all(
color: SomaColors.primary.withAlpha(60),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 16, color: SomaColors.primaryText),
const SizedBox(width: 6),
Expanded(
child: Text(
hayMultiples
? 'Hay ${futuros.length} horarios planificados a futuro (próximo: ${_fmt(_proximo)})'
: 'Hay un horario planificado a partir del ${_fmt(_proximo)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'¿Cómo interactúa este cambio con lo ya planificado?',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 10),
_AlcanceOption(
label: 'Respetar planificación futura',
sublabel:
'El nuevo horario regirá hasta el ${_fmt(_lastDateHasta)}. '
'Desde el ${_fmt(_proximo)} se mantiene lo ya planificado.',
selected: alcance is AlcanceHastaProximo,
onTap: () => onChanged(const AlcanceHastaProximo()),
),
const SizedBox(height: 6),
_AlcanceOption(
label: hayMultiples
? 'Sobrescribir todas las planificaciones futuras'
: 'Sobrescribir y eliminar la planificación futura',
sublabel: hayMultiples
? 'Se eliminarán las ${futuros.length} planificaciones futuras para este día. El nuevo horario regirá sin fecha de fin.'
: 'Se eliminará el horario planificado para el ${_fmt(_proximo)}. El nuevo horario regirá sin fecha de fin.',
selected: alcance is AlcanceIndefinido,
destructive: true,
onTap: () => onChanged(const AlcanceIndefinido()),
),
],
),
);
}
}
class _AlcanceOption extends StatelessWidget {
final String label;
final String sublabel;
final bool selected;
final bool destructive;
final VoidCallback onTap;
const _AlcanceOption({
required this.label,
required this.sublabel,
required this.selected,
required this.onTap,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = destructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : theme.colorScheme.surface,
border: Border.all(
color: selected
? color.withAlpha(110)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 16,
color: selected
? color
: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected
? (destructive
? SomaColors.error
: SomaColors.primaryText)
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
sublabel,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,621 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
/// Diálogo para copiar los bloques de un día regular a uno o más días de la
/// semana. Expone controles de vigencia (valido_desde + alcance) y llama
/// al upsert RPC una vez por destino seleccionado.
///
/// Retorna `int` (total de huérfanas generadas) o `null` si se canceló.
class CopiarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios origen;
final DateTime weekStart;
final SemanaHorarios semana;
const CopiarDiaDialog({
super.key,
required this.origen,
required this.weekStart,
required this.semana,
});
@override
ConsumerState<CopiarDiaDialog> createState() => _CopiarDiaDialogState();
}
class _CopiarDiaDialogState extends ConsumerState<CopiarDiaDialog> {
final Set<int> _destinos = {};
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
bool _saving = false;
bool _futurosLoading = false;
List<PlanificacionFutura>? _futurosCombinados;
Map<int, HorarioError>? _errores;
int get _origenIdx => widget.origen.diaSemana - 1; // ISODOW 1-based → 0-based
@override
void initState() {
super.initState();
final now = DateTime.now();
_validoDesde = DateTime(now.year, now.month, now.day);
}
Future<void> _cargarFuturos() async {
if (_destinos.isEmpty) {
setState(() {
_futurosCombinados = [];
_futurosLoading = false;
});
return;
}
setState(() {
_futurosLoading = true;
_futurosCombinados = null;
});
try {
final repo = ref.read(horariosRepositoryProvider);
final lists = await Future.wait(
_destinos.map((i) => repo.futurosParaDiaSemana(
diaSemana: i + 1,
desde: _validoDesde,
)),
);
if (!mounted) return;
final todos = lists.expand((l) => l).toList();
todos.sort((a, b) => a.validoDesde.compareTo(b.validoDesde));
setState(() {
_futurosCombinados = todos;
_futurosLoading = false;
if (todos.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is! AlcanceIndefinido) {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_futurosCombinados = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
setState(() {
_validoDesde = nuevo.isBefore(hoy) ? hoy : nuevo;
_futurosCombinados = null;
});
await _cargarFuturos();
}
void _toggleDestino(int i) {
setState(() {
if (_destinos.contains(i)) {
_destinos.remove(i);
} else {
_destinos.add(i);
}
_futurosCombinados = null;
_errores = null;
});
_cargarFuturos();
}
List<Map<String, dynamic>> get _bloquesCopia {
final seen = <String>{};
final result = <Map<String, dynamic>>[];
for (final b in widget.origen.bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
result.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
});
}
return result;
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_errores = null;
});
final bloques = _bloquesCopia;
final mandarMetadata = _futurosCombinados != null;
final validoDesde = mandarMetadata ? _validoDesde : null;
final alcance = mandarMetadata ? _alcance : null;
final destinosSorted = _destinos.toList()..sort();
final errores = <int, HorarioError>{};
int totalHuerfanas = 0;
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
for (final i in destinosSorted) {
// El RPC solo usa la fecha para derivar el ISODOW en días regulares.
// Si la fecha de esta semana ya pasó, avanzamos 7 días para obtener
// el mismo weekday la semana siguiente y evitar el rechazo del backend.
DateTime fecha = widget.weekStart.add(Duration(days: i));
if (fecha.isBefore(hoy)) fecha = fecha.add(const Duration(days: 7));
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: fecha,
esEspecial: false,
bloques: bloques,
validoDesde: validoDesde,
alcance: alcance,
);
if (error != null) {
errores[i] = error;
if (error is ConflictoAlcance) {
if (!mounted) return;
setState(() {
_saving = false;
_errores = errores;
_alcance = const AlcanceHastaProximo();
_futurosCombinados = null;
});
_cargarFuturos();
return;
}
} else {
totalHuerfanas += huerfanas;
}
}
if (!mounted) return;
setState(() => _saving = false);
if (errores.isEmpty) {
final n = destinosSorted.length;
SomaToast.show(
context,
message: n == 1
? 'Horario copiado a ${_diasSemana[destinosSorted.first]}'
: 'Horario copiado a $n días',
type: ToastType.success,
);
Navigator.of(context).pop(totalHuerfanas);
} else {
setState(() => _errores = errores);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final origenLabel = _diasSemana[_origenIdx];
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 620),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Copiar $origenLabel a...',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Day chips
Text(
'Días destino',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_DestinosChips(
origenIdx: _origenIdx,
destinos: _destinos,
onToggle: _toggleDestino,
),
// Sub-labels per selected destination
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 10),
for (final i in _destinos.toList()..sort())
_DestinoInfo(
label: _diasSemana[i],
diaActual: widget.semana.diaPara(
widget.weekStart.add(Duration(days: i))),
),
],
// Bloques preview
const SizedBox(height: 20),
Text(
'Actividades a copiar',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 8),
_BloquesPreview(bloques: widget.origen.bloques),
// Vigencia (only when destinations are selected)
if (_destinos.isNotEmpty) ...[
const SizedBox(height: 20),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: null,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futurosCombinados == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: el horario '
'copiado regirá de manera indefinida.',
style: TextStyle(
fontSize: 11,
color:
theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futurosCombinados != null &&
_futurosCombinados!.isNotEmpty)
AlcanceSelector(
futuros: _futurosCombinados!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Error panel
if (_errores != null && _errores!.isNotEmpty) ...[
const SizedBox(height: 12),
for (final entry in _errores!.entries)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${_diasSemana[entry.key]}: ${entry.value.mensajeUsuario()}',
style: const TextStyle(
color: SomaColors.error, fontSize: 12),
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed:
_destinos.isEmpty || _saving || _futurosLoading
? null
: _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: Text(
_destinos.isEmpty
? 'Copiar'
: 'Copiar a ${_destinos.length} '
'${_destinos.length == 1 ? 'día' : 'días'}',
),
),
],
),
),
],
),
),
);
}
}
// ── Chips de destino (multi-select) ───────────────────────────────────────────
class _DestinosChips extends StatelessWidget {
final int origenIdx;
final Set<int> destinos;
final ValueChanged<int> onToggle;
const _DestinosChips({
required this.origenIdx,
required this.destinos,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(7, (i) {
final isOrigen = i == origenIdx;
final isSelected = destinos.contains(i);
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < 6 ? 4 : 0),
child: InkWell(
onTap: isOrigen ? null : () => onToggle(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isOrigen
? theme.colorScheme.surfaceContainerHighest.withAlpha(30)
: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest
.withAlpha(60),
border: Border.all(
color: isOrigen
? theme.colorScheme.surfaceContainerHighest
.withAlpha(60)
: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isOrigen
? theme.colorScheme.onSurface.withAlpha(60)
: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Info por destino seleccionado ─────────────────────────────────────────────
class _DestinoInfo extends StatelessWidget {
final String label;
final DiaHorarios? diaActual;
const _DestinoInfo({required this.label, required this.diaActual});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bloques = diaActual?.bloques ?? [];
final tieneContenido = bloques.isNotEmpty;
final String desc;
final Color color;
if (tieneContenido) {
final n = bloques.length;
desc = '$n ${n == 1 ? 'actividad' : 'actividades'} — se reemplazarán';
color = Colors.orange;
} else {
desc = 'vacío';
color = theme.colorScheme.onSurface.withAlpha(100);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.arrow_forward, size: 12,
color: SomaColors.primary.withAlpha(160)),
const SizedBox(width: 6),
Text(
'$label: ',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
Expanded(
child: Text(
desc,
style: TextStyle(fontSize: 12, color: color),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
// ── Preview de bloques (read-only) ────────────────────────────────────────────
class _BloquesPreview extends StatelessWidget {
final List<BloqueHorario> bloques;
const _BloquesPreview({required this.bloques});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (bloques.isEmpty) {
return Text(
'Sin actividades',
style: TextStyle(
fontSize: 12, color: theme.colorScheme.onSurface.withAlpha(120)),
);
}
final seen = <String>{};
final unique = <BloqueHorario>[];
for (final b in bloques) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
unique.add(b);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: unique
.map((b) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(50),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 3,
height: 20,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
SizedBox(
width: 84,
child: Text(
'${b.horaInicio}${b.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Text(
b.actividad.nombre,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
))
.toList(),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/horario_actividad_tile.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class DiaColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaHorarios? dia;
final bool isSelected;
final bool isAdmin;
final VoidCallback onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final VoidCallback? onCopiarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const DiaColumna({
super.key,
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isSelected,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final col = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isSelected: isSelected,
onTap: onSelectDia,
),
Expanded(
child: _DiaBody(
fecha: fecha,
dia: dia,
isAdmin: isAdmin,
onEditarDia: onEditarDia,
onEliminarBloque: onEliminarBloque,
),
),
],
);
if (!isAdmin) return col;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text('Editar día'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
if (onCopiarDia != null)
MenuItem(
label: const Text('Copiar a...'),
icon: const Icon(Icons.copy_outlined, size: 16),
value: 'copy',
),
],
),
onItemSelected: (v) {
if (v == 'edit') onEditarDia(fecha, dia);
if (v == 'copy') onCopiarDia!();
},
);
},
child: col,
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaHorarios? dia;
final bool isSelected;
final VoidCallback onTap;
const _DiaHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final esEspecial = dia?.esEspecial ?? false;
final esCerrado = dia?.esCerrado ?? false;
final hastaLabel = (dia != null &&
dia!.tipo == TipoDia.normal &&
dia!.validoHasta != null)
? '${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]}'
: null;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
decoration: BoxDecoration(
color: isSelected
? SomaColors.primary.withAlpha(28)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isSelected
? SomaColors.primary
: theme.colorScheme.surfaceContainerHighest,
width: isSelected ? 2 : 1,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 1),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: isSelected
? SomaColors.primaryText.withAlpha(180)
: theme.colorScheme.onSurface.withAlpha(120),
),
),
if (hastaLabel != null) ...[
const SizedBox(width: 6),
Flexible(
child: Tooltip(
message:
'Esta plantilla rige hasta el ${dia!.validoHasta!.day} ${_mesesCortos[dia!.validoHasta!.month]} ${dia!.validoHasta!.year}.',
child: Text(
hastaLabel,
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: isSelected
? SomaColors.primaryText.withAlpha(160)
: theme.colorScheme.onSurface
.withAlpha(100),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
],
),
),
),
);
}
}
class _HeaderBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _HeaderBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(4),
),
child: Icon(icon, size: 12, color: color.withAlpha(200)),
);
}
}
// ── Body ───────────────────────────────────────────────────────────────────────
class _DiaBody extends StatelessWidget {
final DateTime fecha;
final DiaHorarios? dia;
final bool isAdmin;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(BloqueHorario)? onEliminarBloque;
const _DiaBody({
required this.fecha,
required this.dia,
required this.isAdmin,
required this.onEditarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (dia == null) {
return Center(
child: Text(
'',
style: TextStyle(
fontSize: 18,
color: theme.colorScheme.onSurface.withAlpha(60),
),
),
);
}
if (dia!.esCerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 24, color: SomaColors.error.withAlpha(140)),
const SizedBox(height: 6),
Text(
'Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
),
),
if (dia!.motivo != null && dia!.motivo!.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
dia!.motivo!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
}
if (dia!.bloques.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 24,
color: theme.colorScheme.onSurface.withAlpha(50),
),
const SizedBox(height: 6),
Text(
'Sin actividades',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: dia!.bloques.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final bloque = dia!.bloques[index];
return HorarioActividadTile(
bloque: bloque,
onTap: isAdmin ? () => onEditarDia(fecha, dia) : null,
onDelete: onEliminarBloque != null
? () => onEliminarBloque!(bloque)
: null,
);
},
);
}
}
@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/editar_dia_dialog.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
class DiasEspecialesView extends ConsumerWidget {
const DiasEspecialesView({super.key});
String _fmtFecha(DateTime d) =>
'${_diasSemana[d.weekday - 1]}, ${d.day} de ${_meses[d.month]} ${d.year}';
Future<void> _eliminar(
BuildContext context, WidgetRef ref, DiaEspecialResumen dia) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: Text(
'¿Restaurar el horario regular para el ${_fmtFecha(dia.fecha)}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !context.mounted) return;
final (error, huerfanas) = await ref
.read(horariosProvider.notifier)
.eliminarDiaEspecial(dia.fecha);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(
context,
message: error.mensajeUsuario(),
type: ToastType.error,
);
} else {
if (huerfanas > 0) {
_showHuerfanasToast(context, huerfanas);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada',
type: ToastType.success,
);
}
// La recarga de la lista la dispara HorariosNotifier al invalidar
// diasEspecialesProvider tras eliminar la excepción.
}
}
void _showHuerfanasToast(BuildContext context, int n) {
SomaToast.show(
context,
message: '$n ${n == 1 ? 'reserva quedó huérfana' : 'reservas quedaron huérfanas'}',
type: ToastType.info,
action: SnackBarAction(
label: 'Ver',
textColor: SomaColors.onPrimary,
onPressed: () => context.go('/huerfanas'),
),
);
}
Future<void> _editar(
BuildContext context, WidgetRef ref, DiaEspecialResumen resumen) async {
// Convert DiaEspecialResumen to DiaHorarios for EditarDiaDialog
final tipo = resumen.esCerrado ? TipoDia.cerrado : TipoDia.horarioDiferente;
final bloques = resumen.rangos
.map((r) => BloqueHorario(
id: r.id,
horaInicio: r.horaInicio,
horaFin: r.horaFin,
actividad: BloqueActividadInfo(
id: r.actividadId,
nombre: r.actividadNombre,
duracion: r.actividadDuracion,
capacidad: 0,
),
))
.toList();
final diaHorarios = DiaHorarios(
fecha: resumen.fecha,
diaSemana: resumen.fecha.weekday,
tipo: tipo,
motivo: resumen.motivo,
bloques: bloques,
);
final huerfanas = await showDialog<int>(
context: context,
builder: (_) => EditarDiaDialog(
dia: diaHorarios,
fecha: resumen.fecha,
weekStart: resumen.fecha.subtract(
Duration(days: resumen.fecha.weekday - 1),
),
),
);
if (!context.mounted) return;
final n = huerfanas ?? 0;
if (n > 0) _showHuerfanasToast(context, n);
// Si el diálogo guardó algo, HorariosNotifier ya invalidó
// diasEspecialesProvider y la lista se recarga sola; si se canceló, no hay
// nada que refrescar.
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(diasEspecialesProvider);
final theme = Theme.of(context);
final isWide = MediaQuery.of(context).size.width >= 800;
return 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(diasEspecialesProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (especiales) {
if (especiales.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_note_outlined,
size: 56,
color: theme.colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
'Sin días especiales configurados',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 6),
Text(
'Editá un día desde la vista Semanal para marcarlo como especial.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 16, isWide ? 32 : 16, 32,
),
itemCount: especiales.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final dia = especiales[index];
return _EspecialCard(
dia: dia,
fechaLabel: _fmtFecha(dia.fecha),
onEditar: () => _editar(context, ref, dia),
onEliminar: () => _eliminar(context, ref, dia),
);
},
);
},
);
}
}
class _EspecialCard extends StatelessWidget {
final DiaEspecialResumen dia;
final String fechaLabel;
final VoidCallback onEditar;
final VoidCallback onEliminar;
const _EspecialCard({
required this.dia,
required this.fechaLabel,
required this.onEditar,
required this.onEliminar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final railColor = dia.esCerrado
? SomaColors.error.withAlpha(180)
: SomaColors.primary.withAlpha(180);
return InkWell(
onTap: onEditar,
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(14, 11, 8, 11),
child: Row(
children: [
// Icon
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: dia.esCerrado
? SomaColors.error.withAlpha(16)
: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
dia.esCerrado ? Icons.block : Icons.schedule,
size: 20,
color: dia.esCerrado
? SomaColors.error
: SomaColors.primary,
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
fechaLabel,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_TipoBadge(esCerrado: dia.esCerrado),
],
),
const SizedBox(height: 3),
Text(
dia.motivo?.isNotEmpty == true
? dia.motivo!
: dia.esCerrado
? 'Sin motivo especificado'
: '${dia.rangos.length} actividad${dia.rangos.length == 1 ? '' : 'es'}',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu
PopupMenuButton<String>(
icon: Icon(Icons.more_vert,
size: 18,
color:
theme.colorScheme.onSurface.withAlpha(130)),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.restore_outlined,
size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Restaurar normal',
style:
TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEditar();
if (v == 'delete') onEliminar();
},
),
],
),
),
),
],
),
),
),
);
}
}
class _TipoBadge extends StatelessWidget {
final bool esCerrado;
const _TipoBadge({required this.esCerrado});
@override
Widget build(BuildContext context) {
final color = esCerrado ? SomaColors.error : SomaColors.primary;
final textColor = esCerrado ? SomaColors.error : SomaColors.primaryText;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
esCerrado ? 'Cerrado' : 'Especial',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: textColor,
),
),
);
}
}
@@ -0,0 +1,975 @@
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/horarios/domain/entities/alcance.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_error.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/agregar_bloque_dialog.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/alcance_selector.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/valido_desde_selector.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _diasCortos = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class EditarDiaDialog extends ConsumerStatefulWidget {
final DiaHorarios? dia;
final DateTime fecha;
final DateTime weekStart;
const EditarDiaDialog({
super.key,
required this.dia,
required this.fecha,
required this.weekStart,
});
@override
ConsumerState<EditarDiaDialog> createState() => _EditarDiaDialogState();
}
class _EditarDiaDialogState extends ConsumerState<EditarDiaDialog> {
late bool _esEspecial;
late bool _esCerrado;
final _motivoController = TextEditingController();
late List<Map<String, dynamic>> _bloques;
late int _selectedWeekdayIndex;
late DateTime _selectedFecha;
bool _saving = false;
HorarioError? _error;
// Modo Normal: estado de vigencia y alcance del cambio.
late DateTime _validoDesde;
Alcance _alcance = const AlcanceHastaProximo();
List<PlanificacionFutura>? _futuros;
bool _futurosLoading = false;
@override
void initState() {
super.initState();
_esEspecial = widget.dia?.esEspecial ?? false;
_selectedWeekdayIndex = widget.fecha.weekday - 1;
_selectedFecha = widget.fecha;
_esCerrado = false;
_bloques = [];
_applyDia(widget.dia);
final hoy = _hoy();
// CU2.c — editar planificación futura existente: si el día actual ya
// tiene una plantilla vigente con valido_desde futuro, pre-cargamos ese
// valor para que guardar equivalga a editar esa misma planificación.
// Si no, usamos la fecha exacta del calendario que Juani está viendo
// (alineado con el nuevo default del backend: valido_desde = fecha).
// Clampear a hoy por si Juani navega hacia semanas pasadas.
final validoDesdeDia = widget.dia?.validoDesde;
_validoDesde =
(validoDesdeDia != null && validoDesdeDia.isAfter(hoy))
? validoDesdeDia
: (widget.fecha.isBefore(hoy) ? hoy : widget.fecha);
if (!_esEspecial) {
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarFuturos());
}
}
DateTime _hoy() {
final n = DateTime.now();
return DateTime(n.year, n.month, n.day);
}
Future<void> _cargarFuturos() async {
final diaSemana = _fechaParaGuardar.weekday; // ISODOW 1..7
setState(() => _futurosLoading = true);
try {
final lista =
await ref.read(horariosRepositoryProvider).futurosParaDiaSemana(
diaSemana: diaSemana,
desde: _validoDesde,
);
if (!mounted) return;
setState(() {
_futuros = lista;
_futurosLoading = false;
// Si no hay futuros, el alcance es 'indefinido' implícito.
// Si hay, mantenemos el default backend 'hasta_proximo' salvo que ya
// hubiera una elección del usuario distinta.
if (lista.isEmpty) {
_alcance = const AlcanceIndefinido();
} else if (_alcance is AlcanceHasta) {
// Si la fecha del 'hasta' previa quedó fuera del rango válido tras
// recargar futuros, retrocedemos al default.
final lastValid =
lista.first.validoDesde.subtract(const Duration(days: 1));
final f = (_alcance as AlcanceHasta).fecha;
if (f.isBefore(_validoDesde) || f.isAfter(lastValid)) {
_alcance = const AlcanceHastaProximo();
}
} else if (_alcance is AlcanceIndefinido) {
// Mantener selección explícita del usuario.
} else {
_alcance = const AlcanceHastaProximo();
}
});
} catch (_) {
if (!mounted) return;
// Si falla, asumimos que no hay futuros conocidos: el backend usará
// su default 'hasta_proximo' al guardar. El usuario verá el formulario
// sin selector hasta que vuelva a abrir.
setState(() {
_futuros = const [];
_futurosLoading = false;
_alcance = const AlcanceHastaProximo();
});
}
}
/// Mutates _esCerrado, _motivoController, _bloques from a DiaHorarios snapshot.
/// Must be called inside setState (or during initState).
void _applyDia(DiaHorarios? dia) {
_esCerrado = dia?.esCerrado ?? false;
_motivoController.text = dia?.motivo ?? '';
final seen = <String>{};
_bloques = [];
for (final b in dia?.bloques ?? []) {
final key = '${b.actividad.id}_${b.horaInicio}_${b.horaFin}';
if (!seen.add(key)) continue;
_bloques.add({
'actividad_id': b.actividad.id,
'hora_inicio': b.horaInicio,
'hora_fin': b.horaFin,
'_nombre': b.actividad.nombre,
});
}
}
void _onWeekdayChanged(int index) {
final semana = ref.read(horariosProvider).valueOrNull;
final newFecha = widget.weekStart.add(Duration(days: index));
final nuevoDia = semana?.diaPara(newFecha);
setState(() {
_selectedWeekdayIndex = index;
_error = null;
_applyDia(nuevoDia);
// Si el nuevo día tiene plantilla vigente con valido_desde futuro,
// saltamos a esa fecha (CU2.c). Si no, al próximo día con esa weekday.
final hoy = _hoy();
final vd = nuevoDia?.validoDesde;
_validoDesde = (vd != null && vd.isAfter(hoy))
? vd
: (newFecha.isBefore(hoy) ? hoy : newFecha);
_alcance = const AlcanceHastaProximo();
_futuros = null;
});
_cargarFuturos();
}
Future<void> _onValidoDesdeChanged(DateTime nuevo) async {
final hoy = _hoy();
final clamped = nuevo.isBefore(hoy) ? hoy : nuevo;
setState(() {
_validoDesde = clamped;
_futuros = null;
_error = null;
});
await _cargarFuturos();
}
@override
void dispose() {
_motivoController.dispose();
super.dispose();
}
DateTime get _fechaParaGuardar => _esEspecial
? _selectedFecha
: widget.weekStart.add(Duration(days: _selectedWeekdayIndex));
String get _diaLabel => _esEspecial
? '${_diasSemana[_selectedFecha.weekday - 1]} ${_selectedFecha.day} ${_mesesCortos[_selectedFecha.month]}'
: _diasSemana[_selectedWeekdayIndex];
Future<void> _addBloque() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const AgregarBloqueDialog(),
);
if (result == null) return;
setState(() => _bloques = [..._bloques, result]);
}
void _removeBloque(int index) {
setState(() {
_bloques = List<Map<String, dynamic>>.from(_bloques)..removeAt(index);
});
}
Future<void> _guardar() async {
setState(() {
_saving = true;
_error = null;
});
final bloquesPayload = _bloques.map((b) {
return {
'actividad_id': b['actividad_id'],
'hora_inicio': b['hora_inicio'],
'hora_fin': b['hora_fin'],
};
}).toList();
// Para modo Normal, solo mandamos alcance/validoDesde si tenemos info
// confiable. Si _futuros vino vacío explícitamente, mandamos los valores
// elegidos. Si es null (todavía cargando o falló), dejamos que decida
// el backend con sus defaults.
final esRegular = !_esEspecial;
final mandarMetadata = esRegular && _futuros != null;
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).guardarDia(
fecha: _fechaParaGuardar,
esEspecial: _esEspecial,
motivo: _esEspecial ? _motivoController.text.trim() : null,
bloques: _esEspecial && _esCerrado ? [] : bloquesPayload,
validoDesde: mandarMetadata ? _validoDesde : null,
alcance: mandarMetadata ? _alcance : null,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() {
_error = error;
// Si el backend rechazó por conflicto de alcance, retrocedemos a
// 'hasta_proximo' para que el usuario reintente con una opción que
// siempre es segura. También refrescamos el mapa de futuros por si
// el conflicto delata una planificación que no teníamos cacheada.
if (error is ConflictoAlcance) {
_alcance = const AlcanceHastaProximo();
}
});
if (error is ConflictoAlcance) {
await _cargarFuturos();
}
} else {
SomaToast.show(context, message: 'Horario guardado', type: ToastType.success);
Navigator.of(context).pop(huerfanas);
}
}
Future<void> _eliminarExcepcion() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Restaurar horario normal'),
content: const Text(
'Se eliminará la excepción y el día volverá a usar el horario regular.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Restaurar'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() {
_saving = true;
_error = null;
});
final (error, huerfanas) =
await ref.read(horariosProvider.notifier).eliminarDiaEspecial(
widget.dia!.fecha,
);
if (!mounted) return;
setState(() => _saving = false);
if (error != null) {
setState(() => _error = error);
} else {
SomaToast.show(
context,
message: 'Excepción eliminada, se aplica horario regular',
type: ToastType.success,
);
Navigator.of(context).pop(huerfanas);
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
final theme = Theme.of(context);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 480) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Editar $_diaLabel',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tipo toggle
Row(
children: [
Expanded(
child: _TipoOption(
label: 'Normal',
icon: Icons.calendar_today_outlined,
selected: !_esEspecial,
onTap: () {
setState(() {
_esEspecial = false;
_esCerrado = false;
_selectedWeekdayIndex =
_selectedFecha.weekday - 1;
_futuros = null;
});
_cargarFuturos();
},
),
),
const SizedBox(width: 8),
Expanded(
child: _TipoOption(
label: 'Especial',
icon: Icons.event_note_outlined,
selected: _esEspecial,
onTap: () => setState(() {
_esEspecial = true;
_selectedFecha = widget.weekStart
.add(Duration(days: _selectedWeekdayIndex));
}),
),
),
],
),
// Día selector
const SizedBox(height: 16),
if (!_esEspecial)
_WeekdaySelector(
selected: _selectedWeekdayIndex,
onChanged: _onWeekdayChanged,
)
else
_FechaSelector(
fecha: _selectedFecha,
onChanged: (d) => setState(() => _selectedFecha = d),
),
// Vigencia y alcance (sólo modo Normal)
if (!_esEspecial) ...[
const SizedBox(height: 16),
ValidoDesdeSelector(
fecha: _validoDesde,
weekdayTarget: _fechaParaGuardar.weekday,
onChanged: _onValidoDesdeChanged,
),
const SizedBox(height: 12),
if (_futurosLoading && _futuros == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.primary,
),
),
SizedBox(width: 8),
Text(
'Verificando planificaciones futuras…',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_futuros != null && _futuros!.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'No hay horarios planificados a futuro: este '
'horario regirá desde el inicio de vigencia '
'de manera indefinida.',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(140),
fontStyle: FontStyle.italic,
),
),
)
else if (_futuros != null && _futuros!.isNotEmpty)
AlcanceSelector(
futuros: _futuros!,
validoDesde: _validoDesde,
alcance: _alcance,
onChanged: (a) => setState(() => _alcance = a),
),
],
// Especial options
if (_esEspecial) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _SubOption(
label: 'Horario diferente',
selected: !_esCerrado,
onTap: () =>
setState(() => _esCerrado = false),
),
),
const SizedBox(width: 8),
Expanded(
child: _SubOption(
label: 'Cerrado',
selected: _esCerrado,
isDestructive: true,
onTap: () =>
setState(() => _esCerrado = true),
),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _motivoController,
decoration: const InputDecoration(
labelText: 'Motivo (opcional)',
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
),
],
// Bloques (solo si no está cerrado)
if (!(_esEspecial && _esCerrado)) ...[
const SizedBox(height: 20),
Text(
'Actividades',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
if (_bloques.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Text(
'Sin actividades — el día quedará vacío',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(120),
),
),
),
)
else
...List.generate(_bloques.length, (i) {
final b = _bloques[i];
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _BloqueEditRow(
horaInicio: b['hora_inicio'] as String,
horaFin: b['hora_fin'] as String,
nombre: b['_nombre'] as String? ?? '',
onDelete: () => _removeBloque(i),
),
);
}),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _addBloque,
icon: const Icon(Icons.add, size: 18),
label: const Text('Agregar actividad'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 44),
foregroundColor: SomaColors.primary,
side: BorderSide(
color: SomaColors.primary.withAlpha(100)),
),
),
],
// Error
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!.mensajeUsuario(),
style: TextStyle(
color: SomaColors.error,
fontSize: 12,
),
),
],
const SizedBox(height: 4),
],
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
children: [
// Eliminar excepción (solo si el día actual ya es especial en BD)
if (widget.dia?.esEspecial == true)
TextButton(
onPressed: _saving ? null : _eliminarExcepcion,
style: TextButton.styleFrom(
foregroundColor: SomaColors.error,
),
child: const Text('Restaurar normal'),
),
const Spacer(),
TextButton(
onPressed:
_saving ? null : () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _saving ? null : _guardar,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
class _TipoOption extends StatelessWidget {
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _TipoOption({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: selected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 16,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _SubOption extends StatelessWidget {
final String label;
final bool selected;
final bool isDestructive;
final VoidCallback onTap;
const _SubOption({
required this.label,
required this.selected,
this.isDestructive = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = isDestructive ? SomaColors.error : SomaColors.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? color.withAlpha(18) : Colors.transparent,
border: Border.all(
color: selected
? color.withAlpha(80)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (selected)
Icon(Icons.radio_button_checked,
size: 14, color: color)
else
Icon(Icons.radio_button_unchecked,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? (isDestructive ? SomaColors.error : SomaColors.primaryText)
: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
);
}
}
class _BloqueEditRow extends StatelessWidget {
final String horaInicio;
final String horaFin;
final String nombre;
final VoidCallback onDelete;
const _BloqueEditRow({
required this.horaInicio,
required this.horaFin,
required this.nombre,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: SomaColors.primary),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
child: Row(
children: [
SizedBox(
width: 94,
child: Text(
'$horaInicio $horaFin',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
Expanded(
child: Text(
nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
Icons.delete_outline,
size: 18,
color: SomaColors.error.withAlpha(180),
),
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: onDelete,
),
],
),
),
),
],
),
),
);
}
}
// ── Weekday selector (Normal) ──────────────────────────────────────────────────
class _WeekdaySelector extends StatelessWidget {
final int selected;
final ValueChanged<int> onChanged;
const _WeekdaySelector({required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: List.generate(_diasCortos.length, (i) {
final isSelected = i == selected;
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < _diasCortos.length - 1 ? 4 : 0),
child: InkWell(
onTap: () => onChanged(i),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? SomaColors.primary.withAlpha(22)
: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: isSelected
? SomaColors.primary.withAlpha(100)
: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Center(
child: Text(
_diasCortos[i],
style: TextStyle(
fontSize: 12,
fontWeight:
isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(130),
),
),
),
),
),
),
);
}),
);
}
}
// ── Date picker button (Especial) ──────────────────────────────────────────────
class _FechaSelector extends StatelessWidget {
final DateTime fecha;
final ValueChanged<DateTime> onChanged;
const _FechaSelector({required this.fecha, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final initial = fecha.isBefore(hoy) ? hoy : fecha;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.calendar_month_outlined,
size: 18,
color: SomaColors.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
);
}
}
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
class _CapacidadBadge extends StatelessWidget {
final int capacidad;
const _CapacidadBadge({required this.capacidad});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(width: 3),
Flexible(
child: Text(
'$capacidad personas',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class HorarioActividadTile extends StatelessWidget {
final BloqueHorario bloque;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const HorarioActividadTile({
super.key,
required this.bloque,
this.onTap,
this.onDelete,
});
Widget _buildCompactContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildWideContent(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
// Hora — monoespaciada, ancho fijo
SizedBox(
width: 94,
child: Text(
'${bloque.horaInicio} ${bloque.horaFin}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
// Separador vertical sutil
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
// Nombre actividad + capacidad
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
bloque.actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
_CapacidadBadge(
capacidad: bloque.actividad.capacidad,
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
final tile = MouseRegion(
cursor:
onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de actividad — color por actividad
Container(
width: 4,
color: ActivityColors.forId(bloque.actividad.id),
),
// Contenido
Expanded(
child: isCompact
? _buildCompactContent(theme)
: _buildWideContent(theme),
),
],
),
),
),
),
),
);
if (onDelete == null) return tile;
return GestureDetector(
onSecondaryTapDown: (details) {
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: [
MenuItem(
label: const Text(
'Eliminar',
style: TextStyle(color: SomaColors.error),
),
icon: const Icon(Icons.delete_outline,
size: 16, color: SomaColors.error),
value: 'delete',
),
],
),
onItemSelected: (v) {
if (v == 'delete') onDelete!();
},
);
},
child: tile,
);
},
);
}
}
@@ -0,0 +1,531 @@
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/horarios/domain/entities/dia_especial.dart';
import 'package:gimnasio_soma/features/horarios/presentation/providers/horarios_provider.dart';
const _colorEspecial = Color(0xFFFF9800);
const _colorCambio = Color(0xFF2196F3);
const _handleWidth = 22.0;
const _panelWidth = 280.0;
const _mesesLargos = [
'',
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const _diasCortos = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
/// Panel de calendario que se desliza desde el borde derecho de la pantalla.
///
/// Debe colocarse con [Positioned(right: 0, top: 0, bottom: 0)] dentro de un
/// [Stack] que envuelva el área de contenido. La franja-handle (~22px) siempre
/// está visible en el borde derecho; al hacer clic el panel de 280px se
/// desliza hacia la izquierda superponiéndose sobre la tabla semanal.
///
/// Indicadores en el calendario:
/// • Naranja → día especial (cualquier tipo)
/// • Azul → arranca nueva plantilla regular ese día
class HorariosCalendarPanel extends ConsumerStatefulWidget {
final ValueChanged<DateTime> onNavigateToWeek;
const HorariosCalendarPanel({super.key, required this.onNavigateToWeek});
@override
ConsumerState<HorariosCalendarPanel> createState() =>
_HorariosCalendarPanelState();
}
class _HorariosCalendarPanelState
extends ConsumerState<HorariosCalendarPanel> {
bool _open = false;
late DateTime _month;
@override
void initState() {
super.initState();
final now = DateTime.now();
_month = DateTime(now.year, now.month);
}
void _toggle() => setState(() => _open = !_open);
void _prevMonth() => setState(
() => _month = DateTime(_month.year, _month.month - 1),
);
void _nextMonth() => setState(
() => _month = DateTime(_month.year, _month.month + 1),
);
@override
Widget build(BuildContext context) {
// Observamos los providers sólo con el panel abierto: así no disparamos sus
// RPC hasta que el usuario lo abre, y mientras está cerrado las
// invalidaciones tras escribir se fusionan en una sola recarga (relevante
// al copiar un día a varios destinos).
final especiales = _open
? (ref.watch(diasEspecialesProvider).valueOrNull ??
const <DiaEspecialResumen>[])
: const <DiaEspecialResumen>[];
final especSet = <DateTime>{};
for (final e in especiales) {
especSet.add(DateTime(e.fecha.year, e.fecha.month, e.fecha.day));
}
final cambiosAsync = _open ? ref.watch(diasCambioProvider) : null;
final diasCambio = cambiosAsync?.valueOrNull ?? const <DateTime>{};
final cargandoCambios = cambiosAsync?.isLoading ?? false;
// Row: [Panel animado (izq)] [Handle (der)]
// Posicionado con right:0, top:0, bottom:0 desde el parent Stack.
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Panel: crece de 0 → _panelWidth hacia la izquierda
ClipRect(
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOut,
width: _open ? _panelWidth : 0,
child: OverflowBox(
maxWidth: _panelWidth,
alignment: Alignment.centerRight,
child: _PanelContent(
month: _month,
diasEspeciales: especSet,
diasCambio: diasCambio,
cargandoCambios: cargandoCambios,
onPrevMonth: _prevMonth,
onNextMonth: _nextMonth,
onDayTap: (fecha) {
widget.onNavigateToWeek(fecha);
setState(() => _open = false);
},
),
),
),
),
// Handle: siempre visible en el borde derecho
_SideHandle(isOpen: _open, onTap: _toggle),
],
);
}
}
// ── Side handle ────────────────────────────────────────────────────────────────
class _SideHandle extends StatefulWidget {
final bool isOpen;
final VoidCallback onTap;
const _SideHandle({required this.isOpen, required this.onTap});
@override
State<_SideHandle> createState() => _SideHandleState();
}
class _SideHandleState extends State<_SideHandle> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visible = _hovered || widget.isOpen;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Opacity(
opacity: visible ? 1.0 : 0.0,
child: IgnorePointer(
ignoring: !visible,
child: GestureDetector(
onTap: widget.onTap,
child: Container(
width: _handleWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(10),
blurRadius: 4,
offset: const Offset(-2, 0),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_month_outlined,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
const SizedBox(height: 5),
AnimatedRotation(
turns: widget.isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
child: Icon(
Icons.chevron_right,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(90),
),
),
],
),
),
),
),
),
);
}
}
// ── Panel content ──────────────────────────────────────────────────────────────
class _PanelContent extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final bool cargandoCambios;
final VoidCallback onPrevMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime> onDayTap;
const _PanelContent({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.cargandoCambios,
required this.onPrevMonth,
required this.onNextMonth,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: _panelWidth,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Column(
children: [
_MonthHeader(
month: month,
onPrev: onPrevMonth,
onNext: onNextMonth,
),
_CalendarGrid(
month: month,
diasEspeciales: diasEspeciales,
diasCambio: diasCambio,
onDayTap: onDayTap,
),
const SizedBox(height: 10),
_Legend(cargandoCambios: cargandoCambios),
],
),
),
);
}
}
// ── Month header ───────────────────────────────────────────────────────────────
class _MonthHeader extends StatelessWidget {
final DateTime month;
final VoidCallback onPrev;
final VoidCallback onNext;
const _MonthHeader({
required this.month,
required this.onPrev,
required this.onNext,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 6),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
onPressed: onPrev,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
Expanded(
child: Text(
'${_mesesLargos[month.month]} ${month.year}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
onPressed: onNext,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Calendar grid ──────────────────────────────────────────────────────────────
class _CalendarGrid extends StatelessWidget {
final DateTime month;
final Set<DateTime> diasEspeciales;
final Set<DateTime> diasCambio;
final ValueChanged<DateTime> onDayTap;
const _CalendarGrid({
required this.month,
required this.diasEspeciales,
required this.diasCambio,
required this.onDayTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final firstDay = DateTime(month.year, month.month, 1);
final offset = firstDay.weekday - 1; // Lun=0, Dom=6
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final rows = ((offset + daysInMonth) / 7).ceil();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Headers de días
Row(
children: _diasCortos.map((d) {
return Expanded(
child: Center(
child: Text(
d,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
);
}).toList(),
),
const SizedBox(height: 2),
// Filas de días
...List.generate(rows, (row) {
return Row(
children: List.generate(7, (col) {
final dayNum = row * 7 + col - offset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
return const Expanded(child: SizedBox(height: 34));
}
final fecha = DateTime(month.year, month.month, dayNum);
return Expanded(
child: _DayCell(
day: dayNum,
isToday: fecha == today,
isEspecial: diasEspeciales.contains(fecha),
isCambio: diasCambio.contains(fecha),
onTap: () => onDayTap(fecha),
),
);
}),
);
}),
],
),
);
}
}
// ── Day cell ───────────────────────────────────────────────────────────────────
class _DayCell extends StatelessWidget {
final int day;
final bool isToday;
final bool isEspecial;
final bool isCambio;
final VoidCallback onTap;
const _DayCell({
required this.day,
required this.isToday,
required this.isEspecial,
required this.isCambio,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
height: 34,
margin: const EdgeInsets.all(1),
decoration: isToday
? BoxDecoration(
color: SomaColors.primary.withAlpha(50),
borderRadius: BorderRadius.circular(6),
)
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 12,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday
? SomaColors.primaryText
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
// Espacio reservado siempre para mantener altura uniforme
SizedBox(
height: 5,
child: (isEspecial || isCambio)
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (isEspecial) const _Dot(color: _colorEspecial),
if (isEspecial && isCambio) const SizedBox(width: 2),
if (isCambio) const _Dot(color: _colorCambio),
],
)
: null,
),
],
),
),
);
}
}
class _Dot extends StatelessWidget {
final Color color;
const _Dot({required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 4,
height: 4,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
}
// ── Legend ─────────────────────────────────────────────────────────────────────
class _Legend extends StatelessWidget {
final bool cargandoCambios;
const _Legend({required this.cargandoCambios});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
_LegendItem(color: _colorEspecial, label: 'Día especial'),
const SizedBox(width: 14),
if (cargandoCambios)
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: theme.colorScheme.onSurface.withAlpha(80),
),
),
const SizedBox(width: 5),
Text(
'Cargando...',
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
],
)
else
_LegendItem(color: _colorCambio, label: 'Nuevo horario'),
],
),
);
}
}
class _LegendItem extends StatelessWidget {
final Color color;
final String label;
const _LegendItem({required this.color, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
],
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
import 'package:gimnasio_soma/features/horarios/presentation/widgets/dia_columna.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _minColumnWidth = 160.0;
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
class SemanaTablaView extends StatelessWidget {
final SemanaHorarios semana;
/// Índices de días a mostrar (0 = Lunes … 6 = Domingo).
final List<int> diasVisibles;
final DateTime weekStart;
final DateTime? selectedDay;
final bool isAdmin;
final void Function(DateTime) onSelectDia;
final void Function(DateTime, DiaHorarios?) onEditarDia;
final void Function(DiaHorarios)? onCopiarDia;
final void Function(DiaHorarios, BloqueHorario)? onEliminarBloque;
const SemanaTablaView({
super.key,
required this.semana,
required this.diasVisibles,
required this.weekStart,
required this.selectedDay,
required this.isAdmin,
required this.onSelectDia,
required this.onEditarDia,
this.onCopiarDia,
this.onEliminarBloque,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = diasVisibles.length;
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth;
final useScroll = available < count * _minColumnWidth;
final rowChildren = <Widget>[];
for (int i = 0; i < count; i++) {
final diaIdx = diasVisibles[i];
final fecha = weekStart.add(Duration(days: diaIdx));
final dia = semana.diaPara(fecha);
final isSelected =
selectedDay != null && _isSameDay(fecha, selectedDay!);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = DiaColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isSelected: isSelected,
isAdmin: isAdmin,
onSelectDia: () => onSelectDia(fecha),
onEditarDia: (f, d) => onEditarDia(f, d),
onCopiarDia: dia != null &&
!dia.esEspecial &&
dia.bloques.isNotEmpty &&
onCopiarDia != null
? () => onCopiarDia!(dia)
: null,
onEliminarBloque: dia != null && onEliminarBloque != null
? (bloque) => onEliminarBloque!(dia, bloque)
: null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
// Ancho total: columnas + separadores de 1px
final totalWidth =
count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
/// Selector de fecha para el campo `valido_desde` de un horario regular.
///
/// [weekdayTarget] (1=Lun..7=Dom) restringe la selección a fechas del mismo
/// día de la semana que el día siendo editado. Pasar `null` para permitir
/// cualquier fecha (útil cuando se copia a múltiples días de la semana).
class ValidoDesdeSelector extends StatelessWidget {
final DateTime fecha;
final int? weekdayTarget;
final ValueChanged<DateTime> onChanged;
const ValidoDesdeSelector({
super.key,
required this.fecha,
required this.weekdayTarget,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[fecha.weekday - 1]}, ${fecha.day} ${_mesesCortos[fecha.month]} ${fecha.year}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Entra en vigor el',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const SizedBox(height: 6),
InkWell(
onTap: () async {
final now = DateTime.now();
final hoy = DateTime(now.year, now.month, now.day);
final target = weekdayTarget;
final DateTime initial;
if (target != null) {
if (!fecha.isBefore(hoy) && fecha.weekday == target) {
initial = fecha;
} else {
final diff = (target - hoy.weekday + 7) % 7;
initial = hoy.add(Duration(days: diff));
}
} else {
initial = fecha.isBefore(hoy) ? hoy : fecha;
}
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: hoy,
lastDate: DateTime(2100),
selectableDayPredicate:
target != null ? (d) => d.weekday == target : null,
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked != null) onChanged(picked);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.surfaceContainerHighest.withAlpha(60),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
children: [
const Icon(Icons.schedule, size: 18, color: SomaColors.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(120),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,62 @@
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/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
class HuerfanasRepositoryImpl implements HuerfanasRepository {
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<ReservaHuerfana>> obtenerHuerfanas({String? estado}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (estado != null) params['p_estado'] = estado;
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasHuerfanas,
params: params,
);
if (response is List) {
return response
.map((e) => ReservaHuerfana.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcResolverHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_nuevo_estado': nuevoEstado,
},
);
}
@override
Future<void> moverHuerfana(String huerfanaId, String turnoId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcMoverReservaHuerfana,
params: {
'p_token': token,
'p_huerfana_id': huerfanaId,
'p_turno_id': turnoId,
},
);
}
}
@@ -0,0 +1,59 @@
enum EstadoHuerfana { pendiente, reubicado, resuelta }
class ReservaHuerfana {
final String huerfanaId;
final String clienteId;
final String nombre;
final String? apellido;
final String? telefono;
final String actividadNombre;
final String fechaOriginal;
final String horaInicioOriginal;
final EstadoHuerfana estado;
final String creadaEn;
const ReservaHuerfana({
required this.huerfanaId,
required this.clienteId,
required this.nombre,
this.apellido,
this.telefono,
required this.actividadNombre,
required this.fechaOriginal,
required this.horaInicioOriginal,
required this.estado,
required this.creadaEn,
});
String get displayName =>
apellido != null ? '$nombre $apellido' : nombre;
String get initials {
final parts = displayName.trim().split(' ');
if (parts.length == 1) return parts[0][0].toUpperCase();
return '${parts[0][0]}${parts.last[0]}'.toUpperCase();
}
static EstadoHuerfana _parseEstado(String s) {
return switch (s) {
'reubicado' => EstadoHuerfana.reubicado,
'resuelta' => EstadoHuerfana.resuelta,
_ => EstadoHuerfana.pendiente,
};
}
factory ReservaHuerfana.fromMap(Map<String, dynamic> m) {
return ReservaHuerfana(
huerfanaId: m['huerfana_id'] as String,
clienteId: m['cliente_id'] as String,
nombre: m['nombre'] as String,
apellido: m['apellido'] as String?,
telefono: m['telefono'] as String?,
actividadNombre: m['actividad_nombre'] as String,
fechaOriginal: m['fecha_original'] as String,
horaInicioOriginal: m['hora_inicio_original'] as String,
estado: _parseEstado(m['estado_resolucion'] as String? ?? 'pendiente'),
creadaEn: m['creada_en'] as String,
);
}
}
@@ -0,0 +1,13 @@
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
abstract class HuerfanasRepository {
/// [estado] puede ser 'pendiente', 'reubicado', 'resuelta', o null para todos.
Future<List<ReservaHuerfana>> obtenerHuerfanas({String? estado});
/// [nuevoEstado] debe ser 'pendiente', 'reubicado' o 'resuelta'.
Future<void> resolverHuerfana(String huerfanaId, String nuevoEstado);
/// Reserva [turnoId] para el cliente de la huérfana y la marca como 'reubicado'
/// en una sola transacción atómica.
Future<void> moverHuerfana(String huerfanaId, String turnoId);
}
@@ -0,0 +1,140 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/features/huerfanas/data/repositories/huerfanas_repository_impl.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/domain/repositories/huerfanas_repository.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
final huerfanasRepositoryProvider = Provider<HuerfanasRepository>((ref) {
return HuerfanasRepositoryImpl();
});
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final huerfanasProvider =
StateNotifierProvider<HuerfanasNotifier, AsyncValue<List<ReservaHuerfana>>>(
(ref) {
return HuerfanasNotifier(ref, ref.read(huerfanasRepositoryProvider));
});
class HuerfanasNotifier
extends StateNotifier<AsyncValue<List<ReservaHuerfana>>> {
final Ref _ref;
final HuerfanasRepository _repository;
String? _currentEstado = 'pendiente';
HuerfanasNotifier(this._ref, this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.obtenerHuerfanas(estado: _currentEstado);
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> filtrar(String? estado) async {
_currentEstado = estado;
await load();
}
String? get currentEstado => _currentEstado;
/// Retorna null si tuvo éxito, o un mensaje de error.
Future<String?> resolver(String huerfanaId, String nuevoEstado) async {
try {
await _repository.resolverHuerfana(huerfanaId, nuevoEstado);
await load();
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Reserva el turno y marca la huérfana como 'reubicado' atómicamente.
/// Retorna null si tuvo éxito, o un mensaje de error.
///
/// La reubicación ocupa un cupo en [turnoId]. Invalidamos turnosProvider
/// para que la pantalla de Turnos no muestre un cupo desactualizado si ya
/// tenía esa semana cacheada de antes.
Future<String?> mover(String huerfanaId, String turnoId) async {
try {
await _repository.moverHuerfana(huerfanaId, turnoId);
await load();
_ref.invalidate(turnosProvider);
return null;
} catch (e) {
return _errorMessage(e);
}
}
/// Marca todas las huérfanas del conjunto como 'resuelta' (best-effort).
Future<void> notificarLote(Iterable<String> ids) async {
for (final id in ids) {
try {
await _repository.resolverHuerfana(id, 'resuelta');
} catch (_) {
// best-effort: continúa con las demás aunque alguna falle
}
}
await load();
}
}
// ── Selección múltiple ────────────────────────────────────────────────────────
final huerfanasModoSeleccionProvider = StateProvider<bool>((ref) => false);
class _SeleccionNotifier extends StateNotifier<Set<String>> {
_SeleccionNotifier() : super({});
void toggle(String id) {
final next = {...state};
if (next.contains(id)) {
next.remove(id);
} else {
next.add(id);
}
state = next;
}
void limpiar() => state = {};
}
final huerfanasSeleccionProvider =
StateNotifierProvider<_SeleccionNotifier, Set<String>>(
(ref) => _SeleccionNotifier());
// ── Badge sidebar ─────────────────────────────────────────────────────────────
/// Cantidad de reservas huérfanas pendientes — usado para el badge en sidebar.
///
/// Observa [huerfanasProvider] para recomputarse tras cualquier mutación.
/// Si el filtro activo es 'pendiente' o null derivamos el conteo en memoria
/// (sin RPC extra). Si el filtro es otro, hacemos una consulta independiente.
final huerfanasPendienteCountProvider =
FutureProvider.autoDispose<int>((ref) async {
final state = ref.watch(huerfanasProvider);
final notifier = ref.read(huerfanasProvider.notifier);
final lista = state.valueOrNull;
if (lista != null) {
if (notifier.currentEstado == 'pendiente') return lista.length;
if (notifier.currentEstado == null) {
return lista.where((r) => r.estado == EstadoHuerfana.pendiente).length;
}
}
// Filtro activo no es pendiente/todas: hacemos la consulta directa.
final repo = ref.read(huerfanasRepositoryProvider);
final pendientes = await repo.obtenerHuerfanas(estado: 'pendiente');
return pendientes.length;
});
@@ -0,0 +1,926 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/services/whatsapp_service.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/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/bulk_notify_dialog.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/widgets/turno_picker_sheet.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class HuerfanasScreen extends ConsumerWidget {
const HuerfanasScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(huerfanasProvider);
final notifier = ref.read(huerfanasProvider.notifier);
final currentEstado = notifier.currentEstado;
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
final modoSeleccion = ref.watch(huerfanasModoSeleccionProvider);
final seleccionadas = ref.watch(huerfanasSeleccionProvider);
final seleccionNotifier = ref.read(huerfanasSeleccionProvider.notifier);
// Sólo aplica a pendientes
final lista = state.valueOrNull ?? [];
final pendientes = lista
.where((r) => r.estado == EstadoHuerfana.pendiente)
.toList();
final seleccionadasValidas = seleccionadas
.where((id) => pendientes.any((r) => r.huerfanaId == id))
.toSet();
void toggleModoSeleccion() {
if (modoSeleccion) {
seleccionNotifier.limpiar();
}
ref.read(huerfanasModoSeleccionProvider.notifier).state = !modoSeleccion;
}
void abrirBulkNotify() {
final items = pendientes
.where((r) => seleccionadasValidas.contains(r.huerfanaId))
.toList();
if (items.isEmpty) return;
showDialog(
context: context,
builder: (_) => BulkNotifyDialog(seleccionadas: items),
).then((_) {
// limpiar selección al cerrar el dialog
seleccionNotifier.limpiar();
ref.read(huerfanasModoSeleccionProvider.notifier).state = false;
});
}
void abrirPickerSheet(ReservaHuerfana reserva) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => TurnoPickerSheet(
reserva: reserva,
onReubicadoExito: (Turno turno, DateTime fecha) {
if (!context.mounted) return;
final fechaStr =
'${fecha.day} ${_meses[fecha.month]} ${turno.horaInicio}';
SomaToast.show(
context,
message: 'Reubicado al $fechaStr',
type: ToastType.success,
action: reserva.telefono != null
? SnackBarAction(
label: 'Avisar por WhatsApp',
textColor: SomaColors.onPrimary,
onPressed: () => WhatsAppService.abrirChat(
telefono: reserva.telefono,
mensaje: 'Hola ${reserva.nombre}, te reasignamos al turno de '
'${turno.actividad.nombre} del $fechaStr. ¡Te esperamos!',
),
)
: null,
);
},
),
);
}
Future<void> abrirWhatsApp(ReservaHuerfana reserva) async {
final nombre = reserva.nombre;
final actividad = reserva.actividadNombre;
final d = DateTime.tryParse(reserva.fechaOriginal);
final fecha = d != null ? '${d.day} ${_meses[d.month]} ${d.year}' : reserva.fechaOriginal;
final ok = await WhatsAppService.abrirChat(
telefono: reserva.telefono,
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
'Tu reserva de $actividad del $fecha quedó sin turno disponible. '
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!',
);
if (!context.mounted) return;
if (!ok) {
final sinNumero = WhatsAppService.normalizarNumeroAr(reserva.telefono) == null;
SomaToast.show(
context,
message: sinNumero
? '${reserva.displayName} no tiene número de teléfono registrado. '
'Podés agregarlo desde la pantalla de Usuarios.'
: 'No se pudo abrir WhatsApp.',
type: ToastType.error,
);
return;
}
// Sólo ofrecer marcar si está pendiente
if (reserva.estado == EstadoHuerfana.pendiente) {
SomaToast.show(
context,
message: 'WhatsApp abierto',
type: ToastType.info,
action: SnackBarAction(
label: 'Marcar resuelta',
textColor: SomaColors.onPrimary,
onPressed: () async {
final error = await ref
.read(huerfanasProvider.notifier)
.resolver(reserva.huerfanaId, 'resuelta');
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
},
),
);
}
}
return Scaffold(
body: Column(
children: [
// ── Header ──────────────────────────────────────────────────────────
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Reservas sin turno',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.filter_alt_outlined,
text: 'Filtrá por estado: pendientes, reubicadas, '
'resueltas o todas.',
),
SomaHelpItem(
icon: Icons.checklist_outlined,
text: 'Modo selección: elegí varias reservas para '
'notificarlas por WhatsApp de una sola vez.',
),
SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga la lista de reservas sin turno.',
),
],
),
const Spacer(),
// Toggle selección múltiple
IconButton(
icon: Icon(
modoSeleccion
? Icons.checklist_rounded
: Icons.checklist_outlined,
size: 20,
color: modoSeleccion
? SomaColors.primary
: null,
),
tooltip: modoSeleccion ? 'Cancelar selección' : 'Seleccionar',
onPressed: toggleModoSeleccion,
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: () {
seleccionNotifier.limpiar();
ref.read(huerfanasModoSeleccionProvider.notifier).state =
false;
notifier.load();
},
),
],
),
),
// ── Filter chips (ocultos en modo selección) ─────────────────────
if (!modoSeleccion)
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
),
child: Row(
children: [
_FilterChip(
label: 'Pendientes',
selected: currentEstado == 'pendiente',
color: SomaColors.error,
onTap: () => notifier.filtrar('pendiente'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Reubicadas',
selected: currentEstado == 'reubicado',
color: SomaColors.primary,
onTap: () => notifier.filtrar('reubicado'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Resueltas',
selected: currentEstado == 'resuelta',
color: theme.colorScheme.secondary,
onTap: () => notifier.filtrar('resuelta'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Todas',
selected: currentEstado == null,
color: theme.colorScheme.onSurface,
onTap: () => notifier.filtrar(null),
),
],
),
)
else
// Etiqueta modo selección
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 12, isWide ? 32 : 16, 0,
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(120),
),
const SizedBox(width: 6),
Text(
'Seleccioná los clientes pendientes que querés notificar en lote',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(140),
),
),
],
),
),
const SizedBox(height: 8),
// ── Content ──────────────────────────────────────────────────────
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: () => notifier.load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (lista) {
// En modo selección sólo mostramos pendientes
final listaFiltrada =
modoSeleccion ? pendientes : lista;
if (listaFiltrada.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 56,
color:
theme.colorScheme.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text(
modoSeleccion
? 'Sin reservas pendientes para notificar'
: currentEstado == 'pendiente'
? 'Sin reservas pendientes'
: 'Sin reservas en este estado',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 8, isWide ? 32 : 16, 32,
),
itemCount: listaFiltrada.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final reserva = listaFiltrada[index];
return _HuerfanaCard(
reserva: reserva,
modoSeleccion: modoSeleccion,
seleccionada: seleccionadasValidas
.contains(reserva.huerfanaId),
onToggleSeleccion: () =>
seleccionNotifier.toggle(reserva.huerfanaId),
onWhatsApp:
() => abrirWhatsApp(reserva),
onReubicar: () => abrirPickerSheet(reserva),
onResolver: (nuevoEstado) async {
final error = await ref
.read(huerfanasProvider.notifier)
.resolver(reserva.huerfanaId, nuevoEstado);
if (!context.mounted) return;
if (error != null) {
SomaToast.show(context,
message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Estado actualizado',
type: ToastType.success);
}
},
);
},
);
},
),
),
// ── Bottom bar de selección ──────────────────────────────────────
if (modoSeleccion)
_SelectionBar(
count: seleccionadasValidas.length,
onNotificar: seleccionadasValidas.isEmpty ? null : abrirBulkNotify,
onCancelar: toggleModoSeleccion,
),
],
),
);
}
}
// ── Filter chip ───────────────────────────────────────────────────────────────
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final Color color;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.selected,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
mouseCursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: selected ? color.withAlpha(22) : Colors.transparent,
border: Border.all(
color: selected ? color.withAlpha(100) : color.withAlpha(40),
width: 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? color : color.withAlpha(150),
),
),
),
);
}
}
// ── Card de reserva huérfana ──────────────────────────────────────────────────
class _HuerfanaCard extends StatelessWidget {
final ReservaHuerfana reserva;
final bool modoSeleccion;
final bool seleccionada;
final VoidCallback onToggleSeleccion;
final Future<void> Function() onWhatsApp;
final VoidCallback onReubicar;
final Future<void> Function(String nuevoEstado) onResolver;
const _HuerfanaCard({
required this.reserva,
required this.modoSeleccion,
required this.seleccionada,
required this.onToggleSeleccion,
required this.onWhatsApp,
required this.onReubicar,
required this.onResolver,
});
String _fmtFecha(String fechaIso) {
final d = DateTime.tryParse(fechaIso);
if (d == null) return fechaIso;
return '${d.day} ${_meses[d.month]} ${d.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isPendiente = reserva.estado == EstadoHuerfana.pendiente;
return GestureDetector(
onTap: modoSeleccion ? onToggleSeleccion : null,
onSecondaryTapDown: modoSeleccion
? null
: (details) {
final entries = <ContextMenuEntry<String>>[];
if (isPendiente) {
entries.add(MenuItem(
label: const Text('Reubicar'),
icon: const Icon(Icons.swap_horiz, size: 16),
value: 'reubicar',
));
if (reserva.telefono != null) {
entries.add(MenuItem(
label: const Text('Notificar por WhatsApp'),
icon: const Icon(Icons.chat_outlined,
size: 16, color: Color(0xFF25D366)),
value: 'whatsapp',
));
}
entries.add(MenuItem(
label: const Text('Marcar como resuelta'),
icon: const Icon(Icons.notifications_none, size: 16),
value: 'resuelta',
));
} else {
if (reserva.telefono != null) {
entries.add(MenuItem(
label: const Text('Notificar por WhatsApp'),
icon: const Icon(Icons.chat_outlined,
size: 16, color: Color(0xFF25D366)),
value: 'whatsapp',
));
}
entries.add(MenuItem(
label: const Text('Volver a pendiente'),
icon: const Icon(Icons.undo, size: 16),
value: 'pendiente',
));
}
showContextMenu<String>(
context,
contextMenu: ContextMenu<String>(
position: details.globalPosition,
entries: entries,
),
onItemSelected: (v) {
switch (v) {
case 'reubicar':
onReubicar();
case 'whatsapp':
onWhatsApp();
case 'resuelta':
onResolver('resuelta');
case 'pendiente':
onResolver('pendiente');
}
},
);
},
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: modoSeleccion && seleccionada
? SomaColors.primary.withAlpha(140)
: theme.colorScheme.surfaceContainerHighest,
width: modoSeleccion && seleccionada ? 1.5 : 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Checkbox en modo selección / rail de estado normal
if (modoSeleccion)
_SelectionRail(seleccionada: seleccionada)
else
Container(
width: 4,
color: switch (reserva.estado) {
EstadoHuerfana.pendiente => SomaColors.error.withAlpha(180),
EstadoHuerfana.reubicado => SomaColors.primary.withAlpha(180),
EstadoHuerfana.resuelta =>
theme.colorScheme.secondary.withAlpha(180),
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row superior: avatar + info + badge
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(
reserva.initials,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: SomaColors.primaryText,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reserva.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (reserva.telefono != null)
Text(
reserva.telefono!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
),
if (!modoSeleccion) _EstadoBadge(estado: reserva.estado),
],
),
const SizedBox(height: 10),
// Actividad + fecha
Row(
children: [
Icon(Icons.sports_gymnastics_outlined,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(120)),
const SizedBox(width: 5),
Expanded(
child: Text(
reserva.actividadNombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(180),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(Icons.calendar_today_outlined,
size: 13,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
'${_fmtFecha(reserva.fechaOriginal)} ${reserva.horaInicioOriginal}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(150),
fontFeatures: const [
FontFeature.tabularFigures()
],
),
),
],
),
// Acciones (sólo cuando no estamos en modo selección)
if (!modoSeleccion) ...[
if (isPendiente) ...[
const SizedBox(height: 10),
Row(
children: [
_ActionButton(
icon: Icons.chat_outlined,
label: 'WhatsApp',
color: const Color(0xFF25D366),
onTap: onWhatsApp,
),
const SizedBox(width: 8),
_ActionButton(
icon: Icons.swap_horiz,
label: 'Reubicar',
color: SomaColors.primary,
onTap: () => onReubicar(),
),
const SizedBox(width: 8),
_ActionButton(
icon: Icons.notifications_none,
label: 'Resuelta',
color: theme.colorScheme.secondary,
onTap: () => onResolver('resuelta'),
),
],
),
] else ...[
const SizedBox(height: 6),
Row(
children: [
_ActionButton(
icon: Icons.chat_outlined,
label: 'WhatsApp',
color: const Color(0xFF25D366),
onTap: onWhatsApp,
),
const SizedBox(width: 8),
InkWell(
onTap: () => onResolver('pendiente'),
borderRadius: BorderRadius.circular(6),
mouseCursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Text(
'Volver a pendiente',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface
.withAlpha(120),
decoration: TextDecoration.underline,
decorationColor:
theme.colorScheme.onSurface
.withAlpha(80),
),
),
),
),
],
),
],
],
],
),
),
),
],
),
),
),
);
}
}
// ── Rail de selección ─────────────────────────────────────────────────────────
class _SelectionRail extends StatelessWidget {
final bool seleccionada;
const _SelectionRail({required this.seleccionada});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
color: seleccionada
? SomaColors.primary.withAlpha(20)
: Colors.transparent,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 18,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: seleccionada ? SomaColors.primary : Colors.transparent,
border: Border.all(
color: seleccionada
? SomaColors.primary
: Theme.of(context).colorScheme.onSurface.withAlpha(80),
width: 1.5,
),
),
child: seleccionada
? const Icon(Icons.check, size: 12, color: SomaColors.onPrimary)
: null,
),
),
);
}
}
// ── Badge de estado ───────────────────────────────────────────────────────────
class _EstadoBadge extends StatelessWidget {
final EstadoHuerfana estado;
const _EstadoBadge({required this.estado});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (label, color) = switch (estado) {
EstadoHuerfana.pendiente => ('Pendiente', SomaColors.error),
EstadoHuerfana.reubicado => ('Reubicado', SomaColors.primary),
EstadoHuerfana.resuelta =>
('Resuelta', theme.colorScheme.secondary),
};
final textColor = estado == EstadoHuerfana.reubicado
? SomaColors.primaryText
: color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: textColor,
),
),
);
}
}
// ── Botón de acción ───────────────────────────────────────────────────────────
class _ActionButton extends StatefulWidget {
final IconData icon;
final String label;
final Color color;
final dynamic Function() onTap;
const _ActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
State<_ActionButton> createState() => _ActionButtonState();
}
class _ActionButtonState extends State<_ActionButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => widget.onTap(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
color: widget.color.withAlpha(_hovered ? 38 : 16),
border: Border.all(
color: widget.color.withAlpha(_hovered ? 110 : 60),
width: _hovered ? 0.8 : 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 13, color: widget.color),
const SizedBox(width: 5),
Text(
widget.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: widget.color,
),
),
],
),
),
),
);
}
}
// ── Bottom bar de selección ───────────────────────────────────────────────────
class _SelectionBar extends StatelessWidget {
final int count;
final VoidCallback? onNotificar;
final VoidCallback onCancelar;
const _SelectionBar({
required this.count,
required this.onNotificar,
required this.onCancelar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 14),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.surfaceContainerHighest,
width: 1,
),
),
),
child: Row(
children: [
Text(
count == 0
? 'Sin selección'
: count == 1
? '1 seleccionado'
: '$count seleccionados',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
const Spacer(),
TextButton(
onPressed: onCancelar,
child: const Text('Cancelar'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 38)),
onPressed: onNotificar,
icon: const Icon(Icons.chat_outlined, size: 16),
label: Text(
count == 0
? 'Notificar en lote'
: 'Notificar $count por WhatsApp',
),
),
],
),
);
}
}
@@ -0,0 +1,376 @@
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/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
class BulkNotifyDialog extends ConsumerStatefulWidget {
final List<ReservaHuerfana> seleccionadas;
const BulkNotifyDialog({super.key, required this.seleccionadas});
@override
ConsumerState<BulkNotifyDialog> createState() => _BulkNotifyDialogState();
}
class _BulkNotifyDialogState extends ConsumerState<BulkNotifyDialog> {
late final Set<String> _listos = {};
bool _cargando = false;
String _fmtFecha(String fechaIso) {
final d = DateTime.tryParse(fechaIso);
if (d == null) return fechaIso;
return '${d.day} ${_meses[d.month]} ${d.year}';
}
String _mensajeWa(ReservaHuerfana r) =>
'Hola ${r.nombre}, te contactamos desde el gimnasio SOMA. '
'Tu reserva de ${r.actividadNombre} del ${_fmtFecha(r.fechaOriginal)} '
'quedó sin turno disponible. '
'Por favor, coordiná una nueva reserva cuando puedas. ¡Muchas gracias!';
Future<void> _abrirWa(ReservaHuerfana r) async {
final ok = await WhatsAppService.abrirChat(
telefono: r.telefono,
mensaje: _mensajeWa(r),
);
if (!mounted) return;
if (!ok) {
final sinNumero = WhatsAppService.normalizarNumeroAr(r.telefono) == null;
SomaToast.show(
context,
message: sinNumero
? '${r.displayName} no tiene número de teléfono registrado. '
'Podés agregarlo desde la pantalla de Usuarios.'
: 'No se pudo abrir WhatsApp para ${r.displayName}.',
type: ToastType.error,
);
}
}
Future<void> _marcarListos() async {
if (_listos.isEmpty) return;
setState(() => _cargando = true);
await ref.read(huerfanasProvider.notifier).notificarLote(_listos);
if (!mounted) return;
final n = _listos.length;
Navigator.pop(context);
SomaToast.show(
context,
message: n == 1 ? '1 reserva marcada como resuelta' : '$n reservas marcadas como resueltas',
type: ToastType.success,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final n = widget.seleccionadas.length;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
n == 1 ? 'Notificar a 1 cliente' : 'Notificar a $n clientes',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 6, 24, 12),
child: Text(
'Abrí WhatsApp para cada cliente y marcá "Listo" cuando lo hayas enviado.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
),
const Divider(height: 1),
// Lista
Flexible(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
shrinkWrap: true,
itemCount: widget.seleccionadas.length,
separatorBuilder: (_, _) => const Divider(height: 1, indent: 20, endIndent: 20),
itemBuilder: (_, i) {
final r = widget.seleccionadas[i];
final listo = _listos.contains(r.huerfanaId);
return _ClienteRow(
reserva: r,
listo: listo,
onAbrirWa: () => _abrirWa(r),
onToggleListo: () {
setState(() {
if (listo) {
_listos.remove(r.huerfanaId);
} else {
_listos.add(r.huerfanaId);
}
});
},
);
},
),
),
const Divider(height: 1),
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Row(
children: [
if (_listos.isNotEmpty)
Text(
'${_listos.length} listo${_listos.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(150),
),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cerrar'),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 40)),
onPressed: _listos.isEmpty || _cargando ? null : _marcarListos,
child: _cargando
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary,
),
)
: Text(
_listos.isEmpty
? 'Marcar como resueltas'
: 'Marcar ${_listos.length} como resuelta${_listos.length == 1 ? '' : 's'}',
),
),
],
),
),
],
),
),
);
}
}
class _ClienteRow extends StatefulWidget {
final ReservaHuerfana reserva;
final bool listo;
final VoidCallback onAbrirWa;
final VoidCallback onToggleListo;
const _ClienteRow({
required this.reserva,
required this.listo,
required this.onAbrirWa,
required this.onToggleListo,
});
@override
State<_ClienteRow> createState() => _ClienteRowState();
}
class _ClienteRowState extends State<_ClienteRow> {
bool _hoveredWa = false;
bool _hoveredListo = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const waColor = Color(0xFF25D366);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(28),
child: Text(
widget.reserva.initials,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: SomaColors.primaryText,
),
),
),
const SizedBox(width: 10),
// Nombre + teléfono
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.reserva.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.reserva.telefono ?? 'Sin teléfono',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(
widget.reserva.telefono != null ? 130 : 80,
),
),
),
],
),
),
// Botón WhatsApp
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoveredWa = true),
onExit: (_) => setState(() => _hoveredWa = false),
child: GestureDetector(
onTap: widget.onAbrirWa,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
color: waColor.withAlpha(_hoveredWa ? 38 : 16),
border: Border.all(
color: waColor.withAlpha(_hoveredWa ? 110 : 60),
width: _hoveredWa ? 0.8 : 0.5,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_outlined, size: 13, color: waColor),
SizedBox(width: 4),
Text(
'Abrir WA',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: waColor,
),
),
],
),
),
),
),
const SizedBox(width: 10),
// Toggle "Listo"
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoveredListo = true),
onExit: (_) => setState(() => _hoveredListo = false),
child: GestureDetector(
onTap: widget.onToggleListo,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: widget.listo
? SomaColors.primary.withAlpha(
_hoveredListo ? 35 : 20,
)
: _hoveredListo
? theme.colorScheme.onSurface.withAlpha(10)
: Colors.transparent,
border: Border.all(
color: widget.listo
? SomaColors.primary.withAlpha(
_hoveredListo ? 160 : 100,
)
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 90 : 50,
),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check,
size: 12,
color: widget.listo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 100 : 60,
),
),
const SizedBox(width: 3),
Text(
'Listo',
style: TextStyle(
fontSize: 11,
fontWeight: widget.listo
? FontWeight.w700
: FontWeight.w500,
color: widget.listo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(
_hoveredListo ? 140 : 100,
),
),
),
],
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,642 @@
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/huerfanas/domain/entities/reserva_huerfana.dart';
import 'package:gimnasio_soma/features/huerfanas/presentation/providers/huerfanas_provider.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
const _meses = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
class TurnoPickerSheet extends ConsumerStatefulWidget {
final ReservaHuerfana reserva;
/// Se invoca tras reubicar exitosamente con el turno elegido y la fecha del día.
/// El llamador usa esta info para mostrar el toast de éxito con la acción de WA.
final void Function(Turno turno, DateTime fecha)? onReubicadoExito;
const TurnoPickerSheet({
super.key,
required this.reserva,
this.onReubicadoExito,
});
@override
ConsumerState<TurnoPickerSheet> createState() => _TurnoPickerSheetState();
}
class _TurnoPickerSheetState extends ConsumerState<TurnoPickerSheet> {
late DateTime _semanaActual;
bool _soloMismaActividad = true;
AsyncValue<SemanaTurnos> _semanaTurnos = const AsyncValue.loading();
bool _reubicando = false;
@override
void initState() {
super.initState();
_semanaActual = _lunesDe(DateTime.now());
WidgetsBinding.instance.addPostFrameCallback((_) => _cargarSemana());
}
DateTime _lunesDe(DateTime d) =>
DateTime(d.year, d.month, d.day - (d.weekday - 1));
Future<void> _cargarSemana() async {
setState(() => _semanaTurnos = const AsyncValue.loading());
try {
final repo = ref.read(turnosRepositoryProvider);
final semana = await repo.obtenerSemana(_semanaActual);
if (mounted) setState(() => _semanaTurnos = AsyncValue.data(semana));
} catch (e, st) {
if (mounted) setState(() => _semanaTurnos = AsyncValue.error(e, st));
}
}
void _irSemanaAnterior() {
setState(() => _semanaActual = _semanaActual.subtract(const Duration(days: 7)));
_cargarSemana();
}
void _irSemanaSiguiente() {
setState(() => _semanaActual = _semanaActual.add(const Duration(days: 7)));
_cargarSemana();
}
String _fmtSemana() {
final fin = _semanaActual.add(const Duration(days: 6));
final inicioStr = '${_semanaActual.day} ${_meses[_semanaActual.month]}';
final finStr = '${fin.day} ${_meses[fin.month]}';
return 'Sem del $inicioStr al $finStr';
}
String _fmtFechaCorta(DateTime d) =>
'${_diasSemana[d.weekday]} ${d.day} ${_meses[d.month]}';
Future<void> _seleccionarTurno(Turno turno, DiaTurnos dia) async {
final nombreFmt =
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]} ${turno.horaInicio}';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmar reubicación'),
content: Text(
'¿Reubicar a ${widget.reserva.displayName} al turno de '
'${turno.actividad.nombre} del $nombreFmt '
'(${turno.disponible}/${turno.capacidadMaxima} cupos)?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 36)),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Confirmar'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _reubicando = true);
final error = await ref
.read(huerfanasProvider.notifier)
.mover(widget.reserva.huerfanaId, turno.id);
if (!mounted) return;
setState(() => _reubicando = false);
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
final callback = widget.onReubicadoExito;
Navigator.pop(context);
callback?.call(turno, dia.fecha);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DraggableScrollableSheet(
initialChildSize: 0.72,
maxChildSize: 0.92,
minChildSize: 0.4,
expand: false,
builder: (ctx, scrollController) {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withAlpha(50),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Reubicar a ${widget.reserva.displayName}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
Text(
'${widget.reserva.actividadNombre} · '
'${_fmtFechaCorta(DateTime.tryParse(widget.reserva.fechaOriginal) ?? DateTime.now())} '
'${widget.reserva.horaInicioOriginal} (original)',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
const Divider(height: 16, indent: 20, endIndent: 20),
// Navegación semanal + filtro
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _irSemanaAnterior,
tooltip: 'Semana anterior',
),
Expanded(
child: Text(
_fmtSemana(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _irSemanaSiguiente,
tooltip: 'Semana siguiente',
),
],
),
),
// Toggle filtro actividad
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: _FiltroToggle(
actividadNombre: widget.reserva.actividadNombre,
soloMismaActividad: _soloMismaActividad,
onChanged: (v) => setState(() => _soloMismaActividad = v),
),
),
// Contenido
Expanded(
child: _reubicando
? const Center(
child: CircularProgressIndicator(
color: SomaColors.primary,
),
)
: _semanaTurnos.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: SomaColors.primary,
),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 40,
color: theme.colorScheme.onSurface.withAlpha(80),
),
const SizedBox(height: 8),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _cargarSemana,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reintentar'),
),
],
),
),
data: (semana) => _buildDias(
semana,
scrollController,
theme,
),
),
),
],
),
);
},
);
}
Widget _buildDias(
SemanaTurnos semana,
ScrollController scrollController,
ThemeData theme,
) {
final dias = List.generate(7, (i) {
final fecha = _semanaActual.add(Duration(days: i));
return semana.diaPara(fecha) ??
DiaTurnos(
fecha: fecha,
diaSemana: fecha.weekday,
estado: DiaEstado.cerrado,
turnos: const [],
);
});
// Filtrar turnos por actividad si aplica
List<Turno> turnosDelDia(DiaTurnos dia) {
if (dia.estado == DiaEstado.cerrado) return [];
final todos = dia.turnos.where((t) => !t.estaLleno).toList();
if (!_soloMismaActividad) return todos;
return todos
.where(
(t) =>
t.actividad.nombre.toLowerCase() ==
widget.reserva.actividadNombre.toLowerCase(),
)
.toList();
}
// Si con el filtro no hay nada en toda la semana, mostrar aviso
final hayAlgo = dias.any((d) => turnosDelDia(d).isNotEmpty);
if (!hayAlgo) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 44,
color: theme.colorScheme.onSurface.withAlpha(50),
),
const SizedBox(height: 10),
Text(
_soloMismaActividad
? 'Sin turnos disponibles de\n${widget.reserva.actividadNombre} esta semana'
: 'Sin turnos disponibles esta semana',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withAlpha(120),
),
),
],
),
);
}
return ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
itemCount: dias.length,
itemBuilder: (_, i) {
final dia = dias[i];
final turnos = turnosDelDia(dia);
if (turnos.isEmpty) return const SizedBox.shrink();
return _DiaSection(
dia: dia,
turnos: turnos,
onTurnoTap: _seleccionarTurno,
);
},
);
}
}
// ── Toggle filtro actividad ───────────────────────────────────────────────────
class _FiltroToggle extends StatelessWidget {
final String actividadNombre;
final bool soloMismaActividad;
final ValueChanged<bool> onChanged;
const _FiltroToggle({
required this.actividadNombre,
required this.soloMismaActividad,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 30,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
_ToggleItem(
label: actividadNombre,
selected: soloMismaActividad,
onTap: () => onChanged(true),
),
_ToggleItem(
label: 'Todas las actividades',
selected: !soloMismaActividad,
onTap: () => onChanged(false),
),
],
),
);
}
}
class _ToggleItem extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ToggleItem({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: selected ? theme.colorScheme.surface : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withAlpha(18),
blurRadius: 4,
offset: const Offset(0, 1),
)
]
: null,
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight:
selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(140),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
}
}
// ── Sección de día con turnos ─────────────────────────────────────────────────
class _DiaSection extends StatelessWidget {
final DiaTurnos dia;
final List<Turno> turnos;
final Future<void> Function(Turno, DiaTurnos) onTurnoTap;
const _DiaSection({
required this.dia,
required this.turnos,
required this.onTurnoTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label =
'${_diasSemana[dia.fecha.weekday]} ${dia.fecha.day} ${_meses[dia.fecha.month]}';
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(160),
letterSpacing: 0.3,
),
),
),
...turnos.map((t) => _TurnoPill(turno: t, dia: dia, onTap: onTurnoTap)),
],
),
);
}
}
class _TurnoPill extends StatefulWidget {
final Turno turno;
final DiaTurnos dia;
final Future<void> Function(Turno, DiaTurnos) onTap;
const _TurnoPill({
required this.turno,
required this.dia,
required this.onTap,
});
@override
State<_TurnoPill> createState() => _TurnoPillState();
}
class _TurnoPillState extends State<_TurnoPill> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => widget.onTap(widget.turno, widget.dia),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail izquierdo
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 3,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(_hovered ? 255 : 180),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(4),
),
),
),
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
),
decoration: BoxDecoration(
color: _hovered
? SomaColors.primary.withAlpha(12)
: theme.colorScheme.surface,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(8),
),
border: Border.all(
color: _hovered
? SomaColors.primary.withAlpha(80)
: theme.colorScheme.surfaceContainerHighest,
width: _hovered ? 0.8 : 0.5,
),
),
child: Row(
children: [
// Hora
SizedBox(
width: 46,
child: Text(
widget.turno.horaInicio,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: theme.colorScheme.surfaceContainerHighest,
),
// Actividad
Expanded(
child: Text(
widget.turno.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(200),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Cupos
Text(
'${widget.turno.disponible}/${widget.turno.capacidadMaxima}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.turno.disponible <= 2
? SomaColors.error.withAlpha(200)
: SomaColors.primary.withAlpha(200),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(width: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 120),
child: Icon(
Icons.arrow_forward_ios_rounded,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(_hovered ? 160 : 80),
),
),
],
),
),
),
],
),
),
),
),
);
}
}
@@ -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;
}
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/core/widgets/soma_primary_button.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
/// Autoservicio: el propio usuario logueado cambia su contraseña,
/// confirmando primero la actual. Accesible desde Perfil para admin/superadmin.
class CambiarContrasenaScreen extends ConsumerStatefulWidget {
const CambiarContrasenaScreen({super.key});
@override
ConsumerState<CambiarContrasenaScreen> createState() =>
_CambiarContrasenaScreenState();
}
class _CambiarContrasenaScreenState
extends ConsumerState<CambiarContrasenaScreen> {
final _formKey = GlobalKey<FormState>();
final _actualCtrl = TextEditingController();
final _nuevaCtrl = TextEditingController();
final _repetirCtrl = TextEditingController();
bool _obscureActual = true;
bool _obscureNueva = true;
bool _obscureRepetir = true;
bool _isLoading = false;
@override
void dispose() {
_actualCtrl.dispose();
_nuevaCtrl.dispose();
_repetirCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref.read(authRepositoryProvider).cambiarPropiaContrasena(
passwordActual: _actualCtrl.text,
passwordNueva: _nuevaCtrl.text,
);
if (!mounted) return;
SomaToast.show(context,
message: 'Contraseña actualizada', type: ToastType.success);
context.pop();
} catch (e) {
if (!mounted) return;
SomaToast.show(
context,
message: e.toString().replaceFirst('Exception: ', ''),
type: ToastType.error,
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Widget _passwordField({
required TextEditingController controller,
required String label,
required bool obscure,
required VoidCallback onToggle,
String? Function(String?)? validator,
}) {
return SomaTextField(
controller: controller,
labelText: label,
prefixIcon: Icons.lock_outline,
obscureText: obscure,
suffixIcon: IconButton(
icon: Icon(
obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: Theme.of(context).colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: onToggle,
),
validator: validator,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cambiar contraseña')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_passwordField(
controller: _actualCtrl,
label: 'Contraseña actual',
obscure: _obscureActual,
onToggle: () =>
setState(() => _obscureActual = !_obscureActual),
validator: (v) => (v == null || v.isEmpty)
? 'Ingresá tu contraseña actual'
: null,
),
const SizedBox(height: 16),
_passwordField(
controller: _nuevaCtrl,
label: 'Contraseña nueva',
obscure: _obscureNueva,
onToggle: () =>
setState(() => _obscureNueva = !_obscureNueva),
validator: (v) {
if (v == null || v.isEmpty) {
return 'Ingresá la contraseña nueva';
}
if (v.length < 8) return 'Mínimo 8 caracteres';
return null;
},
),
const SizedBox(height: 16),
_passwordField(
controller: _repetirCtrl,
label: 'Repetir contraseña nueva',
obscure: _obscureRepetir,
onToggle: () =>
setState(() => _obscureRepetir = !_obscureRepetir),
validator: (v) {
if (v != _nuevaCtrl.text) {
return 'Las contraseñas no coinciden';
}
return null;
},
),
const SizedBox(height: 28),
SomaPrimaryButton(
text: 'Guardar',
onPressed: _submit,
isLoading: _isLoading,
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
class PerfilScreen extends ConsumerWidget {
const PerfilScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authStateProvider).valueOrNull;
final isWide = MediaQuery.of(context).size.width >= 800;
final theme = Theme.of(context);
if (user == null) return const SizedBox.shrink();
String initials() {
if (user.nombre.isNotEmpty && user.apellido.isNotEmpty) {
return '${user.nombre[0]}${user.apellido[0]}'.toUpperCase();
}
if (user.nombre.isNotEmpty) return user.nombre[0].toUpperCase();
if (user.dni.length >= 2) return user.dni.substring(0, 2);
return '?';
}
String rolDisplay() {
switch (user.role) {
case 'superadmin':
return 'Super Admin';
case 'admin':
return 'Administrador';
case 'profesor':
return 'Profesor';
case 'cliente':
return 'Cliente';
default:
return user.role;
}
}
return Scaffold(
body: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
32,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
children: [
// Header
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Mi Perfil',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 24),
// Avatar con ring amarillo
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: SomaColors.primary,
width: 2.5,
),
),
child: CircleAvatar(
radius: 40,
backgroundColor: SomaColors.primary.withAlpha(35),
child: Text(
initials(),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(height: 14),
Text(
user.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: SomaColors.primary.withAlpha(60),
width: 0.5,
),
),
child: Text(
rolDisplay(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
),
const SizedBox(height: 28),
// Info cards
_InfoTile(
icon: Icons.badge_outlined,
label: 'DNI',
value: user.dni,
),
if (user.mail != null && user.mail!.isNotEmpty)
_InfoTile(
icon: Icons.email_outlined,
label: 'Email',
value: user.mail!,
),
if (user.telefono != null && user.telefono!.isNotEmpty)
_InfoTile(
icon: Icons.phone_outlined,
label: 'Teléfono',
value: user.telefono!,
),
const SizedBox(height: 24),
// Cambiar contraseña (staff)
if (user.isStaff)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: ListTile(
leading: Icon(
Icons.lock_outline,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(153),
),
title: const Text(
'Cambiar contraseña',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(100),
),
contentPadding: EdgeInsets.zero,
onTap: () => context.go('/perfil/cambiar-contrasena'),
),
),
),
// Logs (admin)
if (user.isStaff)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: ListTile(
leading: Icon(
Icons.terminal,
size: 20,
color:
theme.colorScheme.onSurface.withAlpha(153),
),
title: const Text(
'Ver logs',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color:
theme.colorScheme.onSurface.withAlpha(100),
),
contentPadding: EdgeInsets.zero,
onTap: () => context.go('/logs'),
),
),
),
const SizedBox(height: 16),
// Logout
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
ref.read(authStateProvider.notifier).logout(),
icon: const Icon(Icons.logout, size: 18),
label: const Text('Cerrar sesión'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
foregroundColor: SomaColors.error,
side: const BorderSide(
color: SomaColors.error, width: 0.8),
),
),
),
],
),
),
),
),
);
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoTile({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(16),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 18,
color: SomaColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,83 @@
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/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart';
class TiposCuotaRepositoryImpl implements TiposCuotaRepository {
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<TipoCuota>> getTiposCuota() async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetTiposCuota,
params: {'p_token': token},
);
if (response is List) {
return response
.map((e) => TipoCuota.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertTipoCuota(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertTipoCuota,
params: {'p_token': token, 'p_datos': datos},
);
}
@override
Future<void> updateTipoCuota(String id, Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateTipoCuota,
params: {'p_token': token, 'p_id': id, 'p_datos': datos},
);
}
@override
Future<bool> deleteTipoCuota(String id) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteTipoCuota,
params: {'p_token': token, 'p_id': id},
);
return response == true;
}
@override
Future<List<Map<String, dynamic>>> getActividadesTipoCuota(
String tipoCuotaId) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetActividadesTipoCuota,
params: {
'p_token': token,
'p_tipo_cuota_id': tipoCuotaId,
},
);
if (response is List) {
return response
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
return [];
}
}
@@ -0,0 +1,57 @@
class TipoCuota {
final String id;
final String nombre;
final String? descripcion;
final int diasSemana;
final double precio;
final bool paraSocios;
final int diaDePago;
final double? recargo;
final List<int> actividadesIds;
const TipoCuota({
required this.id,
required this.nombre,
this.descripcion,
required this.diasSemana,
required this.precio,
required this.paraSocios,
this.diaDePago = 10,
this.recargo,
this.actividadesIds = const [],
});
factory TipoCuota.fromMap(Map<String, dynamic> map) {
return TipoCuota(
id: map['id'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
descripcion: map['descripcion'] as String?,
diasSemana: (map['dias_semana'] as num?)?.toInt() ?? 0,
precio: (map['precio'] as num?)?.toDouble() ?? 0,
paraSocios: map['parasocios'] as bool? ?? false,
diaDePago: (map['dia_de_pago'] as num?)?.toInt() ?? 10,
recargo: (map['recargo'] as num?)?.toDouble(),
actividadesIds: (map['actividades_ids'] as List?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
);
}
Map<String, dynamic> toMap() {
return {
'nombre': nombre,
'descripcion': descripcion,
'dias_semana': diasSemana,
'precio': precio,
'para_socios': paraSocios,
'dia_de_pago': diaDePago,
if (recargo != null) 'recargo': recargo,
if (actividadesIds.isNotEmpty) 'actividades_ids': actividadesIds,
};
}
String get precioDisplay => '\$${precio.toStringAsFixed(precio.truncateToDouble() == precio ? 0 : 2)}';
String get diasDisplay => '$diasSemana día${diasSemana != 1 ? 's' : ''}/sem';
}
@@ -0,0 +1,11 @@
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
abstract class TiposCuotaRepository {
Future<List<TipoCuota>> getTiposCuota();
Future<void> insertTipoCuota(Map<String, dynamic> datos);
Future<void> updateTipoCuota(String id, Map<String, dynamic> datos);
Future<bool> deleteTipoCuota(String id);
/// Obtener actividades asociadas a un tipo de cuota.
Future<List<Map<String, dynamic>>> getActividadesTipoCuota(String tipoCuotaId);
}
@@ -0,0 +1,71 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/tipos_cuota/data/repositories/tipos_cuota_repository_impl.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/repositories/tipos_cuota_repository.dart';
final tiposCuotaRepositoryProvider = Provider<TiposCuotaRepository>((ref) {
return TiposCuotaRepositoryImpl();
});
/// Actividades asociadas a un tipo de cuota (por id).
final actividadesTipoCuotaProvider =
FutureProvider.family<List<Map<String, dynamic>>, String>((ref, id) async {
final repo = ref.read(tiposCuotaRepositoryProvider);
return repo.getActividadesTipoCuota(id);
});
final tiposCuotaProvider =
StateNotifierProvider<TiposCuotaNotifier, AsyncValue<List<TipoCuota>>>(
(ref) {
return TiposCuotaNotifier(ref.read(tiposCuotaRepositoryProvider));
});
class TiposCuotaNotifier extends StateNotifier<AsyncValue<List<TipoCuota>>> {
final TiposCuotaRepository _repository;
TiposCuotaNotifier(this._repository)
: super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
try {
final data = await _repository.getTiposCuota();
state = AsyncValue.data(data);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertTipoCuota(Map<String, dynamic> datos) async {
try {
await _repository.insertTipoCuota(datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> updateTipoCuota(
String id, Map<String, dynamic> datos) async {
try {
await _repository.updateTipoCuota(id, datos);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> deleteTipoCuota(String id) async {
try {
await _repository.deleteTipoCuota(id);
await load();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
@@ -0,0 +1,616 @@
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/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/widgets/tipo_cuota_form_dialog.dart';
class TiposCuotaScreen extends ConsumerStatefulWidget {
const TiposCuotaScreen({super.key});
@override
ConsumerState<TiposCuotaScreen> createState() => _TiposCuotaScreenState();
}
class _TiposCuotaScreenState extends ConsumerState<TiposCuotaScreen> {
Future<void> _showCreateDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const TipoCuotaFormDialog(),
);
if (result == null || !mounted) return;
final error =
await ref.read(tiposCuotaProvider.notifier).insertTipoCuota(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan creado', type: ToastType.success);
}
}
Future<void> _showEditDialog(TipoCuota tc) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => TipoCuotaFormDialog(tipoCuota: tc),
);
if (result == null || !mounted) return;
final error = await ref
.read(tiposCuotaProvider.notifier)
.updateTipoCuota(tc.id, result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan actualizado', type: ToastType.success);
}
}
Future<void> _deleteTipoCuota(TipoCuota tc) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar plan'),
content: Text(
'¿Estás seguro de que querés eliminar "${tc.nombre}"?\n'
'Los usuarios con este plan quedarán sin cuota asignada.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error =
await ref.read(tiposCuotaProvider.notifier).deleteTipoCuota(tc.id);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Plan eliminado', type: ToastType.success);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(tiposCuotaProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Planes',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.add,
text: 'Creá un plan con precio, días de uso y día de '
'pago. Los usuarios se asignan a un plan desde '
'Usuarios.',
),
],
),
const Spacer(),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.read(tiposCuotaProvider.notifier).load(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (planes) {
if (planes.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.card_membership_outlined,
size: 56,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay planes de cuota',
style: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () =>
ref.read(tiposCuotaProvider.notifier).load(),
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: planes.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final tc = planes[index];
return _TipoCuotaCard(
tipoCuota: tc,
onEdit: () => _showEditDialog(tc),
onDelete: () => _deleteTipoCuota(tc),
);
},
),
);
},
),
),
],
),
);
}
}
class _TipoCuotaCard extends ConsumerStatefulWidget {
final TipoCuota tipoCuota;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _TipoCuotaCard({
required this.tipoCuota,
required this.onEdit,
required this.onDelete,
});
@override
ConsumerState<_TipoCuotaCard> createState() => _TipoCuotaCardState();
}
class _TipoCuotaCardState extends ConsumerState<_TipoCuotaCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final tc = widget.tipoCuota;
final theme = Theme.of(context);
return InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de plan — amarillo
Container(width: 4, color: SomaColors.primary),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
child: Column(
children: [
Row(
children: [
// Precio destacado en stamp
Container(
constraints: const BoxConstraints(
minWidth: 56,
minHeight: 48,
),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(18),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: SomaColors.primary.withAlpha(40),
width: 0.5,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
tc.precioDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
height: 1,
),
),
Text(
'/mes',
style: TextStyle(
fontSize: 9,
color: theme.colorScheme.onSurface
.withAlpha(100),
),
),
],
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
tc.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (tc.paraSocios) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.primary
.withAlpha(18),
borderRadius:
BorderRadius.circular(5),
border: Border.all(
color: SomaColors.primary
.withAlpha(60),
width: 0.5,
),
),
child: const Text(
'Socios',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
),
],
],
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_view_week,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(100)),
const SizedBox(width: 3),
Text(
tc.diasDisplay,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event,
size: 12,
color: theme.colorScheme.onSurface
.withAlpha(100)),
const SizedBox(width: 3),
Text(
'Vence el ${tc.diaDePago}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
if (tc.recargo != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_circle_outline,
size: 12,
color: SomaColors.error
.withAlpha(160)),
const SizedBox(width: 3),
Text(
'Recargo \$${tc.recargo!.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 12,
color: SomaColors.error,
),
),
],
),
],
),
],
),
),
Icon(
_expanded
? Icons.expand_less
: Icons.expand_more,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(120),
),
// Actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color:
theme.colorScheme.onSurface.withAlpha(100),
),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Eliminar',
style:
TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') widget.onEdit();
if (v == 'delete') widget.onDelete();
},
),
],
),
// Actividades expandibles
if (_expanded) _buildActividades(theme),
],
),
),
),
],
),
),
),
);
}
Widget _buildActividades(ThemeData theme) {
final actividadesAsync =
ref.watch(actividadesTipoCuotaProvider(widget.tipoCuota.id));
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
height: 1,
color: theme.colorScheme.surfaceContainerHighest,
),
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.sports_gymnastics,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 6),
Text(
'Actividades incluidas',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
const SizedBox(height: 8),
actividadesAsync.when(
loading: () => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
error: (_, _) => Text(
'Error cargando actividades',
style: TextStyle(fontSize: 12, color: SomaColors.error),
),
data: (actividades) {
if (actividades.isEmpty) {
return Text(
'Sin actividades asignadas',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
);
}
return Wrap(
spacing: 6,
runSpacing: 4,
children: actividades.map((a) {
final nombre = a['nombre'] ?? '';
final activo = a['activo'] == true;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: activo
? SomaColors.primary.withAlpha(15)
: theme.colorScheme.onSurface.withAlpha(8),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: activo
? SomaColors.primary.withAlpha(50)
: theme.colorScheme.onSurface.withAlpha(30),
width: 0.5,
),
),
child: Text(
nombre.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: activo
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(80),
),
),
);
}).toList(),
);
},
),
],
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nuevo'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
class TipoCuotaFormDialog extends StatefulWidget {
final TipoCuota? tipoCuota;
const TipoCuotaFormDialog({super.key, this.tipoCuota});
@override
State<TipoCuotaFormDialog> createState() => _TipoCuotaFormDialogState();
}
class _TipoCuotaFormDialogState extends State<TipoCuotaFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nombreCtrl;
late final TextEditingController _descripcionCtrl;
late final TextEditingController _diasCtrl;
late final TextEditingController _precioCtrl;
late final TextEditingController _diaPagoCtrl;
late final TextEditingController _recargoCtrl;
late bool _paraSocios;
bool get _isEditing => widget.tipoCuota != null;
@override
void initState() {
super.initState();
final tc = widget.tipoCuota;
_nombreCtrl = TextEditingController(text: tc?.nombre ?? '');
_descripcionCtrl = TextEditingController(text: tc?.descripcion ?? '');
_diasCtrl = TextEditingController(
text: tc != null ? tc.diasSemana.toString() : '');
_precioCtrl = TextEditingController(
text: tc != null ? tc.precio.toStringAsFixed(2) : '');
_diaPagoCtrl = TextEditingController(
text: tc != null ? tc.diaDePago.toString() : '10');
_recargoCtrl = TextEditingController(
text: tc?.recargo != null ? tc!.recargo!.toStringAsFixed(2) : '');
_paraSocios = tc?.paraSocios ?? false;
}
@override
void dispose() {
_nombreCtrl.dispose();
_descripcionCtrl.dispose();
_diasCtrl.dispose();
_precioCtrl.dispose();
_diaPagoCtrl.dispose();
_recargoCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'nombre': _nombreCtrl.text.trim(),
'dias_semana': int.tryParse(_diasCtrl.text.trim()) ?? 0,
'precio': double.tryParse(_precioCtrl.text.trim()) ?? 0,
'para_socios': _paraSocios,
'dia_de_pago': int.tryParse(_diaPagoCtrl.text.trim()) ?? 10,
};
final desc = _descripcionCtrl.text.trim();
if (desc.isNotEmpty) data['descripcion'] = desc;
final recargo = double.tryParse(_recargoCtrl.text.trim());
if (recargo != null) data['recargo'] = recargo;
Navigator.of(context).pop(data);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 480) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
_isEditing ? 'Editar Plan' : 'Nuevo Plan',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.label_outline,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 16),
SomaTextField(
controller: _descripcionCtrl,
labelText: 'Descripción',
prefixIcon: Icons.notes,
maxLines: 2,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _diasCtrl,
labelText: 'Días/semana *',
prefixIcon: Icons.calendar_view_week,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(1),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n < 1 || n > 7) {
return '1-7';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _precioCtrl,
labelText: 'Precio *',
prefixIcon: Icons.attach_money,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = double.tryParse(v.trim());
if (n == null || n < 0) return 'Inválido';
return null;
},
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _diaPagoCtrl,
labelText: 'Día de pago',
prefixIcon: Icons.event,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _recargoCtrl,
labelText: 'Recargo',
prefixIcon: Icons.trending_up,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
),
),
],
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text(
'Para socios',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: const Text(
'Plan exclusivo para socios',
style: TextStyle(fontSize: 12),
),
value: _paraSocios,
activeThumbColor: SomaColors.primary,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _paraSocios = v),
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(_isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,142 @@
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/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart';
class TurnosRepositoryImpl implements TurnosRepository {
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;
}
String _formatDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
@override
Future<SemanaTurnos> obtenerSemana(DateTime weekStart) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerTurnos,
params: {
'p_token': token,
'p_fecha_inicio': _formatDate(weekStart),
'p_cantidad_dias': 7,
},
);
if (response is Map) {
return SemanaTurnos.fromResponse(
weekStart,
response.cast<String, dynamic>(),
);
}
return SemanaTurnos.fromResponse(weekStart, const {});
}
@override
Future<void> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
}) async {
final token = await _getToken();
// Admin workaround documentado en function_guide: fc_upsert_turno
// está marcado BACKEND y se usa intencionalmente sólo desde aquí
// para que Juani agregue un turno suelto sin tocar el schedule.
await SupabaseConfig.rpc(
AppConstants.rpcUpsertTurno,
params: {
'p_token': token,
'p_fecha': _formatDate(fecha),
'p_actividad_id': actividadId,
'p_hora_inicio': horaInicio,
'p_hora_fin': horaFin,
'p_capacidad_maxima': capacidad,
},
);
}
@override
Future<List<InscriptoTurno>> obtenerInscriptos(String turnoId) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerReservasTurno,
params: {'p_token': token, 'p_turno_id': turnoId},
);
if (response is List) {
return response
.map((e) => InscriptoTurno.fromMap((e as Map).cast<String, dynamic>()))
.toList();
}
return const [];
}
@override
Future<void> reservarAdmin({
required String turnoId,
required String clienteId,
}) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcReservarTurnoAdmin,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_turno_id': turnoId,
},
);
}
@override
Future<void> cancelarReservaAdmin(String reservaId) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcCancelarReservaAdmin,
params: {'p_token': token, 'p_reserva_id': reservaId},
);
}
@override
Future<EstadoCupo> obtenerEstadoCupo({
required String clienteId,
required DateTime fecha,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcObtenerEstadoCupo,
params: {
'p_token': token,
'p_cliente_id': clienteId,
'p_fecha': _formatDate(fecha),
},
);
if (response is Map) {
return EstadoCupo.fromMap(response.cast<String, dynamic>());
}
return const EstadoCupo(
usados: 0,
disponibles: 0,
limiteTotal: 0,
tienePlan: false,
);
}
@override
Future<Map<String, dynamic>> limpiarTurnosAntiguos({
int diasAntiguedad = 30,
}) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcLimpiarTurnosAntiguos,
params: {'p_token': token, 'p_dias_antiguedad': diasAntiguedad},
);
if (response is Map) return response.cast<String, dynamic>();
return {'status': 'error', 'mensaje': 'Respuesta inesperada'};
}
}
@@ -0,0 +1,195 @@
enum DiaEstado { cerrado, normal, horarioDiferente }
DiaEstado _parseEstado(String? raw) {
switch (raw) {
case 'cerrado':
return DiaEstado.cerrado;
case 'horario_diferente':
return DiaEstado.horarioDiferente;
default:
return DiaEstado.normal;
}
}
class TurnoActividad {
final int id;
final String nombre;
final bool libre;
const TurnoActividad({
required this.id,
required this.nombre,
required this.libre,
});
factory TurnoActividad.fromMap(Map<String, dynamic> map) {
return TurnoActividad(
id: (map['id'] as num?)?.toInt() ?? 0,
nombre: map['nombre'] as String? ?? '',
libre: map['libre'] as bool? ?? false,
);
}
}
class Turno {
final String id;
final String horaInicio;
final String horaFin;
final int capacidadMaxima;
final int ocupacion;
final TurnoActividad actividad;
const Turno({
required this.id,
required this.horaInicio,
required this.horaFin,
required this.capacidadMaxima,
required this.ocupacion,
required this.actividad,
});
factory Turno.fromMap(Map<String, dynamic> map) {
return Turno(
id: map['id'] as String? ?? '',
horaInicio: map['hora_inicio'] as String? ?? '',
horaFin: map['hora_fin'] as String? ?? '',
capacidadMaxima: (map['capacidad_maxima'] as num?)?.toInt() ?? 0,
ocupacion: (map['ocupacion'] as num?)?.toInt() ?? 0,
actividad: TurnoActividad.fromMap(
(map['actividad'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
}
int get disponible {
final libre = capacidadMaxima - ocupacion;
return libre < 0 ? 0 : libre;
}
bool get estaLleno => capacidadMaxima > 0 && disponible == 0;
}
class DiaTurnos {
final DateTime fecha;
final int diaSemana;
final DiaEstado estado;
final List<Turno> turnos;
const DiaTurnos({
required this.fecha,
required this.diaSemana,
required this.estado,
required this.turnos,
});
factory DiaTurnos.fromMap(DateTime fecha, Map<String, dynamic> map) {
final lista = (map['turnos'] as List?)
?.map((e) => Turno.fromMap((e as Map).cast<String, dynamic>()))
.toList() ??
const <Turno>[];
return DiaTurnos(
fecha: fecha,
diaSemana: (map['dia_semana'] as num?)?.toInt() ?? fecha.weekday,
estado: _parseEstado(map['estado'] as String?),
turnos: lista,
);
}
}
class SemanaTurnos {
final DateTime weekStart;
final Map<String, DiaTurnos> _byKey;
const SemanaTurnos._(this.weekStart, this._byKey);
factory SemanaTurnos.fromResponse(
DateTime weekStart,
Map<String, dynamic> raw,
) {
final map = <String, DiaTurnos>{};
raw.forEach((dateKey, value) {
if (value is! Map) return;
final fecha = DateTime.tryParse(dateKey);
if (fecha == null) return;
final f = DateTime(fecha.year, fecha.month, fecha.day);
map[_keyFor(f)] =
DiaTurnos.fromMap(f, value.cast<String, dynamic>());
});
return SemanaTurnos._(weekStart, map);
}
static String _keyFor(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
DiaTurnos? diaPara(DateTime fecha) => _byKey[_keyFor(fecha)];
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_keyFor(fecha));
}
class InscriptoTurno {
final String reservaId;
final String clienteId;
final String nombre;
final String? apellido;
final bool cancelada;
const InscriptoTurno({
required this.reservaId,
required this.clienteId,
required this.nombre,
this.apellido,
this.cancelada = false,
});
factory InscriptoTurno.fromMap(Map<String, dynamic> map) {
return InscriptoTurno(
reservaId: map['reserva_id'] as String? ?? '',
clienteId: map['cliente_id'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String?,
cancelada: map['cancelada'] as bool? ?? false,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '$nombre $apellido';
}
return nombre.isNotEmpty ? nombre : '?';
}
String get initials {
if (nombre.isNotEmpty) {
if (apellido != null && apellido!.isNotEmpty) {
return '${nombre[0]}${apellido![0]}'.toUpperCase();
}
return nombre[0].toUpperCase();
}
return '?';
}
}
class EstadoCupo {
final int usados;
final int disponibles;
final int limiteTotal;
final bool tienePlan;
const EstadoCupo({
required this.usados,
required this.disponibles,
required this.limiteTotal,
required this.tienePlan,
});
factory EstadoCupo.fromMap(Map<String, dynamic> map) {
return EstadoCupo(
usados: (map['usados'] as num?)?.toInt() ?? 0,
disponibles: (map['disponibles'] as num?)?.toInt() ?? 0,
limiteTotal: (map['limite_total'] as num?)?.toInt() ?? 0,
tienePlan: map['tiene_plan'] as bool? ?? false,
);
}
}
@@ -0,0 +1,40 @@
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
abstract class TurnosRepository {
/// Carga la semana completa empezando en [weekStart] (lunes recomendado).
/// Una sola llamada trae 7 días consecutivos; la JIT del backend
/// materializa los turnos faltantes según el horario vigente.
Future<SemanaTurnos> obtenerSemana(DateTime weekStart);
/// Workaround admin para crear un turno suelto fuera del schedule regular.
/// El backend lo marca como `es_especial=true`.
Future<void> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
});
/// Lista las reservas del turno (devuelve también canceladas).
Future<List<InscriptoTurno>> obtenerInscriptos(String turnoId);
/// Reserva como admin: bypassa deuda/plan/semana/fecha pasada,
/// sólo respeta capacidad del turno.
Future<void> reservarAdmin({
required String turnoId,
required String clienteId,
});
/// Cancela una reserva como admin: bypassa ownership y antelación.
Future<void> cancelarReservaAdmin(String reservaId);
/// Cupo semanal del cliente para la semana que contiene [fecha].
Future<EstadoCupo> obtenerEstadoCupo({
required String clienteId,
required DateTime fecha,
});
/// Mantenimiento: borra turnos vacíos de más de [diasAntiguedad] días.
Future<Map<String, dynamic>> limpiarTurnosAntiguos({int diasAntiguedad = 30});
}
@@ -0,0 +1,126 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/features/turnos/data/repositories/turnos_repository_impl.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/domain/repositories/turnos_repository.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
DateTime _normalizeWeekStart(DateTime d) {
final monday = DateTime(d.year, d.month, d.day - (d.weekday - 1));
return monday;
}
final turnosRepositoryProvider = Provider<TurnosRepository>((ref) {
return TurnosRepositoryImpl();
});
final turnosProvider =
StateNotifierProvider<TurnosNotifier, AsyncValue<SemanaTurnos?>>((ref) {
return TurnosNotifier(ref.read(turnosRepositoryProvider));
});
class TurnosNotifier extends StateNotifier<AsyncValue<SemanaTurnos?>> {
final TurnosRepository _repository;
DateTime? _currentWeek;
TurnosNotifier(this._repository) : super(const AsyncValue.data(null));
DateTime? get semanaActual => _currentWeek;
/// Carga la semana que contiene [referencia] (se normaliza al lunes).
/// Si ya estamos en esa semana, no recarga (a menos que [force] sea true).
Future<String?> cargarSemana(
DateTime referencia, {
bool force = false,
}) async {
final weekStart = _normalizeWeekStart(referencia);
if (!force && _currentWeek == weekStart && state.value != null) {
return null;
}
_currentWeek = weekStart;
state = const AsyncValue.loading();
try {
final semana = await _repository.obtenerSemana(weekStart);
if (_currentWeek != weekStart) return null; // semana cambió mientras cargaba
state = AsyncValue.data(semana);
return null;
} catch (e, st) {
if (_currentWeek != weekStart) return null;
state = AsyncValue.error(e, st);
return _errorMessage(e);
}
}
/// Refresca la semana actual en segundo plano, sin parpadeo: mantiene los
/// datos visibles mientras recarga y solo los reemplaza cuando llegan. Evita
/// el "corte" de volver a estado loading, que vaciaría la grilla a un spinner
/// (se nota, por ejemplo, al cerrar el diálogo de inscriptos).
Future<String?> refrescar() async {
final week = _currentWeek;
if (week == null) return null;
try {
final semana = await _repository.obtenerSemana(week);
if (_currentWeek != week) return null; // semana cambió mientras cargaba
state = AsyncValue.data(semana);
return null;
} catch (e) {
if (_currentWeek != week) return null;
// No pisamos los datos visibles con un error: los dejamos en pantalla y
// devolvemos el mensaje para que la pantalla muestre un toast.
return _errorMessage(e);
}
}
Future<String?> crearTurnoManual({
required DateTime fecha,
required int actividadId,
required String horaInicio,
required String horaFin,
required int capacidad,
}) async {
try {
await _repository.crearTurnoManual(
fecha: fecha,
actividadId: actividadId,
horaInicio: horaInicio,
horaFin: horaFin,
capacidad: capacidad,
);
await refrescar();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> limpiarAntiguos({int dias = 30}) async {
try {
final result =
await _repository.limpiarTurnosAntiguos(diasAntiguedad: dias);
final eliminados = result['turnos_eliminados'] ?? 0;
return 'Se eliminaron $eliminados turnos antiguos';
} catch (e) {
return _errorMessage(e);
}
}
}
/// Lista de reservas de un turno (incluye canceladas; filtrar en UI).
final inscriptosTurnoProvider =
FutureProvider.autoDispose.family<List<InscriptoTurno>, String>(
(ref, turnoId) =>
ref.read(turnosRepositoryProvider).obtenerInscriptos(turnoId),
);
/// Cupo semanal del cliente para la fecha pedida (usados/disponibles/total).
final estadoCupoProvider = FutureProvider.autoDispose
.family<EstadoCupo, ({String clienteId, DateTime fecha})>(
(ref, params) => ref.read(turnosRepositoryProvider).obtenerEstadoCupo(
clienteId: params.clienteId,
fecha: params.fecha,
),
);
@@ -0,0 +1,400 @@
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/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/asignar_usuario_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/crear_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/dia_inscriptos_sheet.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/inscriptos_turno_dialog.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/semana_turnos_grid.dart';
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _maxWeeksBack = 4;
const _maxWeeksForward = 8;
class TurnosScreen extends ConsumerStatefulWidget {
const TurnosScreen({super.key});
@override
ConsumerState<TurnosScreen> createState() => _TurnosScreenState();
}
class _TurnosScreenState extends ConsumerState<TurnosScreen> {
late DateTime _weekStart;
List<int> _diasVisibles = [0, 1, 2, 3, 4];
static DateTime _toMonday(DateTime d) =>
DateTime(d.year, d.month, d.day - (d.weekday - 1));
DateTime get _minWeek {
final now = DateTime.now();
return _toMonday(DateTime(now.year, now.month, now.day))
.subtract(const Duration(days: 7 * _maxWeeksBack));
}
DateTime get _maxWeek {
final now = DateTime.now();
return _toMonday(DateTime(now.year, now.month, now.day))
.add(const Duration(days: 7 * _maxWeeksForward));
}
bool get _canGoPrev => _weekStart.isAfter(_minWeek);
bool get _canGoNext => _weekStart.isBefore(_maxWeek);
@override
void initState() {
super.initState();
_weekStart = _toMonday(DateTime.now());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _cargarSemana();
});
}
Future<void> _cargarSemana() async {
final week = _weekStart;
final error = await ref.read(turnosProvider.notifier).cargarSemana(week);
if (!mounted || _weekStart != week) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
Future<void> _refrescar() async {
final error = await ref.read(turnosProvider.notifier).refrescar();
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
void _prevWeek() {
if (!_canGoPrev) return;
setState(() => _weekStart = _weekStart.subtract(const Duration(days: 7)));
_cargarSemana();
}
void _nextWeek() {
if (!_canGoNext) return;
setState(() => _weekStart = _weekStart.add(const Duration(days: 7)));
_cargarSemana();
}
String _weekLabel() {
final end = _weekStart.add(const Duration(days: 6));
final sameMonth = _weekStart.month == end.month;
if (sameMonth) {
return '${_weekStart.day} ${end.day} ${_mesesCortos[end.month]} ${end.year}';
}
return '${_weekStart.day} ${_mesesCortos[_weekStart.month]} ${end.day} ${_mesesCortos[end.month]} ${end.year}';
}
void _openDiasConfig() {
var local = List<int>.from(_diasVisibles);
showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('Días visibles'),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: SizedBox(
width: 260,
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final checked = local.contains(i);
return CheckboxListTile(
title: Text(_diasSemana[i]),
value: checked,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
onChanged: (local.length == 1 && checked)
? null
: (val) {
setLocal(() {
if (val == true) {
local = ([...local, i])..sort();
} else {
local = local.where((d) => d != i).toList();
}
});
setState(() => _diasVisibles = local);
},
);
}),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Listo'),
),
],
),
),
);
}
Future<void> _handleCrearTurno(DateTime fecha) async {
final data = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const CrearTurnoDialog(),
);
if (data == null || !mounted) return;
final error = await ref.read(turnosProvider.notifier).crearTurnoManual(
fecha: fecha,
actividadId: data['actividad_id'] as int,
horaInicio: data['hora_inicio'] as String,
horaFin: data['hora_fin'] as String,
capacidad: data['capacidad'] as int,
);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
}
}
Future<void> _handleAsignar(Turno turno, DateTime fecha) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AsignarUsuarioTurnoDialog(turno: turno, fecha: fecha),
);
if (ok == true && mounted) await _refrescar();
}
Future<void> _handleVerInscriptos(Turno turno) async {
var huboCambios = false;
await showDialog(
context: context,
builder: (_) => InscriptosTurnoDialog(
turno: turno,
onCambio: () => huboCambios = true,
),
);
// Solo refrescamos si se canceló alguna inscripción. Abrir y cerrar el
// diálogo sin tocar nada no dispara el rebuild de la grilla (que trababa
// la animación de cierre).
if (mounted && huboCambios) await _refrescar();
}
Future<void> _handleVerDia(DateTime fecha, DiaTurnos? dia) async {
if (dia == null || dia.turnos.isEmpty) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => DiaInscriptosSheet(
dia: dia,
fecha: fecha,
isAdmin: _isAdmin,
),
);
if (mounted) await _refrescar();
}
bool get _isAdmin {
final user = ref.read(authStateProvider).value;
return user?.isStaff ?? false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final isWide = MediaQuery.of(context).size.width >= 800;
final hPad = isWide ? 32.0 : 16.0;
final state = ref.watch(turnosProvider);
final isAdmin = _isAdmin;
return Scaffold(
body: Column(
children: [
// ── Header ──────────────────────────────────────────────────────────
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Turnos',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
SomaHeaderHelp(
items: [
const SomaHelpItem(
icon: Icons.chevron_left,
text: 'Las flechas navegan entre semanas.',
),
const SomaHelpItem(
icon: Icons.tune,
text: 'Días visibles: elegí qué días de la semana se '
'muestran en la grilla.',
),
const SomaHelpItem(
icon: Icons.touch_app_outlined,
text: 'Tocá un turno para ver o asignar inscriptos, '
'o un día sin turnos para crear uno nuevo.',
),
const SomaHelpItem(
icon: Icons.refresh,
text: 'Recarga los turnos de la semana actual.',
),
],
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Recargar',
onPressed: state.isLoading ? null : _refrescar,
),
],
),
),
// ── Navegación semanal ────────────────────────────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _canGoPrev ? _prevWeek : null,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Text(
_weekLabel(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _canGoNext ? _nextWeek : null,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, size: 20),
tooltip: 'Días visibles',
visualDensity: VisualDensity.compact,
onPressed: _openDiasConfig,
),
],
),
),
// ── Contenido ─────────────────────────────────────────────────────
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, hPad),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: cs.outline.withAlpha(40),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: state.when(
loading: () => Center(
key: const ValueKey('loading'),
child: CircularProgressIndicator(
color: SomaColors.primary),
),
error: (e, _) => _ErrorView(
key: const ValueKey('error'),
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: _cargarSemana,
),
data: (semana) {
if (semana == null) {
return Center(
key: const ValueKey('null'),
child: CircularProgressIndicator(
color: SomaColors.primary),
);
}
return SemanaTurnosGrid(
key: ValueKey(_weekStart),
semana: semana,
weekStart: _weekStart,
diasVisibles: _diasVisibles,
isAdmin: isAdmin,
onCrearTurno: _handleCrearTurno,
onAsignar: _handleAsignar,
onVerInscriptos: _handleVerInscriptos,
onTapDia: _handleVerDia,
);
},
),
),
),
),
),
],
),
);
}
}
// ── Error view ─────────────────────────────────────────────────────────────────
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({super.key, required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: cs.error.withAlpha(178)),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: cs.onSurface.withAlpha(153)),
),
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reintentar'),
),
],
),
),
);
}
}
@@ -0,0 +1,707 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
enum _Violacion { ninguna, sinPlan, actividadNoEnPlan }
class AsignarUsuarioTurnoDialog extends ConsumerStatefulWidget {
final Turno turno;
final DateTime fecha;
const AsignarUsuarioTurnoDialog({
super.key,
required this.turno,
required this.fecha,
});
@override
ConsumerState<AsignarUsuarioTurnoDialog> createState() =>
_AsignarUsuarioTurnoDialogState();
}
class _AsignarUsuarioTurnoDialogState
extends ConsumerState<AsignarUsuarioTurnoDialog> {
final Set<String> _selectedIds = {};
final Map<String, Usuario> _selectedMap = {};
bool _loading = false;
String _searchQuery = '';
String? _filterPlanId; // null = todos
_Violacion _getViolacion(Usuario user, List<TipoCuota> planes) {
if (widget.turno.actividad.libre) return _Violacion.ninguna;
if (user.tipoCuota == null) return _Violacion.sinPlan;
final plan = planes.where((p) => p.id == user.tipoCuota).firstOrNull;
if (plan == null) return _Violacion.sinPlan;
if (!plan.actividadesIds.contains(widget.turno.actividad.id)) {
return _Violacion.actividadNoEnPlan;
}
return _Violacion.ninguna;
}
void _toggle(Usuario u) {
setState(() {
if (_selectedIds.contains(u.id)) {
_selectedIds.remove(u.id);
_selectedMap.remove(u.id);
} else {
_selectedIds.add(u.id);
_selectedMap[u.id] = u;
}
});
}
Future<void> _submit() async {
if (_selectedIds.isEmpty) return;
setState(() => _loading = true);
int ok = 0;
String? lastError;
for (final u in _selectedMap.values) {
try {
await ref.read(turnosRepositoryProvider).reservarAdmin(
turnoId: widget.turno.id,
clienteId: u.id,
);
ok++;
} catch (e) {
lastError = '${u.displayName}: ${_errorMessage(e)}';
}
}
if (!mounted) return;
setState(() => _loading = false);
if (lastError != null) {
SomaToast.show(context, message: lastError, type: ToastType.error);
}
if (ok > 0 && mounted) Navigator.of(context).pop(true);
}
bool get _canSubmit => _selectedIds.isNotEmpty && !_loading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final usuariosAsync = ref.watch(usuariosProvider);
final planesAsync = ref.watch(tiposCuotaProvider);
final planes = planesAsync.valueOrNull ?? [];
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 660 ? (width - 580) / 2 : 12,
vertical: 28,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 580, maxHeight: 760),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Header ───────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.group_add_outlined,
size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Asignar usuarios',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700)),
Text(
'${widget.turno.actividad.nombre} · '
'${widget.turno.horaInicio} ${widget.turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// ── Contenido ────────────────────────────────────────────────────
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Filtro por plan ──────────────────────────────────────
planesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (allPlanes) => _PlanFilterChips(
planes: allPlanes,
selectedPlanId: _filterPlanId,
onSelected: (id) =>
setState(() => _filterPlanId = id),
),
),
const SizedBox(height: 10),
// ── Lista de usuarios ────────────────────────────────────
usuariosAsync.when(
loading: () => const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (_, _) => Text(
'Error cargando usuarios',
style: TextStyle(
color: SomaColors.error, fontSize: 13),
),
data: (usuarios) {
final clientes = usuarios
.where(
(u) => u.isActive && u.rol == 'cliente')
.toList();
// Filtro por plan
final planFiltrados = _filterPlanId == null
? clientes
: clientes
.where(
(u) => u.tipoCuota == _filterPlanId)
.toList();
// Filtro por búsqueda
final filtrados = _searchQuery.isEmpty
? planFiltrados
: planFiltrados
.where((u) => u.displayName
.toLowerCase()
.contains(
_searchQuery.toLowerCase()))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Buscar usuario...',
prefixIcon:
const Icon(Icons.search, size: 18),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
isDense: true,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withAlpha(60)),
),
),
onChanged: (v) =>
setState(() => _searchQuery = v),
),
const SizedBox(height: 8),
if (filtrados.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 18,
color:
cs.onSurface.withAlpha(80)),
const SizedBox(width: 8),
Text(
_filterPlanId != null
? 'Sin usuarios con este plan'
: 'Sin resultados',
style: TextStyle(
fontSize: 13,
color: cs.onSurface
.withAlpha(120)),
),
],
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: filtrados.length,
itemBuilder: (_, i) {
final u = filtrados[i];
final selected =
_selectedIds.contains(u.id);
final plan = planes
.where((p) =>
p.id == u.tipoCuota)
.firstOrNull;
final violacion =
_getViolacion(u, planes);
return _UserListItem(
user: u,
plan: plan,
violacion: violacion,
selected: selected,
onToggle: () => _toggle(u),
);
},
),
),
],
);
},
),
// ── Panel de seleccionados ───────────────────────────────
if (_selectedMap.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: cs.surfaceContainerHighest),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.check_circle_outline,
size: 15, color: SomaColors.success),
const SizedBox(width: 6),
Text(
'${_selectedMap.length} seleccionado${_selectedMap.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(160),
),
),
],
),
const SizedBox(height: 8),
..._selectedMap.values.map((u) {
final violacion = _getViolacion(u, planes);
return _SelectedUserRow(
user: u,
fecha: widget.fecha,
violacion: violacion,
actividad: widget.turno.actividad.nombre,
onRemove: () => _toggle(u),
);
}),
],
const SizedBox(height: 4),
],
),
),
),
// ── Botones ───────────────────────────────────────────────────
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancelar',
style: TextStyle(
color: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _canSubmit ? _submit : null,
style: FilledButton.styleFrom(
minimumSize: const Size(0, 42)),
child: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SomaColors.onPrimary))
: Text(_selectedMap.length > 1
? 'Asignar (${_selectedMap.length})'
: 'Asignar'),
),
],
),
),
],
),
),
);
}
}
// ── Chips de filtro por plan ───────────────────────────────────────────────────
class _PlanFilterChips extends StatelessWidget {
final List<TipoCuota> planes;
final String? selectedPlanId;
final ValueChanged<String?> onSelected;
const _PlanFilterChips({
required this.planes,
required this.selectedPlanId,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (planes.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'Todos',
selected: selectedPlanId == null,
onTap: () => onSelected(null),
cs: cs,
),
...planes.map((p) => _FilterChip(
label: p.nombre,
selected: selectedPlanId == p.id,
onTap: () =>
onSelected(selectedPlanId == p.id ? null : p.id),
cs: cs,
)),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final ColorScheme cs;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
required this.cs,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(right: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected
? SomaColors.primary.withAlpha(30)
: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(160)
: cs.outline.withAlpha(40),
width: selected ? 1 : 0.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected
? SomaColors.primaryText
: cs.onSurface.withAlpha(160),
),
),
),
);
}
}
// ── Fila de usuario en la lista ───────────────────────────────────────────────
class _UserListItem extends StatelessWidget {
final Usuario user;
final TipoCuota? plan;
final _Violacion violacion;
final bool selected;
final VoidCallback onToggle;
const _UserListItem({
required this.user,
required this.plan,
required this.violacion,
required this.selected,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final hasViolacion = violacion != _Violacion.ninguna;
return InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 3),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected
? SomaColors.primary.withAlpha(20)
: Colors.transparent,
border: Border.all(
color: selected
? SomaColors.primary.withAlpha(90)
: Colors.transparent,
width: 0.8,
),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: selected,
onChanged: (_) => onToggle(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
activeColor: SomaColors.primary,
checkColor: SomaColors.onPrimary,
side: BorderSide(
color: cs.outline.withAlpha(140), width: 1.2),
),
),
const SizedBox(width: 10),
CircleAvatar(
radius: 13,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
if (plan != null)
Text(
plan!.nombre,
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120)),
overflow: TextOverflow.ellipsis,
)
else
Text(
'Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(80),
fontStyle: FontStyle.italic),
),
],
),
),
if (hasViolacion)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: violacion == _Violacion.sinPlan
? 'Sin plan asignado'
: 'Plan no incluye esta actividad',
child: Icon(Icons.warning_amber_rounded,
size: 16, color: const Color(0xFFE67700)),
),
),
],
),
),
);
}
}
// ── Fila de usuario seleccionado (panel inferior) ─────────────────────────────
class _SelectedUserRow extends ConsumerWidget {
final Usuario user;
final DateTime fecha;
final _Violacion violacion;
final String actividad;
final VoidCallback onRemove;
const _SelectedUserRow({
required this.user,
required this.fecha,
required this.violacion,
required this.actividad,
required this.onRemove,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final cupoAsync =
ref.watch(estadoCupoProvider((clienteId: user.id, fecha: fecha)));
final hasViolacion = violacion != _Violacion.ninguna;
final warningColor = const Color(0xFFE67700);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(50),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(user.initials,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Cupo inline
cupoAsync.when(
loading: () => const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
error: (_, _) => const SizedBox.shrink(),
data: (cupo) {
if (!cupo.tienePlan) {
return Row(children: [
Icon(Icons.info_outline,
size: 12, color: cs.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text('Sin plan',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(120))),
]);
}
final lleno = cupo.disponibles == 0;
final cupoColor =
lleno ? SomaColors.error : SomaColors.success;
return Row(children: [
Icon(Icons.calendar_today_outlined,
size: 12, color: cs.onSurface.withAlpha(120)),
const SizedBox(width: 4),
Text(
'${cupo.usados}/${cupo.limiteTotal} días usados',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withAlpha(140)),
),
const SizedBox(width: 8),
Text(
'${cupo.disponibles} disp.',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: cupoColor),
),
]);
},
),
// Violación de plan
if (hasViolacion) ...[
const SizedBox(height: 3),
Row(children: [
Icon(Icons.warning_amber_rounded,
size: 12, color: warningColor),
const SizedBox(width: 4),
Expanded(
child: Text(
violacion == _Violacion.sinPlan
? 'Sin plan · se asignará igual'
: 'Plan no incluye $actividad · se asignará igual',
style: TextStyle(
fontSize: 11, color: warningColor),
overflow: TextOverflow.ellipsis,
),
),
]),
],
],
),
),
IconButton(
icon: Icon(Icons.close,
size: 16, color: cs.onSurface.withAlpha(140)),
tooltip: 'Quitar de la selección',
onPressed: onRemove,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,250 @@
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/actividades/presentation/providers/actividades_provider.dart';
class CrearTurnoDialog extends ConsumerStatefulWidget {
const CrearTurnoDialog({super.key});
@override
ConsumerState<CrearTurnoDialog> createState() => _CrearTurnoDialogState();
}
class _CrearTurnoDialogState extends ConsumerState<CrearTurnoDialog> {
int? _actividadId;
TimeOfDay _horaInicio = const TimeOfDay(hour: 8, minute: 0);
TimeOfDay _horaFin = const TimeOfDay(hour: 9, minute: 0);
int _capacidad = 10;
final _capacidadCtrl = TextEditingController(text: '10');
@override
void dispose() {
_capacidadCtrl.dispose();
super.dispose();
}
Future<void> _pickTime({required bool isStart}) async {
final initial = isStart ? _horaInicio : _horaFin;
final picked = await showTimePicker(
context: context,
initialTime: initial,
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: Theme.of(ctx).colorScheme.copyWith(
primary: SomaColors.primary,
onPrimary: SomaColors.onPrimary,
),
),
child: child!,
),
);
if (picked == null) return;
setState(() {
if (isStart) {
_horaInicio = picked;
if (_toMin(picked) >= _toMin(_horaFin)) {
_horaFin = TimeOfDay(hour: (picked.hour + 1) % 24, minute: picked.minute);
}
} else {
_horaFin = picked;
}
});
}
int _toMin(TimeOfDay t) => t.hour * 60 + t.minute;
String _fmt(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
bool get _valid =>
_actividadId != null && _toMin(_horaInicio) < _toMin(_horaFin) && _capacidad > 0;
void _submit() {
if (!_valid) return;
Navigator.of(context).pop(<String, dynamic>{
'actividad_id': _actividadId,
'hora_inicio': _fmt(_horaInicio),
'hora_fin': _fmt(_horaFin),
'capacidad': _capacidad,
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final actividadesAsync = ref.watch(actividadesProvider);
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (width - 420) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
const Text('Agregar turno',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Actividad
actividadesAsync.when(
loading: () => const Center(
child: SizedBox(
height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (_, e) => Text('Error cargando actividades',
style: TextStyle(color: SomaColors.error, fontSize: 13)),
data: (actividades) => DropdownButtonFormField<int>(
initialValue: _actividadId,
items: actividades
.where((a) => a.activo)
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.nombre)))
.toList(),
onChanged: (v) => setState(() => _actividadId = v),
decoration: const InputDecoration(
labelText: 'Actividad *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
const SizedBox(height: 20),
// Horario
Row(
children: [
Expanded(
child: _TimeField(
label: 'Desde',
value: _fmt(_horaInicio),
onTap: () => _pickTime(isStart: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(Icons.arrow_forward, size: 18,
color: cs.onSurface.withAlpha(100)),
),
Expanded(
child: _TimeField(
label: 'Hasta',
value: _fmt(_horaFin),
onTap: () => _pickTime(isStart: false),
),
),
],
),
if (_toMin(_horaInicio) >= _toMin(_horaFin))
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('La hora de fin debe ser mayor',
style: TextStyle(color: SomaColors.error, fontSize: 12)),
),
const SizedBox(height: 20),
// Capacidad
TextField(
controller: _capacidadCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Capacidad máxima *',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
onChanged: (v) => setState(() => _capacidad = int.tryParse(v) ?? 0),
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancelar',
style: TextStyle(color: cs.onSurface.withAlpha(178))),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _valid ? _submit : null,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 42)),
child: const Text('Agregar'),
),
],
),
),
],
),
),
);
}
}
class _TimeField extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _TimeField({required this.label, required this.value, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5)),
const SizedBox(height: 6),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.inputDecorationTheme.fillColor,
),
child: Row(
children: [
Icon(Icons.schedule, size: 20, color: theme.colorScheme.onSurface.withAlpha(130)),
const SizedBox(width: 10),
Text(value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
],
),
),
),
],
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
const _diasNombres = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _meses = [
'', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
];
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class DiaInscriptosSheet extends ConsumerWidget {
final DiaTurnos dia;
final DateTime fecha;
final bool isAdmin;
const DiaInscriptosSheet({
super.key,
required this.dia,
required this.fecha,
required this.isAdmin,
});
Future<void> _cancelar(
WidgetRef ref,
BuildContext context,
InscriptoTurno inscripto,
String turnoId,
) async {
try {
await ref.read(turnosRepositoryProvider).cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turnoId));
} catch (e) {
if (context.mounted) {
SomaToast.show(
context,
message: e is PostgrestException
? e.message
: e.toString().replaceFirst('Exception: ', ''),
type: ToastType.error,
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final turnos = [...dia.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
final nombreDia = _diasNombres[fecha.weekday - 1];
final fechaLabel = '${fecha.day} de ${_meses[fecha.month]}';
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.92,
builder: (ctx, scrollController) {
return Column(
children: [
// Drag handle
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurface.withAlpha(60),
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.calendar_today_outlined,
size: 18,
color: cs.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
fechaLabel,
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.surfaceContainerHighest,
),
// Body
Expanded(
child: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final turno = turnos[i];
return _TurnoSection(
turno: turno,
isAdmin: isAdmin,
onCancelar: (inscripto) =>
_cancelar(ref, context, inscripto, turno.id),
);
},
),
),
],
);
},
);
}
}
// ── Sección de un turno con sus inscriptos ────────────────────────────────────
class _TurnoSection extends ConsumerWidget {
final Turno turno;
final bool isAdmin;
final void Function(InscriptoTurno) onCancelar;
const _TurnoSection({
required this.turno,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header del turno
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, color: barColor),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140),
),
),
],
),
),
if (turno.capacidadMaxima > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: barColor.withAlpha(80),
width: 0.7,
),
),
child: Text(
turno.estaLleno
? 'LLENO'
: '${turno.ocupacion}/${turno.capacidadMaxima}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: barColor,
),
),
),
],
),
),
),
],
),
),
Divider(
height: 1,
thickness: 0.5,
color: cs.outline.withAlpha(25),
),
// Lista de inscriptos
inscriptosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (_, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Text(
'Error cargando inscriptos',
style: TextStyle(fontSize: 12, color: cs.error),
),
),
data: (inscriptos) {
final activos = inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.person_off_outlined,
size: 15,
color: cs.onSurface.withAlpha(60),
),
const SizedBox(width: 8),
Text(
'Sin inscriptos',
style: TextStyle(
fontSize: 13,
color: cs.onSurface.withAlpha(100),
),
),
],
),
);
}
return Column(
children: [
for (final inscripto in activos)
_InscriptoRow(
inscripto: inscripto,
isAdmin: isAdmin,
onCancelar: () => onCancelar(inscripto),
),
],
);
},
),
],
),
);
}
}
// ── Fila de inscripto ─────────────────────────────────────────────────────────
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final bool isAdmin;
final VoidCallback onCancelar;
const _InscriptoRow({
required this.inscripto,
required this.isAdmin,
required this.onCancelar,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(
inscripto.initials,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
inscripto.displayName,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (isAdmin)
IconButton(
icon: Icon(
Icons.person_remove_outlined,
size: 16,
color: cs.error.withAlpha(180),
),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/providers/turnos_provider.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
class InscriptosTurnoDialog extends ConsumerWidget {
final Turno turno;
/// Se invoca cuando se canceló alguna inscripción. La pantalla lo usa para
/// refrescar los cupos al cerrar SOLO si hubo cambios; si el diálogo se abrió
/// y cerró sin tocar nada, no se refresca y se evita el rebuild de la grilla
/// (que trababa la animación de cierre).
final VoidCallback? onCambio;
const InscriptosTurnoDialog({super.key, required this.turno, this.onCambio});
Future<void> _cancelar(
WidgetRef ref, BuildContext context, InscriptoTurno inscripto) async {
try {
await ref
.read(turnosRepositoryProvider)
.cancelarReservaAdmin(inscripto.reservaId);
ref.invalidate(inscriptosTurnoProvider(turno.id));
onCambio?.call();
} catch (e) {
if (context.mounted) {
SomaToast.show(context,
message: _errorMessage(e), type: ToastType.error);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final inscriptosAsync = ref.watch(inscriptosTurnoProvider(turno.id));
final width = MediaQuery.of(context).size.width;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: width >= 600 ? (width - 440) / 2 : 20,
vertical: 40,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.primary.withAlpha(25),
borderRadius: BorderRadius.circular(10),
),
child:
Icon(Icons.group_outlined, size: 18, color: cs.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
turno.actividad.nombre,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withAlpha(140)),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
Flexible(
child: inscriptosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 40, color: cs.onSurface.withAlpha(80)),
const SizedBox(height: 12),
Text(
_errorMessage(e),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13, color: cs.onSurface.withAlpha(140)),
),
],
),
),
data: (inscriptos) {
// Filtrar canceladas para la vista principal de inscritos
final activos =
inscriptos.where((i) => !i.cancelada).toList();
if (activos.isEmpty) {
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_off_outlined,
size: 48, color: cs.onSurface.withAlpha(60)),
const SizedBox(height: 12),
Text('Sin inscriptos en este turno',
style: TextStyle(
fontSize: 14,
color: cs.onSurface.withAlpha(130))),
],
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
child: Row(
children: [
Text(
'${activos.length} inscripto${activos.length == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface.withAlpha(150)),
),
const Spacer(),
if (turno.capacidadMaxima > 0)
Text(
'${turno.disponible} cupo${turno.disponible == 1 ? '' : 's'} libre${turno.disponible == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: turno.disponible == 0
? SomaColors.error
: SomaColors.success),
),
],
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: activos.length,
separatorBuilder: (_, i) =>
const SizedBox(height: 6),
itemBuilder: (_, i) => _InscriptoRow(
inscripto: activos[i],
onCancelar: () =>
_cancelar(ref, context, activos[i]),
),
),
),
],
);
},
),
),
],
),
),
);
}
}
class _InscriptoRow extends StatelessWidget {
final InscriptoTurno inscripto;
final VoidCallback onCancelar;
const _InscriptoRow({required this.inscripto, required this.onCancelar});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(30), width: 0.5),
),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: SomaColors.primary.withAlpha(30),
child: Text(inscripto.initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
const SizedBox(width: 10),
Expanded(
child: Text(inscripto.displayName,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
IconButton(
icon: Icon(Icons.person_remove_outlined,
size: 18, color: cs.error.withAlpha(180)),
tooltip: 'Cancelar inscripción',
onPressed: onCancelar,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
import 'package:gimnasio_soma/features/turnos/presentation/widgets/turno_slot_tile.dart';
const _diasSemana = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo',
];
const _mesesCortos = [
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
const _minColumnWidth = 160.0;
bool _isToday(DateTime d) {
final now = DateTime.now();
return d.year == now.year && d.month == now.month && d.day == now.day;
}
bool _isPast(DateTime d) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return DateTime(d.year, d.month, d.day).isBefore(today);
}
class SemanaTurnosGrid extends StatelessWidget {
final SemanaTurnos semana;
final DateTime weekStart;
final List<int> diasVisibles;
final bool isAdmin;
final void Function(DateTime fecha) onCrearTurno;
final void Function(Turno turno, DateTime fecha) onAsignar;
final void Function(Turno turno) onVerInscriptos;
final void Function(DateTime fecha, DiaTurnos? dia)? onTapDia;
const SemanaTurnosGrid({
super.key,
required this.semana,
required this.weekStart,
required this.diasVisibles,
required this.isAdmin,
required this.onCrearTurno,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = diasVisibles.length;
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth;
final useScroll = available < count * _minColumnWidth;
final rowChildren = <Widget>[];
for (int i = 0; i < count; i++) {
final diaIdx = diasVisibles[i];
final fecha = weekStart.add(Duration(days: diaIdx));
final dia = semana.diaPara(fecha);
final isPast = _isPast(fecha);
if (i > 0) {
rowChildren.add(Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
));
}
final columna = _DiaTurnosColumna(
fecha: fecha,
nombreDia: _diasSemana[diaIdx],
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: () => onCrearTurno(fecha),
onAsignar: (t) => onAsignar(t, fecha),
onVerInscriptos: onVerInscriptos,
onTapDia: onTapDia != null ? () => onTapDia!(fecha, dia) : null,
);
rowChildren.add(
useScroll
? SizedBox(width: _minColumnWidth, child: columna)
: Expanded(child: columna),
);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rowChildren,
);
if (!useScroll) return row;
final totalWidth = count * _minColumnWidth + (count - 1).toDouble();
return Scrollbar(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: totalWidth,
height: constraints.maxHeight,
child: row,
),
),
);
},
);
}
}
// ── Columna de un día ──────────────────────────────────────────────────────────
class _DiaTurnosColumna extends StatelessWidget {
final DateTime fecha;
final String nombreDia;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
final VoidCallback? onTapDia;
const _DiaTurnosColumna({
required this.fecha,
required this.nombreDia,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
required this.onAsignar,
required this.onVerInscriptos,
this.onTapDia,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DiaTurnosHeader(
nombreDia: nombreDia,
fecha: fecha,
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onCrear: onCrear,
onTap: onTapDia,
),
Expanded(
child: _DiaTurnosBody(
dia: dia,
isPast: isPast,
isAdmin: isAdmin,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
);
}
}
// ── Header ─────────────────────────────────────────────────────────────────────
class _DiaTurnosHeader extends StatelessWidget {
final String nombreDia;
final DateTime fecha;
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final VoidCallback onCrear;
final VoidCallback? onTap;
const _DiaTurnosHeader({
required this.nombreDia,
required this.fecha,
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onCrear,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final today = _isToday(fecha);
final esCerrado = dia?.estado == DiaEstado.cerrado;
final esEspecial = dia?.estado == DiaEstado.horarioDiferente;
final puedeAgregar = isAdmin && !isPast && !esCerrado;
final content = Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombreDia,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: today
? SomaColors.primaryText
: cs.onSurface.withAlpha(155),
),
),
const SizedBox(height: 1),
Text(
'${fecha.day} ${_mesesCortos[fecha.month]}',
style: TextStyle(
fontSize: 11,
color: today
? SomaColors.primaryText.withAlpha(180)
: cs.onSurface.withAlpha(115),
),
),
],
),
),
if (esCerrado)
_HeaderBadge(icon: Icons.block, color: SomaColors.error)
else if (esEspecial)
_HeaderBadge(icon: Icons.event_note, color: SomaColors.primary),
if (puedeAgregar) ...[
const SizedBox(width: 2),
SizedBox(
width: 28,
height: 28,
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 16, color: cs.onSurface.withAlpha(130)),
tooltip: 'Agregar turno',
onPressed: onCrear,
),
),
] else
const SizedBox(width: 32),
],
),
);
final decoration = BoxDecoration(
color: today ? SomaColors.primary.withAlpha(22) : null,
border: Border(
bottom: BorderSide(
color: today
? SomaColors.primary.withAlpha(120)
: cs.surfaceContainerHighest,
width: today ? 1.5 : 1,
),
),
);
if (onTap == null) {
return DecoratedBox(decoration: decoration, child: content);
}
return DecoratedBox(
decoration: decoration,
child: InkWell(
onTap: onTap,
child: content,
),
);
}
}
class _HeaderBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _HeaderBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(4),
),
child: Icon(icon, size: 12, color: color.withAlpha(200)),
);
}
}
// ── Body ───────────────────────────────────────────────────────────────────────
class _DiaTurnosBody extends StatelessWidget {
final DiaTurnos? dia;
final bool isPast;
final bool isAdmin;
final void Function(Turno) onAsignar;
final void Function(Turno) onVerInscriptos;
const _DiaTurnosBody({
required this.dia,
required this.isPast,
required this.isAdmin,
required this.onAsignar,
required this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
if (dia == null) {
return Center(
child: Text('',
style: TextStyle(fontSize: 18, color: cs.onSurface.withAlpha(55))),
);
}
if (dia!.estado == DiaEstado.cerrado) {
return Container(
color: SomaColors.error.withAlpha(10),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 22, color: SomaColors.error.withAlpha(130)),
const SizedBox(height: 6),
Text('Cerrado',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.error.withAlpha(160),
)),
],
),
),
);
}
if (dia!.turnos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_busy_outlined,
size: 22, color: cs.onSurface.withAlpha(50)),
const SizedBox(height: 6),
Text(
isPast ? 'Sin turnos' : 'Sin turnos\ngenerados',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(90)),
),
],
),
);
}
final turnos = [...dia!.turnos]
..sort((a, b) => a.horaInicio.compareTo(b.horaInicio));
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: turnos.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final turno = turnos[index];
final readOnly = isPast || !isAdmin;
return TurnoSlotTile(
turno: turno,
readOnly: readOnly,
onAsignar: readOnly ? null : () => onAsignar(turno),
onVerInscriptos: isAdmin ? () => onVerInscriptos(turno) : null,
);
},
);
}
}
@@ -0,0 +1,378 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/turnos/domain/entities/turno.dart';
Color _barColor(int ocupacion, int capacidad, ColorScheme cs) {
if (capacidad == 0) return cs.outline;
final ratio = ocupacion / capacidad;
if (ratio < 0.5) return SomaColors.success;
if (ratio < 0.85) return const Color(0xFFFFB300);
return SomaColors.error;
}
class TurnoSlotTile extends StatelessWidget {
final Turno turno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const TurnoSlotTile({
super.key,
required this.turno,
this.readOnly = false,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final barColor = _barColor(turno.ocupacion, turno.capacidadMaxima, cs);
final estaLleno = turno.estaLleno;
final canTap = !readOnly && onVerInscriptos != null;
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 200;
return MouseRegion(
cursor: canTap ? SystemMouseCursors.click : SystemMouseCursors.basic,
// Material propio (en vez de Ink) para que el fondo y el ripple se
// pinten en la capa de la card y queden recortados al ListView. Con
// Ink la decoración se pintaba sobre el Material ancestro y "sangraba"
// por encima de las cabeceras al scrollear.
child: Material(
color: cs.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: cs.surfaceContainerHighest,
width: 0.5,
),
),
child: InkWell(
onTap: canTap ? onVerInscriptos : null,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
bottomLeft: Radius.circular(10),
),
),
),
Expanded(
child: isCompact
? _CompactContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
)
: _WideContent(
turno: turno,
barColor: barColor,
estaLleno: estaLleno,
readOnly: readOnly,
onAsignar: onAsignar,
onVerInscriptos: onVerInscriptos,
),
),
],
),
),
),
),
);
},
);
}
}
class _CompactContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
const _CompactContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && onAsignar != null;
return Padding(
padding: EdgeInsets.fromLTRB(10, 8, hasMenu ? 0 : 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: cs.onSurface.withAlpha(170),
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: true,
),
if (hasMenu) _SlotMenu(onAsignar: onAsignar, onVerInscriptos: null),
],
),
const SizedBox(height: 2),
Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _WideContent extends StatelessWidget {
final Turno turno;
final Color barColor;
final bool estaLleno;
final bool readOnly;
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _WideContent({
required this.turno,
required this.barColor,
required this.estaLleno,
required this.readOnly,
this.onAsignar,
this.onVerInscriptos,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final hasMenu = !readOnly && (onAsignar != null || onVerInscriptos != null);
return Padding(
padding: EdgeInsets.fromLTRB(12, 10, hasMenu ? 4 : 12, 10),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(
'${turno.horaInicio} ${turno.horaFin}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: cs.surfaceContainerHighest,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
turno.actividad.nombre,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (turno.actividad.libre) ...[
const SizedBox(width: 6),
_LibreTag(),
],
],
),
const SizedBox(height: 3),
_SlotBadge(
estaLleno: estaLleno,
ocupacion: turno.ocupacion,
maxima: turno.capacidadMaxima,
barColor: barColor,
compact: false,
),
],
),
),
if (hasMenu)
_SlotMenu(onAsignar: onAsignar, onVerInscriptos: onVerInscriptos),
],
),
);
}
}
// ── Slot badge: "X / Y" o "LLENO" ─────────────────────────────────────────────
class _SlotBadge extends StatelessWidget {
final bool estaLleno;
final int ocupacion;
final int maxima;
final Color barColor;
final bool compact;
const _SlotBadge({
required this.estaLleno,
required this.ocupacion,
required this.maxima,
required this.barColor,
required this.compact,
});
@override
Widget build(BuildContext context) {
if (maxima == 0) return const SizedBox.shrink();
final label = estaLleno ? 'LLENO' : '$ocupacion/$maxima';
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 5 : 6,
vertical: 2,
),
decoration: BoxDecoration(
color: barColor.withAlpha(25),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: barColor.withAlpha(80), width: 0.7),
),
child: Text(
label,
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w700,
color: barColor,
letterSpacing: 0.3,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
);
}
}
class _LibreTag extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(22),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: SomaColors.success,
letterSpacing: 0.2,
),
),
);
}
}
// ── Menú ───────────────────────────────────────────────────────────────────────
class _SlotMenu extends StatelessWidget {
final VoidCallback? onAsignar;
final VoidCallback? onVerInscriptos;
const _SlotMenu({this.onAsignar, this.onVerInscriptos});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final items = <PopupMenuEntry<String>>[];
if (onAsignar != null) {
items.add(PopupMenuItem(
value: 'asignar',
height: 44,
child: Row(children: [
Icon(Icons.person_add_outlined, size: 16, color: cs.primary),
const SizedBox(width: 10),
const Text('Asignar usuario', style: TextStyle(fontSize: 13)),
]),
));
}
if (onVerInscriptos != null) {
items.add(PopupMenuItem(
value: 'inscriptos',
height: 44,
child: Row(children: [
Icon(Icons.group_outlined, size: 16, color: cs.onSurface.withAlpha(153)),
const SizedBox(width: 10),
const Text('Ver inscriptos', style: TextStyle(fontSize: 13)),
]),
));
}
if (items.isEmpty) return const SizedBox(width: 36);
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 18, color: cs.onSurface.withAlpha(100)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
itemBuilder: (_) => items,
onSelected: (val) {
if (val == 'asignar') onAsignar?.call();
if (val == 'inscriptos') onVerInscriptos?.call();
},
);
}
}
@@ -0,0 +1,135 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart';
class UsuariosRepositoryImpl implements UsuariosRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<Usuario>> getUsuarios({
int pagina = 1,
int cantidad = 50,
String? dni,
}) async {
final token = await _getToken();
final params = <String, dynamic>{
'p_token': token,
'p_pagina': pagina,
'p_cantidad': cantidad,
};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetUsuarios,
params: params,
);
if (response is List) {
return response
.map((e) => Usuario.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertUsuario(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertUsuario,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<void> updateUsuario(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateUsuario,
params: {
'p_datos': datos,
'p_token': token,
},
);
}
@override
Future<void> toggleUsuarioStatus(String id, bool estado) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcToggleUsuarioStatus,
params: {
'p_token': token,
'p_id': id,
'p_estado': estado,
},
);
}
@override
Future<bool> deleteUsuario(String dni) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteUsuario,
params: {
'p_dni': dni,
'p_token': token,
},
);
return response == true;
}
@override
Future<void> resetearContrasena(String usuarioId, String passwordNueva) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcResetearContrasenaUsuario,
params: {
'p_token': token,
'p_usuario_id': usuarioId,
'p_password_nueva': passwordNueva,
},
);
}
@override
Future<List<Map<String, dynamic>>> getUsuariosTipoCuota(
{String? dni}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (dni != null && dni.isNotEmpty) {
params['p_dni'] = dni;
}
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetUsuarioTipoCuota,
params: params,
);
if (response is List) {
return response
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
return [];
}
}
@@ -0,0 +1,118 @@
class Usuario {
final String id;
final String dni;
final String nombre;
final String? apellido;
final double? peso;
final int? altura;
final String rol;
final bool isActive;
final DateTime? fechaCreacion;
final DateTime? fechaModificacion;
final String? mail;
final String? telefono;
final double? fuerzaMax;
final String? sexo;
final String? tipoCuota;
const Usuario({
required this.id,
required this.dni,
required this.nombre,
this.apellido,
this.peso,
this.altura,
required this.rol,
this.isActive = true,
this.fechaCreacion,
this.fechaModificacion,
this.mail,
this.telefono,
this.fuerzaMax,
this.sexo,
this.tipoCuota,
});
factory Usuario.fromMap(Map<String, dynamic> map) {
return Usuario(
id: map['id'] as String,
dni: map['dni'] as String? ?? '',
nombre: map['nombre'] as String? ?? '',
apellido: map['apellido'] as String?,
peso: (map['peso'] as num?)?.toDouble(),
altura: (map['altura'] as num?)?.toInt(),
rol: map['rol'] as String? ?? 'cliente',
isActive: map['isactive'] as bool? ?? map['isActive'] as bool? ?? true,
fechaCreacion: map['fecha_creacion'] != null
? DateTime.tryParse(map['fecha_creacion'].toString())
: null,
fechaModificacion: map['fecha_modificacion'] != null
? DateTime.tryParse(map['fecha_modificacion'].toString())
: null,
mail: map['mail'] as String?,
telefono: map['telefono'] as String?,
fuerzaMax: (map['fuerza_max'] as num?)?.toDouble(),
sexo: map['sexo'] as String?,
tipoCuota: map['tipo_cuota'] as String?,
);
}
String get displayName {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '$nombre $apellido';
}
return nombre.isNotEmpty ? nombre : dni;
}
String get rolDisplay {
return switch (rol) {
'superadmin' || 'admin' || 'profesor' => 'Admin',
'cliente' => 'Cliente',
_ => rol,
};
}
String get initials {
if (nombre.isNotEmpty && apellido != null && apellido!.isNotEmpty) {
return '${nombre[0]}${apellido![0]}'.toUpperCase();
}
if (nombre.isNotEmpty) return nombre[0].toUpperCase();
if (dni.length >= 2) return dni.substring(0, 2);
return '?';
}
/// Para fc_insertar_usuario (p_datos jsonb).
Map<String, dynamic> toInsertMap(String password) {
return {
'dni': dni,
'nombre': nombre,
if (apellido != null) 'apellido': apellido,
if (peso != null) 'peso': peso,
if (altura != null) 'altura': altura,
'rol': rol,
if (mail != null && mail!.isNotEmpty) 'mail': mail,
if (telefono != null && telefono!.isNotEmpty) 'telefono': telefono,
if (fuerzaMax != null) 'fuerza_max': fuerzaMax,
if (sexo != null) 'sexo': sexo,
if (tipoCuota != null) 'tipo_cuota': tipoCuota,
if (password.isNotEmpty) 'password': password,
'isActive': isActive,
};
}
/// Para fc_modificar_usuario (p_datos jsonb). Sin password.
Map<String, dynamic> toUpdateMap() {
return {
'dni': dni,
'nombre': nombre,
if (apellido != null) 'apellido': apellido,
if (peso != null) 'peso': peso,
if (altura != null) 'altura': altura,
'rol': rol,
if (mail != null) 'mail': mail,
if (telefono != null) 'telefono': telefono,
if (fuerzaMax != null) 'fuerza_max': fuerzaMax,
if (sexo != null) 'sexo': sexo,
};
}
}
@@ -0,0 +1,24 @@
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
abstract class UsuariosRepository {
Future<List<Usuario>> getUsuarios({
int pagina = 1,
int cantidad = 50,
String? dni,
});
Future<void> insertUsuario(Map<String, dynamic> datos);
Future<void> updateUsuario(Map<String, dynamic> datos);
Future<void> toggleUsuarioStatus(String id, bool estado);
Future<bool> deleteUsuario(String dni);
/// Reset administrativo de contraseña (solo superadmin). No requiere la
/// contraseña actual del usuario objetivo.
Future<void> resetearContrasena(String usuarioId, String passwordNueva);
/// Obtener mapping usuario → tipo de cuota.
Future<List<Map<String, dynamic>>> getUsuariosTipoCuota({String? dni});
}
@@ -0,0 +1,152 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/data/repositories/usuarios_repository_impl.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/domain/repositories/usuarios_repository.dart';
final usuariosRepositoryProvider = Provider<UsuariosRepository>((ref) {
return UsuariosRepositoryImpl();
});
/// Todos los usuarios sin filtro. Se invalida automáticamente cuando usuariosProvider muta.
final allUsuariosProvider = FutureProvider.autoDispose<List<Usuario>>((ref) async {
final repo = ref.read(usuariosRepositoryProvider);
return repo.getUsuarios();
});
/// Mapa DNI → monto de deuda del mes actual.
/// Se recalcula automáticamente cuando allUsuariosProvider o tiposCuotaProvider cambian.
final usuariosDeudaProvider = FutureProvider.autoDispose<Map<String, double>>((ref) async {
final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull;
if (tiposCuota == null || tiposCuota.isEmpty) return {};
final usuarios = ref.watch(allUsuariosProvider).valueOrNull;
if (usuarios == null || usuarios.isEmpty) return {};
final pagosRepo = ref.read(pagosRepositoryProvider);
final pagos = await pagosRepo.getPagos(cantidad: 500);
final now = DateTime.now();
final mesActual =
'${now.year}-${now.month.toString().padLeft(2, '0')}-01';
final pagosMes = <String, double>{};
for (final p in pagos) {
if (p.anioMesPagado == mesActual && p.cliente != null) {
pagosMes[p.cliente!.dni] =
(pagosMes[p.cliente!.dni] ?? 0) + p.montoTotal;
}
}
final deuda = <String, double>{};
for (final u in usuarios) {
if (u.tipoCuota == null || !u.isActive) continue;
final tc =
tiposCuota.where((t) => t.id == u.tipoCuota).firstOrNull;
if (tc == null) continue;
double esperado = tc.precio;
if (tc.recargo != null &&
tc.recargo! > 0 &&
now.day > tc.diaDePago) {
esperado += tc.recargo!;
}
final pagado = pagosMes[u.dni] ?? 0;
deuda[u.dni] = esperado - pagado;
}
return deuda;
});
/// Helper compartido: retorna null si el usuario no tiene plan o está inactivo,
/// 0.0 si está al día, >0 si debe.
double? getUsuarioDeuda(Usuario u, Map<String, double> deudaMap) {
if (u.tipoCuota == null || !u.isActive) return null;
return deudaMap[u.dni] ?? 0.0;
}
final usuariosProvider =
StateNotifierProvider.autoDispose<UsuariosNotifier, AsyncValue<List<Usuario>>>((ref) {
return UsuariosNotifier(ref.read(usuariosRepositoryProvider), ref);
});
class UsuariosNotifier extends StateNotifier<AsyncValue<List<Usuario>>> {
final UsuariosRepository _repository;
final Ref _ref;
UsuariosNotifier(this._repository, this._ref) : super(const AsyncValue.loading()) {
loadUsuarios();
}
/// Invalida providers derivados para que refetcheen con datos frescos.
void _invalidateDerived() {
_ref.invalidate(allUsuariosProvider);
// usuariosDeudaProvider y pagosEstadoProvider se recalculan solos
// porque hacen ref.watch(allUsuariosProvider)
}
Future<void> loadUsuarios() async {
state = const AsyncValue.loading();
try {
final usuarios = await _repository.getUsuarios();
state = AsyncValue.data(usuarios);
_invalidateDerived();
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertUsuario(Map<String, dynamic> datos) async {
try {
await _repository.insertUsuario(datos);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> updateUsuario(Map<String, dynamic> datos) async {
try {
await _repository.updateUsuario(datos);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> toggleStatus(String id, bool estado) async {
try {
await _repository.toggleUsuarioStatus(id, estado);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
Future<String?> deleteUsuario(String dni) async {
try {
await _repository.deleteUsuario(dni);
await loadUsuarios();
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
/// Reset administrativo de contraseña. No refetchea la lista: no cambia
/// ningún dato visible en ella.
Future<String?> resetearContrasena(String usuarioId, String passwordNueva) async {
try {
await _repository.resetearContrasena(usuarioId, passwordNueva);
return null;
} catch (e) {
return e.toString().replaceFirst('Exception: ', '');
}
}
}
@@ -0,0 +1,48 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
enum UsuariosViewMode { overview, cards, table }
final usuariosViewModeProvider =
StateNotifierProvider<UsuariosViewModeNotifier, UsuariosViewMode>((ref) {
return UsuariosViewModeNotifier();
});
class UsuariosViewModeNotifier extends StateNotifier<UsuariosViewMode> {
UsuariosViewModeNotifier() : super(UsuariosViewMode.overview) {
_loadViewMode();
}
Future<void> _loadViewMode() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(AppConstants.usuariosViewModeKey);
if (stored == 'table') {
state = UsuariosViewMode.table;
} else if (stored == 'cards') {
state = UsuariosViewMode.cards;
} else {
state = UsuariosViewMode.overview;
}
}
Future<void> toggle() async {
final newMode = switch (state) {
UsuariosViewMode.overview => UsuariosViewMode.cards,
UsuariosViewMode.cards => UsuariosViewMode.table,
UsuariosViewMode.table => UsuariosViewMode.overview,
};
await setMode(newMode);
}
Future<void> setMode(UsuariosViewMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
final stored = switch (mode) {
UsuariosViewMode.table => 'table',
UsuariosViewMode.cards => 'cards',
UsuariosViewMode.overview => 'overview',
};
await prefs.setString(AppConstants.usuariosViewModeKey, stored);
}
}
@@ -0,0 +1,991 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_view_mode_provider.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/resetear_contrasena_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_card.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_detail_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuario_form_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_table_view.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/editar_plan_dialog.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/widgets/usuarios_overview.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
import 'package:go_router/go_router.dart';
enum _DeudaFilter { todos, alDia, debe }
enum _RolFilter { todos, staff, clientes }
enum _ActiveFilter { todos, activos, inactivos }
class UsuariosScreen extends ConsumerStatefulWidget {
const UsuariosScreen({super.key});
@override
ConsumerState<UsuariosScreen> createState() => _UsuariosScreenState();
}
class _UsuariosScreenState extends ConsumerState<UsuariosScreen> {
final _searchCtrl = TextEditingController();
_DeudaFilter _filter = _DeudaFilter.todos;
_RolFilter _rolFilter = _RolFilter.todos;
_ActiveFilter _activeFilter = _ActiveFilter.todos;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _onSearch(String value) {
setState(() {}); // Rebuild para aplicar filtro local
}
bool get _actorIsSuperadmin =>
ref.read(authStateProvider).valueOrNull?.isSuperadmin ?? false;
Widget _filterItem(BuildContext ctx, String label) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(ctx).colorScheme.onSurface,
),
),
);
}
List<Usuario> _applyLocalSearch(List<Usuario> usuarios) {
final query = _searchCtrl.text.trim().toLowerCase();
if (query.isEmpty) return usuarios;
return usuarios.where((u) {
return u.nombre.toLowerCase().contains(query) ||
(u.apellido?.toLowerCase().contains(query) ?? false) ||
u.dni.contains(query) ||
(u.mail?.toLowerCase().contains(query) ?? false);
}).toList();
}
Future<void> _showCreateDialog() async {
final existingDnis = ref
.read(allUsuariosProvider)
.valueOrNull
?.map((u) => u.dni)
.toSet() ??
{};
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => UsuarioFormDialog(existingDnis: existingDnis),
);
if (result == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.insertUsuario(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario creado',
type: ToastType.success,
);
}
}
Future<void> _showDetail(Usuario usuario) async {
final result = await showDialog<String>(
context: context,
builder: (_) => UsuarioDetailDialog(usuario: usuario),
);
if (result == 'edit' && mounted) {
_showEditDialog(usuario);
}
}
Future<void> _showEditDialog(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => UsuarioFormDialog(
usuario: usuario,
actorIsSuperadmin: _actorIsSuperadmin,
),
);
if (result == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.updateUsuario(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario actualizado',
type: ToastType.success,
);
}
}
Future<void> _toggleStatus(Usuario usuario) async {
final newStatus = !usuario.isActive;
final accion = newStatus ? 'activar' : 'desactivar';
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('${newStatus ? 'Activar' : 'Desactivar'} usuario'),
content: Text(
'¿Estás seguro de que querés $accion a ${usuario.displayName}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(newStatus ? 'Activar' : 'Desactivar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.toggleStatus(usuario.id, newStatus);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: newStatus ? 'Usuario activado' : 'Usuario desactivado',
type: ToastType.success,
);
}
}
Future<void> _deleteUsuario(Usuario usuario) async {
if (!_actorIsSuperadmin) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar usuario'),
content: Text(
'¿Estás seguro de que querés eliminar a ${usuario.displayName}?\n'
'Esta acción no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.deleteUsuario(usuario.dni);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Usuario eliminado',
type: ToastType.success,
);
}
}
List<Usuario> _applyRolFilter(List<Usuario> usuarios) {
if (_rolFilter == _RolFilter.todos) return usuarios;
return usuarios.where((u) {
if (_rolFilter == _RolFilter.staff) {
return u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor';
}
if (_rolFilter == _RolFilter.clientes) {
return u.rol == 'cliente';
}
return true;
}).toList();
}
List<Usuario> _applyActiveFilter(List<Usuario> usuarios) {
if (_activeFilter == _ActiveFilter.todos) return usuarios;
return usuarios
.where((u) => _activeFilter == _ActiveFilter.activos ? u.isActive : !u.isActive)
.toList();
}
List<Usuario> _applyFilter(
List<Usuario> usuarios, Map<String, double> deudaMap) {
if (_filter == _DeudaFilter.todos) return usuarios;
return usuarios.where((u) {
final d = getUsuarioDeuda(u, deudaMap);
if (_filter == _DeudaFilter.debe) return d != null && d > 0;
if (_filter == _DeudaFilter.alDia) return d != null && d <= 0;
return true;
}).toList();
}
// Acciones rápidas
Future<void> _resetearContrasena(Usuario usuario) async {
final nuevaPassword = await showDialog<String>(
context: context,
builder: (_) => ResetearContrasenaDialog(usuario: usuario),
);
if (nuevaPassword == null || !mounted) return;
final error = await ref
.read(usuariosProvider.notifier)
.resetearContrasena(usuario.id, nuevaPassword);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Contraseña actualizada',
type: ToastType.success,
);
}
}
Future<void> _registrarPago(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => PagoFormDialog(prefilledDni: usuario.dni),
);
if (result == null || !mounted) return;
// Extraer y remover datos de actualización de plan antes de insertar pago
final planUpdate =
result.remove('actualizar_plan') as Map<String, dynamic>?;
// Insertar el pago directamente vía repositorio para evitar la race
// condition con pagosProvider.autoDispose (nadie lo watchea aquí).
String? error;
try {
await ref.read(pagosRepositoryProvider).insertPago(result);
ref.invalidate(ultimoPagoMapProvider);
// userPagosProvider y userHistorialProvider son family autoDispose;
// ref.invalidate sobre la familia entera fuerza re-fetch en próximo watch.
ref.invalidate(userPagosProvider);
ref.invalidate(userHistorialProvider);
// fc_insertar_pago activa al cliente incondicionalmente; refrescamos
// la lista para que la UI refleje el nuevo isactive.
await ref.read(usuariosProvider.notifier).loadUsuarios();
} catch (e) {
error = e.toString().replaceFirst('Exception: ', '');
}
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
return;
}
// Actualizar plan del usuario si se pidió
if (planUpdate != null) {
final planError =
await ref.read(usuariosProvider.notifier).updateUsuario({
'id': planUpdate['usuario_id'],
'tipo_cuota': planUpdate['tipo_cuota_id'],
});
if (mounted && planError != null) {
SomaToast.show(context,
message: 'Pago registrado, pero error al actualizar plan: $planError',
type: ToastType.info);
return;
}
}
if (mounted) {
SomaToast.show(
context,
message: planUpdate != null
? 'Pago registrado y plan actualizado'
: 'Pago registrado',
type: ToastType.success,
);
}
}
void _asignarRutina(Usuario usuario) {
context.go('/rutinas?dni=${usuario.dni}');
}
void _verHistorial(Usuario usuario) {
showDialog(
context: context,
builder: (_) => UsuarioHistorialDialog(
dni: usuario.dni,
nombre: usuario.displayName,
initials: usuario.initials,
),
);
}
Future<void> _editarPlan(Usuario usuario) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => EditarPlanDialog(usuario: usuario),
);
if (result == null || !mounted) return; // Usuario canceló
final newTipoCuota = result['tipo_cuota'] as String?;
// Si no cambió, no hacer nada
if (newTipoCuota == usuario.tipoCuota) return;
// Actualizar el tipo_cuota del usuario
final updateData = {
'id': usuario.id,
'tipo_cuota': newTipoCuota, // null si se seleccionó "Sin plan"
};
final error = await ref
.read(usuariosProvider.notifier)
.updateUsuario(updateData);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(
context,
message: 'Plan actualizado',
type: ToastType.success,
);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(usuariosProvider);
final deudaAsync = ref.watch(usuariosDeudaProvider);
final deudaMap = deudaAsync.valueOrNull ?? {};
final ultimoPagoMap = ref.watch(ultimoPagoMapProvider).valueOrNull ?? {};
final isWide = MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint;
final theme = Theme.of(context);
final viewMode = ref.watch(usuariosViewModeProvider);
final actorIsSuperadmin =
ref.watch(authStateProvider).valueOrNull?.isSuperadmin ?? false;
// Overview mode: simplified header + dashboard
if (viewMode == UsuariosViewMode.overview) {
return Scaffold(
body: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
const Text(
'Usuarios',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.dashboard_outlined,
text: 'Cambiá entre vista resumen, tarjetas o tabla.',
),
SomaHelpItem(
icon: Icons.add,
text: 'Creá un nuevo usuario.',
),
],
),
const Spacer(),
_ViewToggleButton(isWide: isWide),
const SizedBox(width: 8),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
Expanded(
child: UsuariosOverview(
onVerTodos: () => ref
.read(usuariosViewModeProvider.notifier)
.setMode(UsuariosViewMode.cards),
),
),
],
),
);
}
return Scaffold(
body: Column(
children: [
// Header con búsqueda
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
0,
),
child: Row(
children: [
if (isWide) ...[
const Text(
'Usuarios',
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w700),
),
const SomaHeaderHelp(
items: [
SomaHelpItem(
icon: Icons.search,
text: 'Buscá por nombre, apellido, DNI o email.',
),
SomaHelpItem(
icon: Icons.dashboard_outlined,
text: 'Cambiá entre vista resumen, tarjetas o tabla.',
),
SomaHelpItem(
icon: Icons.add,
text: 'Creá un nuevo usuario.',
),
SomaHelpItem(
icon: Icons.filter_alt_outlined,
text: 'Filtrá la lista por estado de pago, rol o si '
'están activos.',
),
],
),
const SizedBox(width: 16),
],
Expanded(
child: SizedBox(
height: 42,
child: TextField(
controller: _searchCtrl,
onChanged: _onSearch,
decoration: InputDecoration(
hintText: 'Buscar por nombre, email o DNI...',
hintStyle: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface
.withAlpha(100),
),
prefixIcon: Icon(
Icons.search,
size: 20,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
_searchCtrl.clear();
_onSearch('');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: theme
.colorScheme.surfaceContainerHighest,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: theme
.colorScheme.surfaceContainerHighest,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: SomaColors.primary,
width: 1.5,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
),
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: 12),
_ViewToggleButton(isWide: isWide),
const SizedBox(width: 8),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Filtros de deuda y rol
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
12,
isWide ? 32 : 16,
8,
),
child: Row(
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.8,
),
),
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_DeudaFilter>(
value: _filter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Estado'),
_filterItem(ctx, 'Al día'),
_filterItem(ctx, 'Debe'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _DeudaFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _DeudaFilter.alDia,
child: Text('Al día', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _DeudaFilter.debe,
child: Text('Debe', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _filter = value);
},
),
),
VerticalDivider(
width: 1,
thickness: 0.8,
color: theme.colorScheme.surfaceContainerHighest,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_RolFilter>(
value: _rolFilter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Rol'),
_filterItem(ctx, 'Admins'),
_filterItem(ctx, 'Clientes'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _RolFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _RolFilter.staff,
child: Text('Admins', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _RolFilter.clientes,
child: Text('Clientes', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _rolFilter = value);
},
),
),
VerticalDivider(
width: 1,
thickness: 0.8,
color: theme.colorScheme.surfaceContainerHighest,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<_ActiveFilter>(
value: _activeFilter,
selectedItemBuilder: (ctx) => [
_filterItem(ctx, 'Estado'),
_filterItem(ctx, 'Activos'),
_filterItem(ctx, 'Inactivos'),
],
underline: const SizedBox(),
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: _ActiveFilter.todos,
child: Text('Todos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _ActiveFilter.activos,
child: Text('Activos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
DropdownMenuItem(
value: _ActiveFilter.inactivos,
child: Text('Inactivos', style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface)),
),
],
onChanged: (value) {
if (value != null) setState(() => _activeFilter = value);
},
),
),
],
),
),
),
if (_filter != _DeudaFilter.todos || _rolFilter != _RolFilter.todos || _activeFilter != _ActiveFilter.todos) ...[
const SizedBox(width: 8),
InkWell(
onTap: () => setState(() {
_filter = _DeudaFilter.todos;
_rolFilter = _RolFilter.todos;
_activeFilter = _ActiveFilter.todos;
}),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: SomaColors.primary.withAlpha(14),
border: Border.all(
color: SomaColors.primary.withAlpha(50),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.close, size: 14, color: SomaColors.primaryText),
const SizedBox(width: 5),
Text(
'Limpiar',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primaryText,
),
),
],
),
),
),
],
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.onSurface.withAlpha(100),
),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref
.read(usuariosProvider.notifier)
.loadUsuarios(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (usuarios) {
final searched = _applyLocalSearch(usuarios);
final activeFiltered = _applyActiveFilter(searched);
final rolFiltered = _applyRolFilter(activeFiltered);
final filtered = _applyFilter(rolFiltered, deudaMap);
if (filtered.isEmpty) {
final hasSearch = _searchCtrl.text.isNotEmpty;
final hasFilter = _filter != _DeudaFilter.todos ||
_rolFilter != _RolFilter.todos;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 56,
color: theme.colorScheme.onSurface.withAlpha(60),
),
const SizedBox(height: 12),
Text(
hasFilter
? 'No hay usuarios con este filtro'
: hasSearch
? 'No se encontraron usuarios para "${_searchCtrl.text}"'
: 'No hay usuarios',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurface.withAlpha(130),
),
textAlign: TextAlign.center,
),
if (hasSearch) ...[
const SizedBox(height: 14),
TextButton.icon(
onPressed: () {
_searchCtrl.clear();
_onSearch('');
},
icon: const Icon(Icons.close, size: 16),
label: const Text('Limpiar búsqueda'),
),
],
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () => ref
.read(usuariosProvider.notifier)
.loadUsuarios(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: viewMode == UsuariosViewMode.table && isWide
? Padding(
key: const ValueKey('table'),
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
4,
isWide ? 32 : 16,
80,
),
child: UsuariosTableView(
usuarios: filtered,
deudaMap: deudaMap,
ultimoPagoMap: ultimoPagoMap,
onTap: _showDetail,
onEdit: _showEditDialog,
onToggleStatus: _toggleStatus,
onDelete: _deleteUsuario,
onRegistrarPago: _registrarPago,
onAsignarRutina: _asignarRutina,
onVerHistorial: _verHistorial,
onEditarPlan: _editarPlan,
actorIsSuperadmin: actorIsSuperadmin,
onResetPassword: _resetearContrasena,
),
)
: ListView.separated(
key: const ValueKey('cards'),
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
4,
isWide ? 32 : 16,
80,
),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final usuario = filtered[index];
return UsuarioCard(
usuario: usuario,
deuda: getUsuarioDeuda(usuario, deudaMap),
onTap: () => _showDetail(usuario),
onEdit: () => _showEditDialog(usuario),
onToggleStatus: () => _toggleStatus(usuario),
onDelete: () => _deleteUsuario(usuario),
onRegistrarPago: () => _registrarPago(usuario),
onAsignarRutina: () => _asignarRutina(usuario),
onVerHistorial: () => _verHistorial(usuario),
onEditarPlan: () => _editarPlan(usuario),
canDelete: actorIsSuperadmin,
canResetPassword: actorIsSuperadmin &&
usuario.rol != 'cliente',
onResetPassword: () =>
_resetearContrasena(usuario),
);
},
),
),
);
},
),
),
],
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nuevo'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42)),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
class _ViewToggleButton extends ConsumerWidget {
final bool isWide;
const _ViewToggleButton({required this.isWide});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewMode = ref.watch(usuariosViewModeProvider);
final notifier = ref.read(usuariosViewModeProvider.notifier);
final theme = Theme.of(context);
if (isWide) {
return SegmentedButton<UsuariosViewMode>(
segments: const [
ButtonSegment(
value: UsuariosViewMode.overview,
icon: Icon(Icons.dashboard_outlined, size: 18),
tooltip: 'Resumen',
),
ButtonSegment(
value: UsuariosViewMode.cards,
icon: Icon(Icons.view_list, size: 18),
tooltip: 'Tarjetas',
),
ButtonSegment(
value: UsuariosViewMode.table,
icon: Icon(Icons.table_rows_outlined, size: 18),
tooltip: 'Tabla',
),
],
selected: {viewMode},
onSelectionChanged: (s) => notifier.setMode(s.first),
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
showSelectedIcon: false,
);
}
// Narrow: cicla entre modos con un ícono contextual
final (icon, tooltip) = switch (viewMode) {
UsuariosViewMode.overview => (Icons.view_list, 'Vista de tarjetas'),
UsuariosViewMode.cards => (Icons.table_rows_outlined, 'Vista de tabla'),
UsuariosViewMode.table => (Icons.dashboard_outlined, 'Vista resumen'),
};
return IconButton(
onPressed: () => notifier.toggle(),
tooltip: tooltip,
icon: Icon(icon, size: 20),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
minimumSize: const Size(42, 42),
),
);
}
}
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
class EditarPlanDialog extends ConsumerStatefulWidget {
final Usuario usuario;
const EditarPlanDialog({super.key, required this.usuario});
@override
ConsumerState<EditarPlanDialog> createState() => _EditarPlanDialogState();
}
class _EditarPlanDialogState extends ConsumerState<EditarPlanDialog> {
String? _selectedTipoCuotaId;
@override
void initState() {
super.initState();
_selectedTipoCuotaId = widget.usuario.tipoCuota;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tiposCuotaAsync = ref.watch(tiposCuotaProvider);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: const BoxConstraints(maxWidth: 450),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.card_membership,
color: SomaColors.primary,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Editar Plan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
widget.usuario.displayName,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 20),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleccionar Tipo de Cuota',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const SizedBox(height: 12),
tiposCuotaAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(
color: SomaColors.primary,
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Error al cargar tipos de cuota',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.error,
),
),
),
data: (tiposCuota) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
),
),
child: DropdownButton<String?>(
isExpanded: true,
value: _selectedTipoCuotaId,
underline: const SizedBox(),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(
'Sin plan',
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
),
...tiposCuota.map((tc) {
return DropdownMenuItem<String?>(
value: tc.id,
child: Row(
children: [
Expanded(
child: Text(
tc.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
Text(
'${tc.precioDisplay}${tc.diasDisplay}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
],
),
);
}),
],
onChanged: (value) {
setState(() {
_selectedTipoCuotaId = value;
});
},
),
);
},
),
],
),
),
// Footer
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
final removingPlan = widget.usuario.tipoCuota != null &&
_selectedTipoCuotaId == null;
if (removingPlan) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Quitar plan?'),
content: Text(
'${widget.usuario.displayName} quedará sin plan asignado. '
'No se eliminan los pagos registrados.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
),
child: const Text('Quitar plan'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
}
Navigator.of(context)
.pop({'tipo_cuota': _selectedTipoCuotaId});
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
/// Dialog de reset administrativo: el superadmin fija una contraseña nueva
/// para otro admin/superadmin, sin pedir la contraseña actual del objetivo.
/// Retorna la nueva contraseña (String) si se confirma, o null si se cancela.
class ResetearContrasenaDialog extends StatefulWidget {
final Usuario usuario;
const ResetearContrasenaDialog({super.key, required this.usuario});
@override
State<ResetearContrasenaDialog> createState() =>
_ResetearContrasenaDialogState();
}
class _ResetearContrasenaDialogState extends State<ResetearContrasenaDialog> {
final _formKey = GlobalKey<FormState>();
final _nuevaCtrl = TextEditingController();
final _repetirCtrl = TextEditingController();
bool _obscureNueva = true;
bool _obscureRepetir = true;
@override
void dispose() {
_nuevaCtrl.dispose();
_repetirCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_nuevaCtrl.text);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cambiar contraseña de ${widget.usuario.displayName}',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
SomaTextField(
controller: _nuevaCtrl,
labelText: 'Contraseña nueva',
prefixIcon: Icons.lock_outline,
obscureText: _obscureNueva,
suffixIcon: IconButton(
icon: Icon(
_obscureNueva
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: theme.colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: () =>
setState(() => _obscureNueva = !_obscureNueva),
),
validator: (v) {
if (v == null || v.isEmpty) return 'Requerido';
if (v.length < 8) return 'Mínimo 8 caracteres';
return null;
},
),
const SizedBox(height: 12),
SomaTextField(
controller: _repetirCtrl,
labelText: 'Repetir contraseña',
prefixIcon: Icons.lock_outline,
obscureText: _obscureRepetir,
suffixIcon: IconButton(
icon: Icon(
_obscureRepetir
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: theme.colorScheme.onSurface.withAlpha(130),
size: 20,
),
onPressed: () =>
setState(() => _obscureRepetir = !_obscureRepetir),
),
validator: (v) => v != _nuevaCtrl.text
? 'Las contraseñas no coinciden'
: null,
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: const Text('Confirmar'),
),
],
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,524 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
class UsuarioCard extends ConsumerWidget {
final Usuario usuario;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onToggleStatus;
final VoidCallback onDelete;
final VoidCallback onRegistrarPago;
final VoidCallback onAsignarRutina;
final VoidCallback onVerHistorial;
final VoidCallback onEditarPlan;
final bool canResetPassword;
final VoidCallback? onResetPassword;
final bool canDelete;
/// null = sin plan, 0 = al día, >0 = monto de deuda
final double? deuda;
const UsuarioCard({
super.key,
required this.usuario,
required this.onTap,
required this.onEdit,
required this.onToggleStatus,
required this.onDelete,
required this.onRegistrarPago,
required this.onAsignarRutina,
required this.onVerHistorial,
required this.onEditarPlan,
this.canResetPassword = false,
this.onResetPassword,
this.canDelete = false,
this.deuda,
});
Color _stripeColor(ThemeData theme) {
if (!usuario.isActive) return theme.colorScheme.surfaceContainerHighest;
if (deuda == null) return theme.colorScheme.surfaceContainerHighest;
return deuda! <= 0 ? SomaColors.success : SomaColors.error;
}
ContextMenu<String> _buildContextMenu() {
return ContextMenu<String>(
entries: [
MenuItem(
label: const Text('Registrar pago'),
icon: const Icon(Icons.payment, size: 16),
value: 'pago',
),
MenuItem(
label: const Text('Asignar rutina'),
icon: const Icon(Icons.fitness_center, size: 16),
value: 'rutina',
),
MenuItem(
label: const Text('Ver historial'),
icon: const Icon(Icons.history, size: 16),
value: 'historial',
),
MenuItem(
label: const Text('Editar plan'),
icon: const Icon(Icons.card_membership, size: 16),
value: 'plan',
),
const MenuDivider(),
MenuItem(
label: const Text('Editar'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
MenuItem(
label: Text(usuario.isActive ? 'Desactivar' : 'Activar'),
icon: Icon(
usuario.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 16,
),
value: 'toggle',
),
if (canResetPassword)
MenuItem(
label: const Text('Cambiar contraseña'),
icon: const Icon(Icons.lock_reset, size: 16),
value: 'password',
),
if (canDelete) ...[
const MenuDivider(),
MenuItem(
label: const Text(
'Eliminar',
style: TextStyle(color: SomaColors.error),
),
icon: const Icon(Icons.delete_outline, size: 16, color: SomaColors.error),
value: 'delete',
),
],
],
);
}
void _handleContextAction(String? value) {
switch (value) {
case 'pago': onRegistrarPago();
case 'rutina': onAsignarRutina();
case 'historial': onVerHistorial();
case 'plan': onEditarPlan();
case 'edit': onEdit();
case 'toggle': onToggleStatus();
case 'password': onResetPassword?.call();
case 'delete': onDelete();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isWide = MediaQuery.of(context).size.width >= 800;
final isStaff = usuario.rol != 'cliente';
final stripe = _stripeColor(theme);
// Último pago — solo si tiene plan asignado
Widget ultimoPagoRow = const SizedBox.shrink();
if (usuario.tipoCuota != null) {
final pagosAsync = ref.watch(
userPagosProvider((dni: usuario.dni, incluirAnulados: false)),
);
ultimoPagoRow = pagosAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (pagos) {
if (pagos.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
'Último pago: ${pagos.first.mesPagadoDisplay}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
);
},
);
}
final card = ContextMenuRegion<String>(
contextMenu: _buildContextMenu(),
onItemSelected: _handleContextAction,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rail de estado — franja izquierda semántica
Container(width: 4, color: stripe),
// Contenido principal
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
child: Row(
children: [
// Avatar — ring amarillo para staff
Container(
padding: isStaff
? const EdgeInsets.all(2)
: EdgeInsets.zero,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isStaff
? SomaColors.primary
: Colors.transparent,
),
child: CircleAvatar(
radius: AppConstants.kAvatarRadiusMd,
backgroundColor: usuario.isActive
? SomaColors.primary.withAlpha(40)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
usuario.initials,
style: TextStyle(
color: usuario.isActive
? (isStaff
? SomaColors.onPrimary
: theme.colorScheme.onSurface)
: theme.colorScheme.onSurface.withAlpha(100),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
usuario.displayName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: usuario.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface
.withAlpha(100),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_RolBadge(rol: usuario.rol),
if (deuda != null) ...[
const SizedBox(width: 6),
_DeudaBadge(monto: deuda!),
],
],
),
const SizedBox(height: 3),
Row(
children: [
// Indicador activo/inactivo inline
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: usuario.isActive
? SomaColors.success
: SomaColors.error,
),
),
Text(
'DNI: ${usuario.dni}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
),
if (usuario.mail != null &&
usuario.mail!.isNotEmpty) ...[
Text(
'',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(80),
),
),
Expanded(
child: Text(
usuario.mail!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
ultimoPagoRow,
],
),
),
const SizedBox(width: 6),
// Quick actions con fondo sutil (solo en pantallas anchas)
if (isWide) ...[
_QuickActionButton(
icon: Icons.payment,
tooltip: 'Registrar pago',
onPressed: onRegistrarPago,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.fitness_center,
tooltip: 'Asignar rutina',
onPressed: onAsignarRutina,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.history,
tooltip: 'Ver historial',
onPressed: onVerHistorial,
),
const SizedBox(width: 4),
_QuickActionButton(
icon: Icons.card_membership,
tooltip: 'Editar plan',
onPressed: onEditarPlan,
),
const SizedBox(width: 4),
],
// Menú de acciones
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 20,
color: theme.colorScheme.onSurface.withAlpha(130),
),
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
case 'toggle':
onToggleStatus();
case 'password':
onResetPassword?.call();
case 'delete':
onDelete();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 10),
Text('Editar',
style: TextStyle(fontSize: 13)),
],
),
),
PopupMenuItem(
value: 'toggle',
child: Row(
children: [
Icon(
usuario.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 18,
),
const SizedBox(width: 10),
Text(
usuario.isActive ? 'Desactivar' : 'Activar',
style: const TextStyle(fontSize: 13),
),
],
),
),
if (canResetPassword)
const PopupMenuItem(
value: 'password',
child: Row(
children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 10),
Text('Cambiar contraseña',
style: TextStyle(fontSize: 13)),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
size: 18, color: SomaColors.error),
SizedBox(width: 10),
Text('Eliminar',
style: TextStyle(
fontSize: 13,
color: SomaColors.error)),
],
),
),
],
),
],
),
),
),
],
),
),
),
),
);
// Usuarios inactivos se atenúan visualmente
if (!usuario.isActive) {
return Opacity(opacity: 0.58, child: card);
}
return card;
}
}
class _QuickActionButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
const _QuickActionButton({
required this.icon,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: Icon(icon, size: 17),
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceContainerHighest.withAlpha(180),
foregroundColor: theme.colorScheme.onSurface.withAlpha(160),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
class _RolBadge extends StatelessWidget {
final String rol;
const _RolBadge({required this.rol});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isStaff =
rol == 'superadmin' || rol == 'admin' || rol == 'profesor';
final label = switch (rol) {
'superadmin' || 'admin' || 'profesor' => 'Admin',
'cliente' => 'Cliente',
_ => rol,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isStaff
? SomaColors.primary.withAlpha(25)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(5),
border: isStaff
? Border.all(color: SomaColors.primary.withAlpha(60), width: 0.5)
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isStaff
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(178),
),
),
);
}
}
class _DeudaBadge extends StatelessWidget {
final double monto;
const _DeudaBadge({required this.monto});
@override
Widget build(BuildContext context) {
final alDia = monto <= 0;
final color = alDia ? SomaColors.success : SomaColors.error;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withAlpha(60), width: 0.5),
),
child: Text(
alDia
? 'Al día'
: 'Debe \$${monto.toStringAsFixed(monto.truncateToDouble() == monto ? 0 : 2)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
@@ -0,0 +1,712 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
import 'package:gimnasio_soma/features/tipos_cuota/domain/entities/tipo_cuota.dart';
import 'package:gimnasio_soma/features/tipos_cuota/presentation/providers/tipos_cuota_provider.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
/// Retorna 'edit' si el usuario quiere editar.
class UsuarioDetailDialog extends ConsumerWidget {
final Usuario usuario;
const UsuarioDetailDialog({super.key, required this.usuario});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tiposCuota = ref.watch(tiposCuotaProvider).valueOrNull ?? [];
final pagosAsync = ref.watch(
userPagosProvider((dni: usuario.dni, incluirAnulados: false)),
);
final deudaMap = ref.watch(usuariosDeudaProvider).valueOrNull ?? {};
final width = MediaQuery.of(context).size.width;
final isWide = width >= AppConstants.kDesktopBreakpoint;
final theme = Theme.of(context);
final tipoCuota = usuario.tipoCuota != null
? tiposCuota
.where((t) => t.id == usuario.tipoCuota)
.firstOrNull
: null;
final deuda = (usuario.tipoCuota != null && usuario.isActive)
? (deudaMap[usuario.dni] ?? 0.0)
: null;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 500) / 2 : 16,
vertical: 24,
),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header con cerrar
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 0),
child: Row(
children: [
const Text(
'Detalle de usuario',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Body scrollable
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar + nombre + rol
_buildHeader(theme),
const SizedBox(height: 20),
// Info personal
_sectionTitle('Información'),
const SizedBox(height: 8),
_infoCard(theme, [
_infoRow(Icons.badge_outlined, 'DNI',
usuario.dni),
if (usuario.mail != null &&
usuario.mail!.isNotEmpty)
_infoRow(Icons.email_outlined, 'Email',
usuario.mail!),
if (usuario.telefono != null &&
usuario.telefono!.isNotEmpty)
_infoRow(Icons.phone_outlined, 'Teléfono',
usuario.telefono!),
if (usuario.sexo != null &&
usuario.sexo!.isNotEmpty)
_infoRow(
Icons.person_outline,
'Sexo',
usuario.sexo == 'M'
? 'Masculino'
: usuario.sexo == 'F'
? 'Femenino'
: usuario.sexo!),
if (usuario.fechaCreacion != null)
_infoRow(
Icons.calendar_today_outlined,
'Miembro desde',
_fmtDate(usuario.fechaCreacion!)),
]),
// Medidas
if (usuario.peso != null ||
usuario.altura != null ||
usuario.fuerzaMax != null) ...[
const SizedBox(height: 16),
_sectionTitle('Medidas'),
const SizedBox(height: 8),
_buildMedidas(theme),
],
// Plan
const SizedBox(height: 16),
_sectionTitle('Plan'),
const SizedBox(height: 8),
tipoCuota != null
? _buildPlan(theme, tipoCuota)
: _emptyCard(
theme, 'Sin plan asignado'),
// Estado de cuenta
if (deuda != null) ...[
const SizedBox(height: 16),
_sectionTitle('Estado de cuenta'),
const SizedBox(height: 8),
_buildEstadoCuenta(
theme, deuda, pagosAsync),
],
// Últimos pagos
const SizedBox(height: 16),
_sectionTitle('Últimos pagos'),
const SizedBox(height: 8),
pagosAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
),
),
),
error: (_, _) => _emptyCard(
theme, 'Error cargando pagos'),
data: (pagos) => pagos.isEmpty
? _emptyCard(
theme, 'Sin pagos registrados')
: _buildPagosList(theme, pagos),
),
],
),
),
),
const Divider(height: 1),
// Acciones
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cerrar',
style: TextStyle(
color: theme.colorScheme.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () =>
Navigator.of(context).pop('edit'),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Editar'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
),
],
),
),
],
),
),
);
}
// ─── Secciones ──────────────────────────────────────────
Widget _buildHeader(ThemeData theme) {
final isStaff = usuario.rol != 'cliente';
final statusColor =
usuario.isActive ? SomaColors.success : SomaColors.error;
return Row(
children: [
// Avatar con ring para staff activos
Container(
padding: isStaff ? const EdgeInsets.all(2.5) : EdgeInsets.zero,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isStaff ? SomaColors.primary : Colors.transparent,
),
child: CircleAvatar(
radius: AppConstants.kAvatarRadiusLg,
backgroundColor: usuario.isActive
? SomaColors.primary.withAlpha(40)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
usuario.initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: usuario.isActive
? (isStaff
? SomaColors.onPrimary
: theme.colorScheme.onSurface)
: theme.colorScheme.onSurface.withAlpha(100),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
usuario.displayName,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Row(
children: [
// Rol badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isStaff
? SomaColors.primary.withAlpha(25)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(5),
border: isStaff
? Border.all(
color: SomaColors.primary.withAlpha(60),
width: 0.5)
: null,
),
child: Text(
usuario.rolDisplay,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isStaff
? SomaColors.primaryText
: theme.colorScheme.onSurface.withAlpha(178),
),
),
),
const SizedBox(width: 8),
// Estado pill
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withAlpha(18),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: statusColor.withAlpha(60), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 5),
Text(
usuario.isActive ? 'Activo' : 'Inactivo',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
],
),
),
],
);
}
Widget _buildMedidas(ThemeData theme) {
final items = <(String, String)>[];
if (usuario.peso != null) {
items.add(('Peso',
'${usuario.peso!.toStringAsFixed(usuario.peso!.truncateToDouble() == usuario.peso! ? 0 : 1)} kg'));
}
if (usuario.altura != null) {
items.add(('Altura', '${usuario.altura} cm'));
}
if (usuario.fuerzaMax != null) {
items.add(('Fuerza máx', usuario.fuerzaMax!.toStringAsFixed(0)));
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: SomaColors.primary.withAlpha(8),
border: Border.all(
color: SomaColors.primary.withAlpha(40),
width: 0.5,
),
),
child: IntrinsicHeight(
child: Row(
children: [
for (int i = 0; i < items.length; i++) ...[
if (i > 0)
Container(
width: 1,
color: theme.colorScheme.surfaceContainerHighest,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
Expanded(child: _medidaItem(theme, items[i].$1, items[i].$2)),
],
],
),
),
);
}
Widget _medidaItem(ThemeData theme, String label, String value) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
);
}
Widget _buildPlan(ThemeData theme, TipoCuota tc) {
return _cardContainer(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tc.nombre,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
),
if (tc.descripcion != null &&
tc.descripcion!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
tc.descripcion!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
const SizedBox(height: 8),
Row(
children: [
_planChip(theme, tc.precioDisplay),
const SizedBox(width: 8),
_planChip(theme, tc.diasDisplay),
const SizedBox(width: 8),
_planChip(theme, 'Vto. día ${tc.diaDePago}'),
],
),
if (tc.recargo != null && tc.recargo! > 0) ...[
const SizedBox(height: 6),
Text(
'Recargo: \$${tc.recargo!.toStringAsFixed(tc.recargo!.truncateToDouble() == tc.recargo! ? 0 : 2)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
],
),
);
}
Widget _planChip(ThemeData theme, String text) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
);
}
Widget _buildEstadoCuenta(
ThemeData theme,
double deuda,
AsyncValue<List<Pago>> pagosAsync,
) {
final alDia = deuda <= 0;
final statusColor = alDia ? SomaColors.success : SomaColors.error;
final ultimoPago = pagosAsync.valueOrNull?.firstOrNull;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: statusColor.withAlpha(alDia ? 14 : 18),
border: Border.all(
color: statusColor.withAlpha(60),
width: 0.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 8),
Text(
alDia
? 'Al día'
: 'Debe \$${deuda.toStringAsFixed(deuda.truncateToDouble() == deuda ? 0 : 2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: statusColor,
),
),
],
),
if (ultimoPago != null) ...[
const SizedBox(height: 10),
Divider(height: 1, color: statusColor.withAlpha(40)),
const SizedBox(height: 10),
Text(
'ÚLTIMO PAGO',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: statusColor.withAlpha(160),
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
ultimoPago.mesPagadoDisplay,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
Text(
'\$${ultimoPago.montoTotal.toStringAsFixed(ultimoPago.montoTotal.truncateToDouble() == ultimoPago.montoTotal ? 0 : 2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: statusColor),
),
const SizedBox(width: 8),
Text(
ultimoPago.metodo,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
const Spacer(),
Text(
ultimoPago.fechaPagoDisplay,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
],
],
),
);
}
Widget _buildPagosList(ThemeData theme, List<Pago> pagos) {
final show = pagos.take(5).toList();
return _cardContainer(
theme,
child: Column(
children: [
for (int i = 0; i < show.length; i++) ...[
if (i > 0)
Divider(
height: 16,
color:
theme.colorScheme.surfaceContainerHighest,
),
Row(
children: [
Expanded(
child: Text(
show[i].mesPagadoDisplay,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
Text(
_fmtMonto(show[i].montoTotal),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
SizedBox(
width: 70,
child: Text(
show[i].metodo,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface
.withAlpha(130),
),
textAlign: TextAlign.end,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
// ─── Helpers ──────────────────────────────────────────
Widget _sectionTitle(String title) {
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
title.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: SomaColors.primary,
letterSpacing: 0.8,
),
),
],
);
}
Widget _cardContainer(ThemeData theme, {required Widget child}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: child,
);
}
Widget _emptyCard(ThemeData theme, String text) {
return _cardContainer(
theme,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
),
),
);
}
Widget _infoCard(ThemeData theme, List<Widget> rows) {
return _cardContainer(
theme,
child: Column(children: rows),
);
}
Widget _infoRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 18, color: SomaColors.primary.withAlpha(150)),
const SizedBox(width: 10),
SizedBox(
width: 100,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
),
),
],
),
);
}
String _fmtMonto(double n) =>
'\$${n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2)}';
String _fmtDate(DateTime d) {
const meses = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
return '${meses[d.month]} ${d.year}';
}
}
@@ -0,0 +1,446 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
/// Dialog para crear o editar un usuario.
/// Retorna un `Map` con los datos si el usuario confirma, o null si cancela.
class UsuarioFormDialog extends StatefulWidget {
final Usuario? usuario; // null = crear, non-null = editar
final Set<String> existingDnis;
final bool actorIsSuperadmin;
const UsuarioFormDialog({
super.key,
this.usuario,
this.existingDnis = const {},
this.actorIsSuperadmin = false,
});
@override
State<UsuarioFormDialog> createState() => _UsuarioFormDialogState();
}
class _UsuarioFormDialogState extends State<UsuarioFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _dniCtrl;
late final TextEditingController _nombreCtrl;
late final TextEditingController _apellidoCtrl;
late final TextEditingController _mailCtrl;
late final TextEditingController _telefonoCtrl;
late final TextEditingController _passwordCtrl;
late final TextEditingController _pesoCtrl;
late final TextEditingController _alturaCtrl;
late final TextEditingController _fuerzaMaxCtrl;
late String _rol;
late String _sexo;
bool _obscurePassword = true;
bool get isEditing => widget.usuario != null;
bool get _showPasswordField =>
_rol != 'cliente' && (!isEditing || widget.actorIsSuperadmin);
@override
void initState() {
super.initState();
final u = widget.usuario;
_dniCtrl = TextEditingController(text: u?.dni ?? '');
_nombreCtrl = TextEditingController(text: u?.nombre ?? '');
_apellidoCtrl = TextEditingController(text: u?.apellido ?? '');
_mailCtrl = TextEditingController(text: u?.mail ?? '');
_telefonoCtrl = TextEditingController(text: u?.telefono ?? '');
_passwordCtrl = TextEditingController();
_pesoCtrl = TextEditingController(
text: u?.peso != null ? u!.peso!.toString() : '',
);
_alturaCtrl = TextEditingController(
text: u?.altura != null ? u!.altura!.toString() : '',
);
_fuerzaMaxCtrl = TextEditingController(
text: u?.fuerzaMax != null ? u!.fuerzaMax!.toString() : '',
);
_rol = u?.rol ?? 'cliente';
_sexo = u?.sexo ?? 'Hombre';
}
@override
void dispose() {
_dniCtrl.dispose();
_nombreCtrl.dispose();
_apellidoCtrl.dispose();
_mailCtrl.dispose();
_telefonoCtrl.dispose();
_passwordCtrl.dispose();
_pesoCtrl.dispose();
_alturaCtrl.dispose();
_fuerzaMaxCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (isEditing) {
final data = <String, dynamic>{
'dni': _dniCtrl.text.trim(),
'nombre': _nombreCtrl.text.trim(),
};
if (_apellidoCtrl.text.trim().isNotEmpty) {
data['apellido'] = _apellidoCtrl.text.trim();
}
if (_mailCtrl.text.trim().isNotEmpty) {
data['mail'] = _mailCtrl.text.trim();
}
if (_telefonoCtrl.text.trim().isNotEmpty) {
data['telefono'] = _telefonoCtrl.text.trim();
}
data['rol'] = _rol;
data['sexo'] = _sexo;
if (_pesoCtrl.text.trim().isNotEmpty) {
data['peso'] = double.tryParse(_pesoCtrl.text.trim());
}
if (_alturaCtrl.text.trim().isNotEmpty) {
data['altura'] = int.tryParse(_alturaCtrl.text.trim());
}
if (_fuerzaMaxCtrl.text.trim().isNotEmpty) {
data['fuerza_max'] = double.tryParse(_fuerzaMaxCtrl.text.trim());
}
if (_showPasswordField && _passwordCtrl.text.trim().isNotEmpty) {
data['password'] = _passwordCtrl.text.trim();
}
Navigator.of(context).pop(data);
} else {
// Crear
final usuario = Usuario(
id: '',
dni: _dniCtrl.text.trim(),
nombre: _nombreCtrl.text.trim(),
apellido: _apellidoCtrl.text.trim().isEmpty
? null
: _apellidoCtrl.text.trim(),
mail: _mailCtrl.text.trim().isEmpty ? null : _mailCtrl.text.trim(),
telefono: _telefonoCtrl.text.trim().isEmpty
? null
: _telefonoCtrl.text.trim(),
rol: _rol,
sexo: _sexo,
peso: double.tryParse(_pesoCtrl.text.trim()),
altura: int.tryParse(_alturaCtrl.text.trim()),
fuerzaMax: double.tryParse(_fuerzaMaxCtrl.text.trim()),
);
Navigator.of(context)
.pop(usuario.toInsertMap(_passwordCtrl.text.trim()));
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 520) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
isEditing ? 'Editar Usuario' : 'Nuevo Usuario',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Información básica'),
const SizedBox(height: 8),
SomaTextField(
controller: _dniCtrl,
labelText: 'DNI *',
prefixIcon: Icons.badge_outlined,
enabled: !isEditing,
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'DNI requerido';
}
if (!isEditing &&
widget.existingDnis.contains(v.trim())) {
return 'Ya existe un usuario con ese DNI';
}
return null;
},
),
const SizedBox(height: 12),
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.person_outline,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 12),
SomaTextField(
controller: _apellidoCtrl,
labelText: 'Apellido',
prefixIcon: Icons.person_outline,
),
const SizedBox(height: 20),
_sectionLabel('Contacto'),
const SizedBox(height: 8),
SomaTextField(
controller: _mailCtrl,
labelText: 'Email',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
SomaTextField(
controller: _telefonoCtrl,
labelText: 'Teléfono',
prefixIcon: Icons.phone_outlined,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 20),
_sectionLabel('Acceso'),
const SizedBox(height: 8),
// El campo de contraseña es dinámico según el rol
// elegido: los clientes no tienen contraseña en este
// panel (usan el bot de WhatsApp). En edición, solo el
// superadmin puede ver/tocar la contraseña de otro
// usuario.
if (_showPasswordField) ...[
SomaTextField(
controller: _passwordCtrl,
labelText: isEditing
? 'Nueva contraseña (opcional)'
: 'Contraseña *',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
size: 20,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
),
validator: (v) {
final value = v?.trim() ?? '';
if (!isEditing && value.isEmpty) {
return 'Contraseña requerida';
}
if (value.isNotEmpty && value.length < 8) {
return 'Mínimo 8 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
],
_dropdownField<String>(
label: 'Rol',
value: _rol,
items: const [
DropdownMenuItem(
value: 'cliente',
child: Text('Cliente'),
),
DropdownMenuItem(
value: 'admin',
child: Text('Admin'),
),
DropdownMenuItem(
value: 'superadmin',
child: Text('Super Admin'),
),
],
onChanged: (v) => setState(() => _rol = v!),
),
const SizedBox(height: 20),
_sectionLabel('Datos físicos'),
const SizedBox(height: 8),
_dropdownField<String>(
label: 'Sexo',
value: _sexo,
items: const [
DropdownMenuItem(
value: 'Hombre',
child: Text('Hombre'),
),
DropdownMenuItem(
value: 'Mujer',
child: Text('Mujer'),
),
DropdownMenuItem(
value: 'Otro',
child: Text('Otro'),
),
],
onChanged: (v) => setState(() => _sexo = v!),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _pesoCtrl,
labelText: 'Peso (kg)',
prefixIcon: Icons.monitor_weight_outlined,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d{0,3}\.?\d{0,2}'),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _alturaCtrl,
labelText: 'Altura (cm)',
prefixIcon: Icons.height,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
),
),
],
),
const SizedBox(height: 12),
SomaTextField(
controller: _fuerzaMaxCtrl,
labelText: 'Fuerza máx. (kg)',
prefixIcon: Icons.fitness_center_outlined,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d{0,7}\.?\d{0,2}'),
),
],
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
Widget _sectionLabel(String text) {
return Text(
text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
letterSpacing: 0.5,
),
);
}
Widget _dropdownField<T>({
required String label,
required T value,
required List<DropdownMenuItem<T>> items,
required ValueChanged<T?> onChanged,
}) {
return DropdownButtonFormField<T>(
initialValue: value,
items: items,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
);
}
}
@@ -0,0 +1,981 @@
import 'dart:ui';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
class UsuariosOverview extends ConsumerWidget {
final VoidCallback? onVerTodos;
const UsuariosOverview({super.key, this.onVerTodos});
@override
Widget build(BuildContext context, WidgetRef ref) {
final usuariosAsync = ref.watch(usuariosProvider);
final deudaAsync = ref.watch(usuariosDeudaProvider);
final isWide =
MediaQuery.of(context).size.width >= AppConstants.kDesktopBreakpoint;
return usuariosAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Text(
e.toString().replaceFirst('Exception: ', ''),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
),
),
),
data: (usuarios) {
final deudaMap = deudaAsync.valueOrNull ?? {};
return _OverviewContent(
usuarios: usuarios,
deudaMap: deudaMap,
isWide: isWide,
onVerTodos: onVerTodos,
);
},
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Content
// ─────────────────────────────────────────────────────────────────────────────
class _OverviewContent extends StatelessWidget {
final List<Usuario> usuarios;
final Map<String, double> deudaMap;
final bool isWide;
final VoidCallback? onVerTodos;
const _OverviewContent({
required this.usuarios,
required this.deudaMap,
required this.isWide,
this.onVerTodos,
});
static const _kInactive = Color(0xFF9E9E9E);
static const _kSinPlan = Color(0xFFBDBDBD);
static const _kStaff = Color(0xFF42A5F5);
@override
Widget build(BuildContext context) {
final hPad = isWide ? 32.0 : 16.0;
// ── Metrics ──────────────────────────────────────────────────────────────
final total = usuarios.length;
final activos = usuarios.where((u) => u.isActive).length;
final inactivos = total - activos;
final conPlan = usuarios
.where((u) => u.isActive && u.tipoCuota != null)
.length;
final sinPlan = activos - conPlan;
final staff = usuarios
.where(
(u) =>
u.rol == 'superadmin' || u.rol == 'admin' || u.rol == 'profesor',
)
.length;
final clientes = usuarios.where((u) => u.rol == 'cliente').length;
final alDia = deudaMap.values.where((v) => v <= 0).length;
final debe = deudaMap.values.where((v) => v > 0).length;
final ultimos =
([...usuarios]..sort((a, b) {
if (a.fechaCreacion == null && b.fechaCreacion == null) return 0;
if (a.fechaCreacion == null) return 1;
if (b.fechaCreacion == null) return -1;
return b.fechaCreacion!.compareTo(a.fechaCreacion!);
}))
.take(5)
.toList();
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(hPad, 12, hPad, 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stat cards
_StatGrid(
total: total,
activos: activos,
conPlan: conPlan,
alDia: alDia,
isWide: isWide,
),
const SizedBox(height: 14),
// Membership pulse
_MembershipPulse(
alDia: alDia,
debe: debe,
sinPlan: sinPlan,
inactivos: inactivos,
total: total,
),
const SizedBox(height: 14),
// Donut charts
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _DonutCard(
title: 'Membresía',
sections: _membershipSections(activos, inactivos),
legend: [
_LegendItem('Activos', SomaColors.success, activos),
_LegendItem('Inactivos', _kInactive, inactivos),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _DonutCard(
title: 'Pagos del mes',
sections: _pageSections(alDia, debe, sinPlan),
legend: [
_LegendItem('Al día', SomaColors.success, alDia),
_LegendItem('Debe', SomaColors.error, debe),
_LegendItem('Sin plan', _kSinPlan, sinPlan),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _DonutCard(
title: 'Roles',
sections: _rolesSections(clientes, staff),
legend: [
_LegendItem('Clientes', SomaColors.primary, clientes),
_LegendItem('Staff', _kStaff, staff),
],
),
),
],
)
: Column(
children: [
_DonutCard(
title: 'Membresía',
sections: _membershipSections(activos, inactivos),
legend: [
_LegendItem('Activos', SomaColors.success, activos),
_LegendItem('Inactivos', _kInactive, inactivos),
],
),
const SizedBox(height: 12),
_DonutCard(
title: 'Pagos del mes',
sections: _pageSections(alDia, debe, sinPlan),
legend: [
_LegendItem('Al día', SomaColors.success, alDia),
_LegendItem('Debe', SomaColors.error, debe),
_LegendItem('Sin plan', _kSinPlan, sinPlan),
],
),
const SizedBox(height: 12),
_DonutCard(
title: 'Roles',
sections: _rolesSections(clientes, staff),
legend: [
_LegendItem('Clientes', SomaColors.primary, clientes),
_LegendItem('Staff', _kStaff, staff),
],
),
],
),
// Recent users
if (ultimos.isNotEmpty) ...[
const SizedBox(height: 14),
Row(
children: [
const _SectionLabel('Últimos ingresados'),
const Spacer(),
if (onVerTodos != null)
TextButton(
onPressed: onVerTodos,
style: TextButton.styleFrom(
foregroundColor: SomaColors.primary,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Ver todos'),
SizedBox(width: 4),
Icon(Icons.arrow_forward, size: 14),
],
),
),
],
),
const SizedBox(height: 10),
...ultimos.map(
(u) => _RecentUserStub(usuario: u, deuda: deudaMap[u.dni]),
),
],
],
),
);
}
List<PieChartSectionData> _membershipSections(int activos, int inactivos) {
final total = activos + inactivos;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (activos > 0)
PieChartSectionData(
color: SomaColors.success,
value: activos.toDouble(),
title: '',
radius: 30,
),
if (inactivos > 0)
PieChartSectionData(
color: _kInactive,
value: inactivos.toDouble(),
title: '',
radius: 30,
),
];
}
List<PieChartSectionData> _rolesSections(int clientes, int staff) {
final total = clientes + staff;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (clientes > 0)
PieChartSectionData(
color: SomaColors.primary,
value: clientes.toDouble(),
title: '',
radius: 30,
),
if (staff > 0)
PieChartSectionData(
color: _kStaff,
value: staff.toDouble(),
title: '',
radius: 30,
),
];
}
List<PieChartSectionData> _pageSections(int alDia, int debe, int sinPlan) {
final total = alDia + debe + sinPlan;
if (total == 0) {
return [
PieChartSectionData(color: _kInactive, value: 1, title: '', radius: 30),
];
}
return [
if (alDia > 0)
PieChartSectionData(
color: SomaColors.success,
value: alDia.toDouble(),
title: '',
radius: 30,
),
if (debe > 0)
PieChartSectionData(
color: SomaColors.error,
value: debe.toDouble(),
title: '',
radius: 30,
),
if (sinPlan > 0)
PieChartSectionData(
color: _kSinPlan,
value: sinPlan.toDouble(),
title: '',
radius: 30,
),
];
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Glass card container
// ─────────────────────────────────────────────────────────────────────────────
class _GlassCard extends StatelessWidget {
final Widget child;
final EdgeInsets padding;
final double radius;
const _GlassCard({
required this.child,
this.padding = const EdgeInsets.all(16),
this.radius = 20,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: Container(
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: Colors.white.withAlpha(18),
border: Border.all(
color: Colors.white.withAlpha(38),
width: 0.8,
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(55),
blurRadius: 24,
spreadRadius: -4,
offset: const Offset(0, 6),
),
],
),
child: child,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Stat grid + card
// ─────────────────────────────────────────────────────────────────────────────
class _StatGrid extends StatelessWidget {
final int total;
final int activos;
final int conPlan;
final int alDia;
final bool isWide;
const _StatGrid({
required this.total,
required this.activos,
required this.conPlan,
required this.alDia,
required this.isWide,
});
@override
Widget build(BuildContext context) {
final items = [
(Icons.groups_outlined, 'Total', total, SomaColors.primary),
(Icons.how_to_reg_outlined, 'Activos', activos, SomaColors.success),
(
Icons.card_membership_outlined,
'Con plan',
conPlan,
const Color(0xFF42A5F5),
),
(Icons.check_circle_outline, 'Al día', alDia, SomaColors.success),
];
if (isWide) {
return Row(
children: [
for (int i = 0; i < items.length; i++) ...[
Expanded(
child: _StatCard(
icon: items[i].$1,
label: items[i].$2,
value: items[i].$3,
color: items[i].$4,
),
),
if (i < items.length - 1) const SizedBox(width: 10),
],
],
);
}
return Column(
children: [
Row(
children: [
Expanded(
child: _StatCard(
icon: items[0].$1,
label: items[0].$2,
value: items[0].$3,
color: items[0].$4,
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: items[1].$1,
label: items[1].$2,
value: items[1].$3,
color: items[1].$4,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _StatCard(
icon: items[2].$1,
label: items[2].$2,
value: items[2].$3,
color: items[2].$4,
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: items[3].$1,
label: items[3].$2,
value: items[3].$3,
color: items[3].$4,
),
),
],
),
],
);
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String label;
final int value;
final Color color;
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return _GlassCard(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withAlpha(30),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(height: 10),
Text(
'$value',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: color,
height: 1.0,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Membership pulse
// ─────────────────────────────────────────────────────────────────────────────
class _MembershipPulse extends StatelessWidget {
final int alDia;
final int debe;
final int sinPlan;
final int inactivos;
final int total;
const _MembershipPulse({
required this.alDia,
required this.debe,
required this.sinPlan,
required this.inactivos,
required this.total,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final segments = <({Color color, int count, String label})>[
(color: SomaColors.success, count: alDia, label: 'Al día'),
(color: SomaColors.error, count: debe, label: 'Debe'),
(
color: SomaColors.primary.withAlpha(130),
count: sinPlan,
label: 'Sin plan',
),
(
color: theme.colorScheme.onSurface.withAlpha(45),
count: inactivos,
label: 'Inactivos',
),
].where((s) => s.count > 0).toList();
return _GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Pulso de membresía',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(200),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: SomaColors.primary.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: SomaColors.primary.withAlpha(50),
width: 0.5,
),
),
child: Text(
'$total miembros',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SomaColors.primary,
),
),
),
],
),
const SizedBox(height: 14),
// Proportional strip
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
height: 18,
child: total == 0
? Container(color: theme.colorScheme.surfaceContainerHighest)
: Row(
children: [
for (int i = 0; i < segments.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
Expanded(
flex: segments[i].count,
child: Container(color: segments[i].color),
),
],
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 14,
runSpacing: 6,
children: segments.map((s) {
final pct = total > 0 ? (s.count / total * 100).round() : 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: s.color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 5),
Text(
'${s.label} $pct%',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}).toList(),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Donut card — column layout fixes overflow
// ─────────────────────────────────────────────────────────────────────────────
class _LegendItem {
final String label;
final Color color;
final int count;
const _LegendItem(this.label, this.color, this.count);
}
class _DonutCard extends StatelessWidget {
final String title;
final List<PieChartSectionData> sections;
final List<_LegendItem> legend;
const _DonutCard({
required this.title,
required this.sections,
required this.legend,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final total = legend.fold<int>(0, (s, item) => s + item.count);
final leadColor = legend.isNotEmpty
? legend.first.color
: SomaColors.primary;
return _GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title + total
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withAlpha(200),
),
),
),
Text(
'$total',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: leadColor,
height: 1.0,
),
),
],
),
const SizedBox(height: 16),
// Donut chart — constrained to avoid overflow
SizedBox(
height: 110,
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: PieChart(
PieChartData(
sections: sections,
centerSpaceRadius: 32,
sectionsSpace: 3,
startDegreeOffset: -90,
),
),
),
),
),
const SizedBox(height: 16),
// Legend below — no overflow possible
...legend.map((item) {
final pct = total > 0 ? (item.count / total * 100).round() : 0;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(160),
),
),
),
Text(
'$pct%',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 8),
Text(
'${item.count}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: item.color,
),
),
],
),
);
}),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Section label
// ─────────────────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 3,
height: 14,
decoration: BoxDecoration(
color: SomaColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 7),
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Recent user stub
// ─────────────────────────────────────────────────────────────────────────────
class _RecentUserStub extends StatelessWidget {
final Usuario usuario;
final double? deuda;
const _RecentUserStub({required this.usuario, required this.deuda});
String _initials() {
if (usuario.nombre.isNotEmpty) {
final ap = usuario.apellido;
if (ap != null && ap.isNotEmpty) {
return '${usuario.nombre[0]}${ap[0]}'.toUpperCase();
}
return usuario.nombre[0].toUpperCase();
}
if (usuario.dni.length >= 2) return usuario.dni.substring(0, 2);
return '?';
}
String _rolLabel() => switch (usuario.rol) {
'superadmin' => 'Super Admin',
'admin' => 'Admin',
'profesor' => 'Profesor',
_ => 'Cliente',
};
String _fechaLabel() {
final d = usuario.fechaCreacion;
if (d == null) return '';
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color statusColor;
final String statusLabel;
if (!usuario.isActive) {
statusColor = theme.colorScheme.onSurface.withAlpha(100);
statusLabel = 'Inactivo';
} else if (usuario.tipoCuota == null) {
statusColor = SomaColors.primary.withAlpha(160);
statusLabel = 'Sin plan';
} else if (deuda != null && deuda! > 0) {
statusColor = SomaColors.error;
statusLabel = 'Debe';
} else {
statusColor = SomaColors.success;
statusLabel = 'Al día';
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
radius: 14,
child: Row(
children: [
// Avatar with status dot
Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: 18,
backgroundColor: SomaColors.primary.withAlpha(35),
child: Text(
_initials(),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
border: Border.all(
color: SomaColors.darkBackground,
width: 1.5,
),
),
),
),
],
),
const SizedBox(width: 12),
// Name + role
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
usuario.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
Text(
_rolLabel(),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
],
),
),
// Date
if (_fechaLabel().isNotEmpty) ...[
Text(
_fechaLabel(),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withAlpha(110),
),
),
const SizedBox(width: 10),
],
// Status badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: statusColor.withAlpha(60),
width: 0.5,
),
),
child: Text(
statusLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,756 @@
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
enum SortColumn { nombre, dni, email, rol, deuda, estado, ultimoPago }
enum TableCol { avatar, nombre, dni, email, rol, estadoPago, ultimoPago, estado }
extension _ColProps on TableCol {
String get label => switch (this) {
TableCol.avatar => 'Avatar',
TableCol.nombre => 'Nombre',
TableCol.dni => 'DNI',
TableCol.email => 'Email',
TableCol.rol => 'Rol',
TableCol.estadoPago => 'Estado Pago',
TableCol.ultimoPago => 'Último Pago',
TableCol.estado => 'Estado',
};
int get flex => switch (this) {
TableCol.avatar => 0,
TableCol.nombre => 28,
TableCol.dni => 15,
TableCol.email => 23,
TableCol.rol => 13,
TableCol.estadoPago => 17,
TableCol.ultimoPago => 15,
TableCol.estado => 13,
};
bool get hideable => this != TableCol.nombre && this != TableCol.dni;
SortColumn? get sort => switch (this) {
TableCol.nombre => SortColumn.nombre,
TableCol.dni => SortColumn.dni,
TableCol.email => SortColumn.email,
TableCol.rol => SortColumn.rol,
TableCol.estadoPago => SortColumn.deuda,
TableCol.ultimoPago => SortColumn.ultimoPago,
TableCol.estado => SortColumn.estado,
_ => null,
};
}
const double _kAvatarColW = 44.0;
const double _kToggleBtnW = 36.0;
class UsuariosTableView extends StatefulWidget {
final List<Usuario> usuarios;
final Map<String, double> deudaMap;
final Map<String, DateTime?> ultimoPagoMap;
final void Function(Usuario) onTap;
final void Function(Usuario) onEdit;
final void Function(Usuario) onToggleStatus;
final void Function(Usuario) onDelete;
final void Function(Usuario) onRegistrarPago;
final void Function(Usuario) onAsignarRutina;
final void Function(Usuario) onVerHistorial;
final void Function(Usuario) onEditarPlan;
final bool actorIsSuperadmin;
final void Function(Usuario)? onResetPassword;
const UsuariosTableView({
super.key,
required this.usuarios,
required this.deudaMap,
required this.ultimoPagoMap,
required this.onTap,
required this.onEdit,
required this.onToggleStatus,
required this.onDelete,
required this.onRegistrarPago,
required this.onAsignarRutina,
required this.onVerHistorial,
required this.onEditarPlan,
this.actorIsSuperadmin = false,
this.onResetPassword,
});
@override
State<UsuariosTableView> createState() => _UsuariosTableViewState();
}
class _UsuariosTableViewState extends State<UsuariosTableView> {
SortColumn _sortColumn = SortColumn.nombre;
bool _sortAscending = true;
final _scrollCtrl = ScrollController();
bool _scrollbarVisible = false;
final _columnBtnKey = GlobalKey();
final Set<TableCol> _visibleCols = {
TableCol.nombre,
TableCol.dni,
TableCol.rol,
TableCol.estadoPago,
TableCol.ultimoPago,
TableCol.estado,
};
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
double? _getDeuda(Usuario u) {
if (u.tipoCuota == null || !u.isActive) return null;
return widget.deudaMap[u.dni] ?? 0.0;
}
List<Usuario> get _sorted {
final list = List<Usuario>.from(widget.usuarios);
list.sort((a, b) {
final int cmp;
switch (_sortColumn) {
case SortColumn.nombre:
cmp = a.displayName
.toLowerCase()
.compareTo(b.displayName.toLowerCase());
case SortColumn.dni:
cmp = a.dni.compareTo(b.dni);
case SortColumn.email:
cmp = (a.mail ?? '')
.toLowerCase()
.compareTo((b.mail ?? '').toLowerCase());
case SortColumn.rol:
cmp = a.rolDisplay.compareTo(b.rolDisplay);
case SortColumn.deuda:
cmp = (_getDeuda(a) ?? -999999)
.compareTo(_getDeuda(b) ?? -999999);
case SortColumn.ultimoPago:
final fa = widget.ultimoPagoMap[a.dni];
final fb = widget.ultimoPagoMap[b.dni];
if (fa == null && fb == null) {
cmp = 0;
} else if (fa == null) {
cmp = -1;
} else if (fb == null) {
cmp = 1;
} else {
cmp = fa.compareTo(fb);
}
case SortColumn.estado:
cmp =
(a.isActive ? 1 : 0).compareTo(b.isActive ? 1 : 0);
}
return _sortAscending ? cmp : -cmp;
});
return list;
}
void _onSort(SortColumn col) => setState(() {
if (_sortColumn == col) {
_sortAscending = !_sortAscending;
} else {
_sortColumn = col;
_sortAscending = true;
}
});
void _showColumnPicker() {
final ctx = _columnBtnKey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject() as RenderBox;
final offset = box.localToGlobal(Offset.zero);
final btnSize = box.size;
final screenWidth = MediaQuery.of(context).size.width;
showDialog(
context: context,
barrierColor: Colors.transparent,
builder: (dCtx) => StatefulBuilder(
builder: (dCtx, setLocal) => Stack(
children: [
GestureDetector(
onTap: () => Navigator.pop(dCtx),
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
Positioned(
top: offset.dy + btnSize.height + 4,
right: screenWidth - (offset.dx + btnSize.width),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(10),
color: Theme.of(dCtx).colorScheme.surface,
child: Container(
constraints: const BoxConstraints(minWidth: 175),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 8),
child: Text(
'COLUMNAS VISIBLES',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(dCtx)
.colorScheme
.onSurface
.withAlpha(100),
),
),
),
for (final col
in TableCol.values.where((c) => c.hideable))
InkWell(
onTap: () {
setState(() {
if (_visibleCols.contains(col)) {
_visibleCols.remove(col);
} else {
_visibleCols.add(col);
}
});
setLocal(() {});
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 9),
child: Row(
children: [
Icon(
_visibleCols.contains(col)
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded,
size: 17,
color: _visibleCols.contains(col)
? SomaColors.primary
: Theme.of(dCtx)
.colorScheme
.onSurface
.withAlpha(100),
),
const SizedBox(width: 10),
Text(col.label,
style: const TextStyle(fontSize: 13)),
],
),
),
),
],
),
),
),
),
],
),
),
);
}
ContextMenu<String> _contextMenuFor(Usuario u) => ContextMenu(
entries: [
MenuItem(
label: const Text('Registrar pago'),
icon: const Icon(Icons.payment, size: 16),
value: 'pago',
),
MenuItem(
label: const Text('Asignar rutina'),
icon: const Icon(Icons.fitness_center, size: 16),
value: 'rutina',
),
MenuItem(
label: const Text('Ver historial'),
icon: const Icon(Icons.history, size: 16),
value: 'historial',
),
MenuItem(
label: const Text('Editar plan'),
icon: const Icon(Icons.card_membership, size: 16),
value: 'plan',
),
const MenuDivider(),
MenuItem(
label: const Text('Editar'),
icon: const Icon(Icons.edit_outlined, size: 16),
value: 'edit',
),
MenuItem(
label: Text(u.isActive ? 'Desactivar' : 'Activar'),
icon: Icon(
u.isActive
? Icons.person_off_outlined
: Icons.person_outlined,
size: 16,
),
value: 'toggle',
),
if (widget.actorIsSuperadmin && u.rol != 'cliente')
MenuItem(
label: const Text('Cambiar contraseña'),
icon: const Icon(Icons.lock_reset, size: 16),
value: 'password',
),
if (widget.actorIsSuperadmin)
MenuItem(
label: const Text('Eliminar',
style: TextStyle(color: SomaColors.error)),
icon: const Icon(Icons.delete_outline,
size: 16, color: SomaColors.error),
value: 'delete',
),
],
);
void _handleAction(String? value, Usuario u) {
if (value == null) return;
switch (value) {
case 'pago':
widget.onRegistrarPago(u);
case 'rutina':
widget.onAsignarRutina(u);
case 'historial':
widget.onVerHistorial(u);
case 'plan':
widget.onEditarPlan(u);
case 'edit':
widget.onEdit(u);
case 'toggle':
widget.onToggleStatus(u);
case 'password':
widget.onResetPassword?.call(u);
case 'delete':
widget.onDelete(u);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sorted = _sorted;
if (sorted.isEmpty) {
return const Center(child: Text('No hay usuarios para mostrar'));
}
final flexCols = TableCol.values
.where((c) => c != TableCol.avatar && _visibleCols.contains(c))
.toList();
return Container(
decoration: BoxDecoration(
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(70),
),
borderRadius: BorderRadius.circular(14),
),
clipBehavior: Clip.hardEdge,
child: Column(
children: [
// ── Header ───────────────────────────────────────────────────
Container(
decoration: BoxDecoration(
color:
theme.colorScheme.surfaceContainerHighest.withAlpha(50),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(80),
),
),
),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
children: [
if (_visibleCols.contains(TableCol.avatar))
const SizedBox(width: _kAvatarColW),
for (final col in flexCols)
Expanded(
flex: col.flex,
child: _HeaderCell(
col: col,
sortColumn: _sortColumn,
ascending: _sortAscending,
onSort: () {
if (col.sort != null) _onSort(col.sort!);
},
),
),
SizedBox(
width: _kToggleBtnW,
child: Align(
alignment: Alignment.centerRight,
child: IconButton(
key: _columnBtnKey,
icon: const Icon(Icons.view_column_outlined,
size: 16),
tooltip: 'Columnas',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28, minHeight: 28),
style: IconButton.styleFrom(
foregroundColor:
theme.colorScheme.onSurface.withAlpha(110),
),
onPressed: _showColumnPicker,
),
),
),
],
),
),
// ── Rows ─────────────────────────────────────────────────────
Expanded(
child: MouseRegion(
onEnter: (_) =>
setState(() => _scrollbarVisible = true),
onExit: (_) =>
setState(() => _scrollbarVisible = false),
child: ScrollbarTheme(
data: ScrollbarThemeData(
thumbColor: WidgetStateProperty.all(
_scrollbarVisible
? theme.colorScheme.onSurface
.withValues(alpha: 0.35)
: Colors.transparent,
),
trackVisibility:
WidgetStateProperty.all(false),
trackColor:
WidgetStateProperty.all(Colors.transparent),
trackBorderColor:
WidgetStateProperty.all(Colors.transparent),
),
child: Scrollbar(
controller: _scrollCtrl,
thumbVisibility: true,
child: ListView.builder(
controller: _scrollCtrl,
itemCount: sorted.length,
itemBuilder: (context, i) {
final u = sorted[i];
return ContextMenuRegion(
contextMenu: _contextMenuFor(u),
onItemSelected: (v) =>
_handleAction(v, u),
child: _UserRow(
usuario: u,
deuda: _getDeuda(u),
ultimoPago: widget.ultimoPagoMap[u.dni],
visibleCols: _visibleCols,
flexCols: flexCols,
onTap: () => widget.onTap(u),
isLast: i == sorted.length - 1,
),
);
},
),
),
),
),
),
],
),
);
}
}
// ── Header cell ────────────────────────────────────────────────────────────────
class _HeaderCell extends StatelessWidget {
final TableCol col;
final SortColumn sortColumn;
final bool ascending;
final VoidCallback onSort;
const _HeaderCell({
required this.col,
required this.sortColumn,
required this.ascending,
required this.onSort,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isActive = col.sort != null && col.sort == sortColumn;
final label = Text(
col.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: isActive
? SomaColors.primary
: theme.colorScheme.onSurface.withAlpha(110),
),
);
if (col.sort == null) return label;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onSort,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
label,
const SizedBox(width: 3),
if (isActive)
Icon(
ascending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 11,
color: SomaColors.primary,
)
else
Icon(
Icons.unfold_more_rounded,
size: 11,
color: theme.colorScheme.onSurface.withAlpha(55),
),
],
),
),
);
}
}
// ── Data row ───────────────────────────────────────────────────────────────────
class _UserRow extends StatefulWidget {
final Usuario usuario;
final double? deuda;
final DateTime? ultimoPago;
final Set<TableCol> visibleCols;
final List<TableCol> flexCols;
final VoidCallback onTap;
final bool isLast;
const _UserRow({
required this.usuario,
required this.deuda,
required this.ultimoPago,
required this.visibleCols,
required this.flexCols,
required this.onTap,
required this.isLast,
});
@override
State<_UserRow> createState() => _UserRowState();
}
class _UserRowState extends State<_UserRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final u = widget.usuario;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.onSurface.withAlpha(7)
: Colors.transparent,
border: widget.isLast
? null
: Border(
bottom: BorderSide(
color: theme.colorScheme.surfaceContainerHighest
.withAlpha(50),
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 52,
child: Row(
children: [
if (widget.visibleCols.contains(TableCol.avatar))
SizedBox(
width: _kAvatarColW,
child: CircleAvatar(
radius: 14,
backgroundColor: u.isActive
? SomaColors.primary.withAlpha(30)
: theme.colorScheme.surfaceContainerHighest,
child: Text(
u.initials,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: u.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface
.withAlpha(100),
),
),
),
),
for (final col in widget.flexCols)
Expanded(
flex: col.flex,
child: _cellFor(col, u, theme),
),
const SizedBox(width: _kToggleBtnW),
],
),
),
),
);
}
Widget _cellFor(TableCol col, Usuario u, ThemeData theme) =>
switch (col) {
TableCol.nombre => Padding(
padding: const EdgeInsets.only(right: 12),
child: Text(
u.displayName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: u.isActive
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(100),
),
overflow: TextOverflow.ellipsis,
),
),
TableCol.dni => Text(
u.dni,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
TableCol.email => Padding(
padding: const EdgeInsets.only(right: 12),
child: Text(
u.mail ?? '-',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
overflow: TextOverflow.ellipsis,
),
),
TableCol.rol => _RolText(rol: u.rol),
TableCol.estadoPago => widget.deuda != null
? _DeudaText(monto: widget.deuda!)
: Text(
'Sin plan',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(100),
),
),
TableCol.ultimoPago => () {
final d = widget.ultimoPago;
if (d == null) {
return Text(
'-',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(100),
),
);
}
return Text(
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurface.withAlpha(130),
),
);
}(),
TableCol.estado => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
u.isActive ? SomaColors.success : SomaColors.error,
),
),
const SizedBox(width: 5),
Text(
u.isActive ? 'Activo' : 'Inactivo',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
_ => const SizedBox.shrink(),
};
}
// ── Role text ──────────────────────────────────────────────────────────────────
class _RolText extends StatelessWidget {
final String rol;
const _RolText({required this.rol});
@override
Widget build(BuildContext context) {
final (Color fg, String label) = switch (rol) {
'superadmin' => (const Color(0xFFCE93D8), 'Superadmin'),
'admin' => (SomaColors.primary, 'Admin'),
'profesor' => (const Color(0xFF90CAF9), 'Profesor'),
_ => (
Theme.of(context).colorScheme.onSurface.withAlpha(130),
'Cliente',
),
};
return Text(
label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg),
);
}
}
// ── Debt text ──────────────────────────────────────────────────────────────────
class _DeudaText extends StatelessWidget {
final double monto;
const _DeudaText({required this.monto});
@override
Widget build(BuildContext context) {
final isDebe = monto > 0;
final (Color fg, String label) = isDebe
? (SomaColors.error, 'Debe \$$monto')
: (SomaColors.success, 'Al día');
return Text(
label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: fg),
);
}
}