diff --git a/frontend/lib/core/models/ticket_model.dart b/frontend/lib/core/models/ticket_model.dart index e009220..260090c 100644 --- a/frontend/lib/core/models/ticket_model.dart +++ b/frontend/lib/core/models/ticket_model.dart @@ -5,6 +5,7 @@ class TicketType { final int maxCount; final double price; final String currency; + final DateTime? availableFrom; // Added this field TicketType({ this.typeId, @@ -13,6 +14,7 @@ class TicketType { required this.maxCount, required this.price, required this.currency, + this.availableFrom, // Added this parameter }); factory TicketType.fromJson(Map json) { @@ -24,6 +26,10 @@ class TicketType { // Price can be int or double from JSON price: (json['price'] as num).toDouble(), currency: json['currency'] ?? 'USD', + // Parse availableFrom field + availableFrom: json['available_from'] != null + ? DateTime.parse(json['available_from']) + : null, ); } @@ -35,8 +41,34 @@ class TicketType { 'max_count': maxCount, 'price': price, 'currency': currency, + 'available_from': availableFrom?.toIso8601String(), // Added this field }; } + + TicketType copyWith({ + int? typeId, + int? eventId, + String? description, + int? maxCount, + double? price, + String? currency, + DateTime? availableFrom, + }) { + return TicketType( + typeId: typeId ?? this.typeId, + eventId: eventId ?? this.eventId, + description: description ?? this.description, + maxCount: maxCount ?? this.maxCount, + price: price ?? this.price, + currency: currency ?? this.currency, + availableFrom: availableFrom ?? this.availableFrom, + ); + } + + @override + String toString() { + return 'TicketType{typeId: $typeId, eventId: $eventId, description: $description, maxCount: $maxCount, price: $price, currency: $currency, availableFrom: $availableFrom}'; + } } class TicketDetailsModel { @@ -45,12 +77,15 @@ class TicketDetailsModel { final String? seat; final int? ownerId; final double? resellPrice; - final double? originalPrice; // The price the user paid for the ticket + final double? originalPrice; // The price the user paid for the ticket // These fields are not in the base model but can be added for convenience final String? eventName; final DateTime? eventStartDate; + final String? ticketTypeDescription; + final DateTime? ticketAvailableFrom; + TicketDetailsModel({ required this.ticketId, this.typeId, @@ -60,6 +95,8 @@ class TicketDetailsModel { this.originalPrice, this.eventName, this.eventStartDate, + this.ticketTypeDescription, + this.ticketAvailableFrom, }); factory TicketDetailsModel.fromJson(Map json) { @@ -68,22 +105,38 @@ class TicketDetailsModel { typeId: json['type_id'], seat: json['seat'], ownerId: json['owner_id'], - resellPrice: - json['resell_price'] != null - ? (json['resell_price'] as num).toDouble() - : null, - originalPrice: - json['original_price'] != null - ? (json['original_price'] as num).toDouble() - : null, + resellPrice: json['resell_price'] != null + ? (json['resell_price'] as num).toDouble() + : null, + originalPrice: json['original_price'] != null + ? (json['original_price'] as num).toDouble() + : null, // Handle both snake_case (from backend) and camelCase (from mock data) eventName: json['event_name'] ?? json['eventName'], - eventStartDate: - json['event_start_date'] != null - ? DateTime.parse(json['event_start_date']) - : json['eventStartDate'] != null + eventStartDate: json['event_start_date'] != null + ? DateTime.parse(json['event_start_date']) + : json['eventStartDate'] != null ? DateTime.parse(json['eventStartDate']) : null, + ticketTypeDescription: json['ticket_type_description'], + ticketAvailableFrom: json['ticket_available_from'] != null + ? DateTime.parse(json['ticket_available_from']) + : null, ); } + + Map toJson() { + return { + 'ticket_id': ticketId, + 'type_id': typeId, + 'seat': seat, + 'owner_id': ownerId, + 'resell_price': resellPrice, + 'original_price': originalPrice, + 'event_name': eventName, + 'event_start_date': eventStartDate?.toIso8601String(), + 'ticket_type_description': ticketTypeDescription, + 'ticket_available_from': ticketAvailableFrom?.toIso8601String(), + }; + } } diff --git a/frontend/lib/core/repositories/event_repository.dart b/frontend/lib/core/repositories/event_repository.dart index 51b9e39..473f2c9 100644 --- a/frontend/lib/core/repositories/event_repository.dart +++ b/frontend/lib/core/repositories/event_repository.dart @@ -34,15 +34,13 @@ class ApiEventRepository implements EventRepository { @override Future> getOrganizerEvents(int organizerId) async { - final data = - await _apiClient.get('/events', queryParams: {'organizer_id': organizerId}); + final data = await _apiClient.get('/events', queryParams: {'organizer_id': organizerId}); return (data as List).map((e) => Event.fromJson(e)).toList(); } @override Future> getTicketTypesForEvent(int eventId) async { - final data = await _apiClient - .get('/ticket-types/', queryParams: {'event_id': eventId}); + final data = await _apiClient.get('/ticket-types/', queryParams: {'event_id': eventId}); return (data as List).map((t) => TicketType.fromJson(t)).toList(); } @@ -75,6 +73,8 @@ class ApiEventRepository implements EventRepository { return TicketType.fromJson(response); } + // REMOVED: updateTicketType method + @override Future deleteTicketType(int typeId) async { final response = await _apiClient.delete('/ticket-types/$typeId'); @@ -86,4 +86,4 @@ class ApiEventRepository implements EventRepository { final data = await _apiClient.get('/locations/'); return (data as List).map((e) => Location.fromJson(e)).toList(); } -} \ No newline at end of file +} diff --git a/frontend/lib/presentation/organizer/cubit/event_form_cubit.dart b/frontend/lib/presentation/organizer/cubit/event_form_cubit.dart index dbb4011..bbbc8b8 100644 --- a/frontend/lib/presentation/organizer/cubit/event_form_cubit.dart +++ b/frontend/lib/presentation/organizer/cubit/event_form_cubit.dart @@ -39,11 +39,22 @@ class EventFormCubit extends Cubit { } } + Future loadExistingTicketTypes(int eventId) async { + try { + emit(EventFormTicketTypesLoading()); + final ticketTypes = await _eventRepository.getTicketTypesForEvent(eventId); + emit(EventFormTicketTypesLoaded(ticketTypes)); + } on ApiException catch (e) { + emit(EventFormError('Failed to load ticket types: ${e.message}')); + } catch (e) { + emit(EventFormError('Failed to load ticket types: $e')); + } + } + Future updateEvent(int eventId, Map eventData) async { try { emit(const EventFormSubmitting(locations: [])); - final updatedEvent = - await _eventRepository.updateEvent(eventId, eventData); + final updatedEvent = await _eventRepository.updateEvent(eventId, eventData); emit(EventFormSuccess(updatedEvent.id)); } on ApiException catch (e) { emit(EventFormError(e.message)); @@ -51,4 +62,88 @@ class EventFormCubit extends Cubit { emit(EventFormError('An unexpected error occurred: $e')); } } + + /// Update event details only - ticket types are managed separately + Future updateEventWithTicketTypes( + int eventId, + Map eventData, + List newTicketTypes + ) async { + try { + emit(const EventFormSubmitting(locations: [])); + + // 1. Update the event details first + await _eventRepository.updateEvent(eventId, eventData); + + // 2. Create only NEW ticket types (no deletion/updating of existing ones) + for (final ticketType in newTicketTypes) { + if (ticketType.typeId == null) { + await _createSingleTicketType(eventId, ticketType); + print('Created new ticket type: ${ticketType.description}'); + } + } + + emit(EventFormSuccess(eventId)); + } on ApiException catch (e) { + emit(EventFormError(e.message)); + } catch (e) { + emit(EventFormError('Failed to update event: $e')); + } + } + + bool canDeleteTicketType(TicketType ticketType) { + if (ticketType.availableFrom == null) return false; + return ticketType.availableFrom!.isAfter(DateTime.now()); + } + + Future deleteTicketType(int typeId, TicketType ticketType) async { + try { + // Check if deletion is allowed + if (!canDeleteTicketType(ticketType)) { + emit(EventFormError( + 'Cannot delete ticket type "${ticketType.description ?? ''}" - sales have already started or no availability date set.' + )); + return; + } + + await _eventRepository.deleteTicketType(typeId); + emit(EventFormTicketTypeDeleted()); + + if (ticketType.eventId != null) { + await loadExistingTicketTypes(ticketType.eventId); + } + } on ApiException catch (e) { + emit(EventFormError(e.message)); + } catch (e) { + emit(EventFormError('Failed to delete ticket type: $e')); + } + } + + Future _createSingleTicketType(int eventId, TicketType ticketType) async { + if (ticketType.availableFrom == null) { + throw Exception('Available from date is required for ticket type: ${ticketType.description}'); + } + + await _eventRepository.createTicketType({ + 'event_id': eventId, + 'description': ticketType.description ?? '', + 'max_count': ticketType.maxCount, + 'price': ticketType.price, + 'currency': ticketType.currency, + 'available_from': ticketType.availableFrom!.toIso8601String(), + }); + } + + Future createTicketType(int eventId, TicketType ticketType) async { + try { + await _createSingleTicketType(eventId, ticketType); + emit(EventFormTicketTypeCreated()); + + await loadExistingTicketTypes(eventId); + } on ApiException catch (e) { + emit(EventFormError(e.message)); + } catch (e) { + emit(EventFormError('Failed to create ticket type: $e')); + } + } } diff --git a/frontend/lib/presentation/organizer/cubit/event_form_state.dart b/frontend/lib/presentation/organizer/cubit/event_form_state.dart index 4b26e67..8ba10f6 100644 --- a/frontend/lib/presentation/organizer/cubit/event_form_state.dart +++ b/frontend/lib/presentation/organizer/cubit/event_form_state.dart @@ -3,6 +3,7 @@ import 'package:resellio/core/models/models.dart'; abstract class EventFormState extends Equatable { const EventFormState(); + @override List get props => []; } @@ -13,7 +14,9 @@ class EventFormPrerequisitesLoading extends EventFormState {} class EventFormPrerequisitesLoaded extends EventFormState { final List locations; + const EventFormPrerequisitesLoaded({required this.locations}); + @override List get props => [locations]; } @@ -24,14 +27,33 @@ class EventFormSubmitting extends EventFormPrerequisitesLoaded { class EventFormSuccess extends EventFormState { final int eventId; + const EventFormSuccess(this.eventId); + @override List get props => [eventId]; } class EventFormError extends EventFormState { final String message; + const EventFormError(this.message); + @override List get props => [message]; } + +class EventFormTicketTypesLoading extends EventFormState {} + +class EventFormTicketTypesLoaded extends EventFormState { + final List ticketTypes; + + const EventFormTicketTypesLoaded(this.ticketTypes); + + @override + List get props => [ticketTypes]; +} + +class EventFormTicketTypeCreated extends EventFormState {} + +class EventFormTicketTypeDeleted extends EventFormState {} diff --git a/frontend/lib/presentation/organizer/pages/edit_event_page.dart b/frontend/lib/presentation/organizer/pages/edit_event_page.dart index 6b605a2..f879ff5 100644 --- a/frontend/lib/presentation/organizer/pages/edit_event_page.dart +++ b/frontend/lib/presentation/organizer/pages/edit_event_page.dart @@ -9,6 +9,7 @@ import 'package:resellio/presentation/common_widgets/primary_button.dart'; import 'package:resellio/presentation/main_page/page_layout.dart'; import 'package:resellio/presentation/organizer/cubit/event_form_cubit.dart'; import 'package:resellio/presentation/organizer/cubit/event_form_state.dart'; +import 'package:resellio/presentation/organizer/widgets/ticket_type_form.dart'; class EditEventPage extends StatelessWidget { final Event event; @@ -17,7 +18,8 @@ class EditEventPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => EventFormCubit(context.read()), + create: (context) => EventFormCubit(context.read()) + ..loadExistingTicketTypes(event.id), child: _EditEventView(event: event), ); } @@ -42,6 +44,9 @@ class _EditEventViewState extends State<_EditEventView> { DateTime? _startDate; DateTime? _endDate; + // FIXED: Changed from TicketTypeData to TicketType + List _additionalTicketTypes = []; + @override void initState() { super.initState(); @@ -95,8 +100,68 @@ class _EditEventViewState extends State<_EditEventView> { }); } + void _addTicketType() { + setState(() { + _additionalTicketTypes.add(TicketType( + eventId: widget.event.id, + description: '', + maxCount: 0, + price: 0.0, + currency: 'USD', + availableFrom: DateTime.now().add(Duration(hours: 1)), + )); + }); + } + + void _removeTicketType(int index) { + setState(() { + _additionalTicketTypes.removeAt(index); + }); + } + + void _updateTicketType(int index, TicketType ticketType) { + setState(() { + _additionalTicketTypes[index] = ticketType; + }); + } + + bool _validateTicketTypes() { + for (int i = 0; i < _additionalTicketTypes.length; i++) { + final ticketType = _additionalTicketTypes[i]; + + if ((ticketType.description ?? '').isEmpty) { + _showError('Please fill description for ticket type ${i + 1}.'); + return false; + } + if (ticketType.maxCount <= 0) { + _showError('Please enter valid ticket count for ticket type ${i + 1}.'); + return false; + } + if (ticketType.price < 0) { + _showError('Please enter valid price for ticket type ${i + 1}.'); + return false; + } + if (ticketType.availableFrom == null) { + _showError('Please set available from date for ticket type ${i + 1}.'); + return false; + } + if (ticketType.availableFrom!.isAfter(_startDate!)) { + _showError('Available from date for ticket type ${i + 1} cannot be after event start date.'); + return false; + } + } + return true; + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: Colors.red, + )); + } + void _submitForm() { - if (_formKey.currentState!.validate()) { + if (_formKey.currentState!.validate() && _validateTicketTypes()) { final eventData = { 'name': _nameController.text, 'description': _descriptionController.text, @@ -104,14 +169,17 @@ class _EditEventViewState extends State<_EditEventView> { 'end_date': _endDate!.toIso8601String(), 'minimum_age': int.tryParse(_minimumAgeController.text), }; + context .read() - .updateEvent(widget.event.id, eventData); + .updateEventWithTicketTypes(widget.event.id, eventData, _additionalTicketTypes); } } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + return PageLayout( title: 'Edit Event', showBackButton: true, @@ -141,42 +209,12 @@ class _EditEventViewState extends State<_EditEventView> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - CustomTextFormField( - controller: _nameController, - labelText: 'Event Name', - validator: (v) => - v!.isEmpty ? 'Event name is required' : null, - ), - const SizedBox(height: 16), - CustomTextFormField( - controller: _descriptionController, - labelText: 'Description', - keyboardType: TextInputType.multiline, - ), - const SizedBox(height: 16), - CustomTextFormField( - controller: _startDateController, - labelText: 'Start Date & Time', - readOnly: true, - onTap: () => _selectDateTime(context, true), - validator: (v) => - v!.isEmpty ? 'Start date is required' : null, - ), - const SizedBox(height: 16), - CustomTextFormField( - controller: _endDateController, - labelText: 'End Date & Time', - readOnly: true, - onTap: () => _selectDateTime(context, false), - validator: (v) => v!.isEmpty ? 'End date is required' : null, - ), - const SizedBox(height: 16), - CustomTextFormField( - controller: _minimumAgeController, - labelText: 'Minimum Age (Optional)', - keyboardType: TextInputType.number, - ), + _buildEventDetailsSection(theme), const SizedBox(height: 32), + + _buildTicketTypesSection(theme), + const SizedBox(height: 32), + BlocBuilder( builder: (context, state) { return PrimaryButton( @@ -193,4 +231,238 @@ class _EditEventViewState extends State<_EditEventView> { ), ); } + + Widget _buildEventDetailsSection(ThemeData theme) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Event Details", style: theme.textTheme.headlineSmall), + const SizedBox(height: 20), + CustomTextFormField( + controller: _nameController, + labelText: 'Event Name', + validator: (v) => v!.isEmpty ? 'Event name is required' : null, + ), + const SizedBox(height: 16), + CustomTextFormField( + controller: _descriptionController, + labelText: 'Description', + keyboardType: TextInputType.multiline, + maxLines: 4, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: CustomTextFormField( + controller: _startDateController, + labelText: 'Start Date & Time', + readOnly: true, + onTap: () => _selectDateTime(context, true), + validator: (v) => v!.isEmpty ? 'Start date is required' : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextFormField( + controller: _endDateController, + labelText: 'End Date & Time', + readOnly: true, + onTap: () => _selectDateTime(context, false), + validator: (v) => v!.isEmpty ? 'End date is required' : null, + ), + ), + ], + ), + const SizedBox(height: 16), + CustomTextFormField( + controller: _minimumAgeController, + labelText: 'Minimum Age (Optional)', + keyboardType: TextInputType.number, + ), + ], + ), + ), + ); + } + Widget _buildTicketTypesSection(ThemeData theme) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Ticket Types Management", style: theme.textTheme.headlineSmall), + OutlinedButton.icon( + onPressed: _addTicketType, + icon: const Icon(Icons.add), + label: const Text('Add New Type'), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Existing ticket types are read-only. You can only delete types that haven't started selling yet.", + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + + // Show existing ticket types (from backend) + BlocBuilder( + builder: (context, state) { + if (state is EventFormTicketTypesLoaded) { + final existingTypes = state.ticketTypes + .where((t) => (t.description ?? '') != "Standard Ticket") + .toList(); + + if (existingTypes.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Existing Ticket Types", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...existingTypes.asMap().entries.map((entry) { + final index = entry.key; + final ticketType = entry.value; + final cubit = context.read(); + final canDelete = cubit.canDeleteTicketType(ticketType); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TicketTypeForm( + ticketType: ticketType, + index: index + 2, // Start from 2 (1 is standard) + isDeletable: canDelete, + onDelete: canDelete && ticketType.typeId != null + ? () => _deleteExistingTicketType(ticketType.typeId!, ticketType) + : null, + ), + ); + }).toList(), + const SizedBox(height: 20), + ], + ); + } + } + return const SizedBox.shrink(); + }, + ), + + // Show new ticket types being added + if (_additionalTicketTypes.isNotEmpty) ...[ + Text( + "New Ticket Types", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _additionalTicketTypes.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: EditableTicketTypeForm( + ticketType: _additionalTicketTypes[index], + index: index + 100, // Use high numbers to distinguish from existing + onChanged: (ticketType) => _updateTicketType(index, ticketType), + onDelete: () => _removeTicketType(index), + ), + ); + }, + ), + ], + + // Empty state + if (_additionalTicketTypes.isEmpty) + BlocBuilder( + builder: (context, state) { + // Only show empty state if there are no existing types either + final hasExistingTypes = state is EventFormTicketTypesLoaded && + state.ticketTypes.any((t) => (t.description ?? '') != "Standard Ticket"); + + if (!hasExistingTypes) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outlineVariant.withOpacity(0.5), + ), + ), + child: Column( + children: [ + Icon( + Icons.confirmation_number_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + ), + const SizedBox(height: 12), + Text( + 'No additional ticket types yet', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Standard tickets are already available. Add premium types here.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ); + } + + void _deleteExistingTicketType(int typeId, TicketType ticketType) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Ticket Type'), + content: Text('Are you sure you want to delete "${ticketType.description}"?\n\nThis action cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + context.read().deleteTicketType(typeId, ticketType); + } + } } diff --git a/frontend/lib/presentation/organizer/widgets/ticket_type_form.dart b/frontend/lib/presentation/organizer/widgets/ticket_type_form.dart new file mode 100644 index 0000000..1f5f179 --- /dev/null +++ b/frontend/lib/presentation/organizer/widgets/ticket_type_form.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:resellio/core/models/models.dart'; +import 'package:resellio/presentation/common_widgets/custom_text_form_field.dart'; + +// Read-only ticket type display +class TicketTypeForm extends StatelessWidget { + final TicketType ticketType; + final int index; + final VoidCallback? onDelete; + final bool isDeletable; + + const TicketTypeForm({ + super.key, + required this.ticketType, + required this.index, + this.onDelete, + this.isDeletable = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + // Determine the display status + final isActive = ticketType.availableFrom?.isAfter(DateTime.now()) ?? false; + final statusColor = isActive ? Colors.green : Colors.orange; + final statusText = isActive ? 'Not yet available' : 'Sales active'; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ticketType.description ?? 'Unnamed Ticket Type', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statusColor, width: 1), + ), + child: Text( + statusText, + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + if (isDeletable && onDelete != null) + IconButton( + onPressed: onDelete, + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), + tooltip: 'Remove ticket type', + ) + else if (!isDeletable) + Tooltip( + message: 'Cannot delete - tickets may have been sold', + child: Icon( + Icons.lock_outline, + color: colorScheme.onSurfaceVariant.withOpacity(0.5), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Display ticket type details (read-only) + _buildInfoRow( + context, + 'Count:', + '${ticketType.maxCount} tickets', + Icons.confirmation_number_outlined, + ), + const SizedBox(height: 8), + _buildInfoRow( + context, + 'Price:', + '${ticketType.currency} \$${ticketType.price.toStringAsFixed(2)}', + Icons.attach_money, + ), + const SizedBox(height: 8), + if (ticketType.availableFrom != null) + _buildInfoRow( + context, + 'Available from:', + DateFormat.yMd().add_jm().format(ticketType.availableFrom!), + Icons.schedule, + ), + ], + ), + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value, IconData icon) { + final theme = Theme.of(context); + return Row( + children: [ + Icon( + icon, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ); + } +} + +// Editable version for creating new ticket types +class EditableTicketTypeForm extends StatefulWidget { + final TicketType ticketType; + final int index; + final Function(TicketType) onChanged; + final VoidCallback onDelete; + + const EditableTicketTypeForm({ + super.key, + required this.ticketType, + required this.index, + required this.onChanged, + required this.onDelete, + }); + + @override + State createState() => _EditableTicketTypeFormState(); +} + +class _EditableTicketTypeFormState extends State { + late TextEditingController _descriptionController; + late TextEditingController _maxCountController; + late TextEditingController _priceController; + late TextEditingController _availableFromController; + + @override + void initState() { + super.initState(); + _descriptionController = TextEditingController(text: widget.ticketType.description ?? ''); + _maxCountController = TextEditingController(text: widget.ticketType.maxCount.toString()); + _priceController = TextEditingController(text: widget.ticketType.price.toString()); + _availableFromController = TextEditingController( + text: widget.ticketType.availableFrom != null + ? DateFormat.yMd().add_jm().format(widget.ticketType.availableFrom!) + : '' + ); + + _descriptionController.addListener(_updateTicketType); + _maxCountController.addListener(_updateTicketType); + _priceController.addListener(_updateTicketType); + } + + @override + void dispose() { + _descriptionController.dispose(); + _maxCountController.dispose(); + _priceController.dispose(); + _availableFromController.dispose(); + super.dispose(); + } + + void _updateTicketType() { + final description = _descriptionController.text; + final maxCount = int.tryParse(_maxCountController.text) ?? 0; + final price = double.tryParse(_priceController.text) ?? 0.0; + + widget.onChanged(widget.ticketType.copyWith( + description: description, + maxCount: maxCount, + price: price, + currency: 'USD', + )); + } + + Future _selectAvailableFromDateTime(BuildContext context) async { + final DateTime? date = await showDatePicker( + context: context, + initialDate: widget.ticketType.availableFrom ?? DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime(2101), + ); + if (date == null) return; + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(widget.ticketType.availableFrom ?? DateTime.now()), + ); + if (time == null) return; + + final selectedDateTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); + + setState(() { + _availableFromController.text = DateFormat.yMd().add_jm().format(selectedDateTime); + }); + + widget.onChanged(widget.ticketType.copyWith(availableFrom: selectedDateTime)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary.withOpacity(0.3), + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'New Ticket Type', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + IconButton( + onPressed: widget.onDelete, + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), + tooltip: 'Remove ticket type', + ), + ], + ), + const SizedBox(height: 16), + CustomTextFormField( + controller: _descriptionController, + labelText: 'Description (e.g., VIP, Early Bird)', + validator: (v) => v!.isEmpty ? 'Description is required' : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: CustomTextFormField( + controller: _maxCountController, + labelText: 'Ticket Count', + keyboardType: TextInputType.number, + validator: (v) { + if (v!.isEmpty) return 'Count is required'; + if (int.tryParse(v) == null || int.parse(v) <= 0) { + return 'Enter valid count'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextFormField( + controller: _priceController, + labelText: 'Price (\$)', + prefixText: '\$ ', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v!.isEmpty) return 'Price is required'; + if (double.tryParse(v) == null || double.parse(v) < 0) { + return 'Enter valid price'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + CustomTextFormField( + controller: _availableFromController, + labelText: 'Available From Date & Time *', + readOnly: true, + onTap: () => _selectAvailableFromDateTime(context), + validator: (v) => v!.isEmpty ? 'Available from date is required' : null, + ), + ], + ), + ); + } +}