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