Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -0,0 +1,65 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gimnasio_soma/core/config/app_constants.dart';
import 'package:gimnasio_soma/core/config/supabase_config.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
class ActividadesRepositoryImpl implements ActividadesRepository {
Future<String> _getToken() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(AppConstants.tokenKey);
if (token == null) throw Exception('Sin sesión activa');
return token;
}
@override
Future<List<Actividad>> getActividades({bool? soloActivas}) async {
final token = await _getToken();
final params = <String, dynamic>{'p_token': token};
if (soloActivas != null) params['p_solo_activas'] = soloActivas;
final response = await SupabaseConfig.rpc(
AppConstants.rpcGetActividades,
params: params,
);
if (response is List) {
return response
.map((e) => Actividad.fromMap(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<void> insertActividad(Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcInsertActividad,
params: {'p_token': token, 'p_datos': datos},
);
}
@override
Future<void> updateActividad(int id, Map<String, dynamic> datos) async {
final token = await _getToken();
await SupabaseConfig.rpc(
AppConstants.rpcUpdateActividad,
params: {'p_token': token, 'p_actividad_id': id, 'p_datos': datos},
);
}
@override
Future<bool> deleteActividad(int id) async {
final token = await _getToken();
final response = await SupabaseConfig.rpc(
AppConstants.rpcDeleteActividad,
params: {'p_token': token, 'p_actividad_id': id},
);
return response == true;
}
}
@@ -0,0 +1,47 @@
class Actividad {
final int id;
final String nombre;
final int duracion; // minutos
final int capacidadPorDefecto;
final bool libre;
final bool activo;
const Actividad({
required this.id,
required this.nombre,
required this.duracion,
required this.capacidadPorDefecto,
this.libre = false,
this.activo = true,
});
factory Actividad.fromMap(Map<String, dynamic> map) {
return Actividad(
id: map['id'] as int,
nombre: map['nombre'] as String? ?? '',
duracion: (map['duracion'] as num?)?.toInt() ?? 0,
capacidadPorDefecto: (map['capacidad_por_defecto'] as num?)?.toInt() ?? 0,
libre: map['libre'] as bool? ?? false,
activo: map['activo'] as bool? ?? true,
);
}
Map<String, dynamic> toMap() {
return {
'nombre': nombre,
'duracion': duracion,
'capacidad_por_defecto': capacidadPorDefecto,
'libre': libre,
'activo': activo,
};
}
String get duracionDisplay {
if (duracion >= 60) {
final h = duracion ~/ 60;
final m = duracion % 60;
return m > 0 ? '${h}h ${m}min' : '${h}h';
}
return '${duracion}min';
}
}
@@ -0,0 +1,8 @@
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
abstract class ActividadesRepository {
Future<List<Actividad>> getActividades({bool? soloActivas});
Future<void> insertActividad(Map<String, dynamic> datos);
Future<void> updateActividad(int id, Map<String, dynamic> datos);
Future<bool> deleteActividad(int id);
}
@@ -0,0 +1,67 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/features/actividades/data/repositories/actividades_repository_impl.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/domain/repositories/actividades_repository.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
String _errorMessage(Object e) {
if (e is PostgrestException) return e.message;
return e.toString().replaceFirst('Exception: ', '');
}
final actividadesRepositoryProvider = Provider<ActividadesRepository>((ref) {
return ActividadesRepositoryImpl();
});
final actividadesProvider =
StateNotifierProvider<ActividadesNotifier, AsyncValue<List<Actividad>>>((ref) {
return ActividadesNotifier(ref.read(actividadesRepositoryProvider));
});
class ActividadesNotifier extends StateNotifier<AsyncValue<List<Actividad>>> {
final ActividadesRepository _repository;
ActividadesNotifier(this._repository) : super(const AsyncValue.loading()) {
loadActividades();
}
Future<void> loadActividades() async {
state = const AsyncValue.loading();
try {
final actividades = await _repository.getActividades();
state = AsyncValue.data(actividades);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<String?> insertActividad(Map<String, dynamic> datos) async {
try {
await _repository.insertActividad(datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> updateActividad(int id, Map<String, dynamic> datos) async {
try {
await _repository.updateActividad(id, datos);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
Future<String?> deleteActividad(int id) async {
try {
await _repository.deleteActividad(id);
await loadActividades();
return null;
} catch (e) {
return _errorMessage(e);
}
}
}
@@ -0,0 +1,445 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gimnasio_soma/core/theme/activity_colors.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
import 'package:gimnasio_soma/features/actividades/presentation/providers/actividades_provider.dart';
import 'package:gimnasio_soma/features/actividades/presentation/widgets/actividad_form_dialog.dart';
class ActividadesScreen extends ConsumerStatefulWidget {
const ActividadesScreen({super.key});
@override
ConsumerState<ActividadesScreen> createState() => _ActividadesScreenState();
}
class _ActividadesScreenState extends ConsumerState<ActividadesScreen> {
Future<void> _showCreateDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const ActividadFormDialog(),
);
if (result == null || !mounted) return;
final error =
await ref.read(actividadesProvider.notifier).insertActividad(result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad creada', type: ToastType.success);
}
}
Future<void> _showEditDialog(Actividad actividad) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => ActividadFormDialog(actividad: actividad),
);
if (result == null || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.updateActividad(actividad.id, result);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad actualizada', type: ToastType.success);
}
}
Future<void> _deleteActividad(Actividad actividad) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar actividad'),
content: Text(
'¿Estás seguro de que querés eliminar "${actividad.nombre}"?\n'
'Esta acción no se puede deshacer.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: SomaColors.error,
foregroundColor: Colors.white,
minimumSize: const Size(0, 40),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true || !mounted) return;
final error = await ref
.read(actividadesProvider.notifier)
.deleteActividad(actividad.id);
if (!mounted) return;
if (error != null) {
SomaToast.show(context, message: error, type: ToastType.error);
} else {
SomaToast.show(context,
message: 'Actividad eliminada', type: ToastType.success);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(actividadesProvider);
final isWide = MediaQuery.of(context).size.width >= 800;
return Scaffold(
body: Column(
children: [
// Header
Padding(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16,
isWide ? 28 : 16,
isWide ? 32 : 16,
12,
),
child: Row(
children: [
const Text(
'Actividades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const Spacer(),
_AddButton(isWide: isWide, onTap: _showCreateDialog),
],
),
),
// Lista
Expanded(
child: state.when(
loading: () => const Center(
child: CircularProgressIndicator(color: SomaColors.primary),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(100)),
const SizedBox(height: 12),
Text(
e.toString().replaceFirst('Exception: ', ''),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(153),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Reintentar'),
),
],
),
),
data: (actividades) {
if (actividades.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fitness_center_outlined,
size: 56,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(60)),
const SizedBox(height: 12),
Text(
'No hay actividades',
style: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(130),
),
),
],
),
);
}
return RefreshIndicator(
color: SomaColors.primary,
onRefresh: () => ref
.read(actividadesProvider.notifier)
.loadActividades(),
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
),
itemCount: actividades.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final act = actividades[index];
return _ActividadCard(
actividad: act,
onEdit: () => _showEditDialog(act),
onDelete: () => _deleteActividad(act),
);
},
),
);
},
),
),
],
),
);
}
}
class _ActividadCard extends StatelessWidget {
final Actividad actividad;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _ActividadCard({
required this.actividad,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onEdit,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
border: Border.all(
color: theme.colorScheme.surfaceContainerHighest,
width: 0.5,
),
),
child: Row(
children: [
// Ícono
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: actividad.activo
? ActivityColors.forId(actividad.id).withAlpha(30)
: theme.colorScheme.onSurface.withAlpha(15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.fitness_center,
size: 20,
color: actividad.activo
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withAlpha(100),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
actividad.nombre,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!actividad.activo)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Inactiva',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
),
if (actividad.libre)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: SomaColors.success.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Libre',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: SomaColors.success,
),
),
),
),
],
),
const SizedBox(height: 3),
Row(
children: [
Icon(Icons.timer_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
actividad.duracionDisplay,
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
Text(
'',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(80),
),
),
Icon(Icons.group_outlined,
size: 14,
color:
theme.colorScheme.onSurface.withAlpha(100)),
const SizedBox(width: 4),
Text(
'${actividad.capacidadPorDefecto} personas',
style: TextStyle(
fontSize: 12,
color:
theme.colorScheme.onSurface.withAlpha(130),
),
),
],
),
],
),
),
// Actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: theme.colorScheme.onSurface.withAlpha(100),
),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Editar'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline, size: 18, color: SomaColors.error),
SizedBox(width: 8),
Text('Eliminar',
style: TextStyle(color: SomaColors.error)),
],
),
),
],
onSelected: (v) {
if (v == 'edit') onEdit();
if (v == 'delete') onDelete();
},
),
],
),
),
);
}
}
class _AddButton extends StatelessWidget {
final bool isWide;
final VoidCallback onTap;
const _AddButton({required this.isWide, required this.onTap});
@override
Widget build(BuildContext context) {
if (isWide) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.add, size: 20),
label: const Text('Nueva'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
);
}
return SizedBox(
height: 42,
width: 42,
child: IconButton.filled(
onPressed: onTap,
icon: const Icon(Icons.add, size: 22),
style: IconButton.styleFrom(
backgroundColor: SomaColors.primary,
foregroundColor: SomaColors.onPrimary,
),
),
);
}
}
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
import 'package:gimnasio_soma/features/actividades/domain/entities/actividad.dart';
class ActividadFormDialog extends StatefulWidget {
final Actividad? actividad;
const ActividadFormDialog({super.key, this.actividad});
@override
State<ActividadFormDialog> createState() => _ActividadFormDialogState();
}
class _ActividadFormDialogState extends State<ActividadFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nombreCtrl;
late final TextEditingController _duracionCtrl;
late final TextEditingController _capacidadCtrl;
late bool _libre;
bool get _isEditing => widget.actividad != null;
@override
void initState() {
super.initState();
_nombreCtrl = TextEditingController(text: widget.actividad?.nombre ?? '');
_duracionCtrl = TextEditingController(
text: widget.actividad != null ? widget.actividad!.duracion.toString() : '',
);
_capacidadCtrl = TextEditingController(
text: widget.actividad != null
? widget.actividad!.capacidadPorDefecto.toString()
: '',
);
_libre = widget.actividad?.libre ?? false;
}
@override
void dispose() {
_nombreCtrl.dispose();
_duracionCtrl.dispose();
_capacidadCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'nombre': _nombreCtrl.text.trim(),
'duracion': int.tryParse(_duracionCtrl.text.trim()) ?? 0,
'capacidad_por_defecto': int.tryParse(_capacidadCtrl.text.trim()) ?? 0,
'libre': _libre,
};
Navigator.of(context).pop(data);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isWide = width >= 600;
return Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isWide ? (width - 440) / 2 : 20,
vertical: 24,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
child: Row(
children: [
Text(
_isEditing ? 'Editar Actividad' : 'Nueva Actividad',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 20),
// Form
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SomaTextField(
controller: _nombreCtrl,
labelText: 'Nombre *',
prefixIcon: Icons.fitness_center,
validator: (v) => v == null || v.trim().isEmpty
? 'Nombre requerido'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SomaTextField(
controller: _duracionCtrl,
labelText: 'Duración (min) *',
prefixIcon: Icons.timer_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: SomaTextField(
controller: _capacidadCtrl,
labelText: 'Capacidad *',
prefixIcon: Icons.group_outlined,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Requerido';
}
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Inválido';
return null;
},
),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text(
'Actividad libre',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: const Text(
'No requiere turno previo',
style: TextStyle(fontSize: 12),
),
value: _libre,
activeThumbColor: SomaColors.primary,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _libre = v),
),
const SizedBox(height: 8),
],
),
),
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha(178),
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 42),
),
child: Text(_isEditing ? 'Guardar' : 'Crear'),
),
],
),
),
],
),
),
);
}
}