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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user