Initial commit: Flutter app + PHP/MySQL backend on Hostinger
Replaces Firebase with a self-hosted PHP/MySQL API served from winded.prymsolutions.com. Includes full backend (schema, auth, events, teams, brackets, suggestions, stats, media, file upload) and updated Flutter repositories and domain models. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../events/domain/event.dart';
|
||||
import '../../application/admin_events_notifier.dart';
|
||||
|
||||
class AdminEventFormScreen extends ConsumerStatefulWidget {
|
||||
const AdminEventFormScreen({super.key, this.eventId});
|
||||
|
||||
/// Null when creating a new event; otherwise the id of the event being
|
||||
/// edited.
|
||||
final String? eventId;
|
||||
|
||||
bool get isEdit => eventId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<AdminEventFormScreen> createState() =>
|
||||
_AdminEventFormScreenState();
|
||||
}
|
||||
|
||||
class _AdminEventFormScreenState extends ConsumerState<AdminEventFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final _locationCtrl = TextEditingController();
|
||||
final _imageUrlCtrl = TextEditingController();
|
||||
final _teamsRegisteredCtrl = TextEditingController(text: '0');
|
||||
final _maxTeamsCtrl = TextEditingController(text: '8');
|
||||
|
||||
DateTime _date = DateTime.now().add(const Duration(days: 7));
|
||||
DateTime _registrationDeadline = DateTime.now().add(const Duration(days: 6));
|
||||
EventCategory _category = EventCategory.pickup;
|
||||
bool _isCancelled = false;
|
||||
bool _hydrated = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEdit) _hydrated = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
_imageUrlCtrl.dispose();
|
||||
_teamsRegisteredCtrl.dispose();
|
||||
_maxTeamsCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _hydrateFrom(Event event) {
|
||||
if (_hydrated) return;
|
||||
_titleCtrl.text = event.title;
|
||||
_descCtrl.text = event.description;
|
||||
_locationCtrl.text = event.location;
|
||||
_imageUrlCtrl.text = event.imageUrl ?? '';
|
||||
_teamsRegisteredCtrl.text = event.teamsRegistered.toString();
|
||||
_maxTeamsCtrl.text = event.maxTeams.toString();
|
||||
_date = event.date;
|
||||
_registrationDeadline = event.registrationDeadline;
|
||||
_category = event.category;
|
||||
_isCancelled = event.isCancelled;
|
||||
_hydrated = true;
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool registration}) async {
|
||||
final initial = registration ? _registrationDeadline : _date;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(initial),
|
||||
);
|
||||
if (time == null) return;
|
||||
final merged = DateTime(
|
||||
picked.year,
|
||||
picked.month,
|
||||
picked.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
);
|
||||
setState(() {
|
||||
if (registration) {
|
||||
_registrationDeadline = merged;
|
||||
} else {
|
||||
_date = merged;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final id = widget.eventId ?? '';
|
||||
final event = Event(
|
||||
id: id,
|
||||
title: _titleCtrl.text.trim(),
|
||||
description: _descCtrl.text.trim(),
|
||||
date: _date,
|
||||
location: _locationCtrl.text.trim(),
|
||||
registrationDeadline: _registrationDeadline,
|
||||
teamsRegistered: int.tryParse(_teamsRegisteredCtrl.text.trim()) ?? 0,
|
||||
maxTeams: int.tryParse(_maxTeamsCtrl.text.trim()) ?? 0,
|
||||
category: _category,
|
||||
imageUrl: _imageUrlCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _imageUrlCtrl.text.trim(),
|
||||
isCancelled: _isCancelled,
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
if (widget.isEdit) {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).save(event);
|
||||
} else {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).create(event);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.isEdit ? 'Event updated' : 'Event created'),
|
||||
),
|
||||
);
|
||||
context.go('/admin/events');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isEdit && !_hydrated) {
|
||||
final eventsAsync = ref.watch(adminEventsStreamProvider);
|
||||
final events = eventsAsync.valueOrNull;
|
||||
if (events != null) {
|
||||
final match = events.firstWhere(
|
||||
(e) => e.id == widget.eventId,
|
||||
orElse: () => _placeholderEvent(),
|
||||
);
|
||||
if (match.id.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hydrateFrom(match));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final df = DateFormat.yMMMd().add_jm();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isEdit ? 'EDIT EVENT' : 'NEW EVENT'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/admin/events'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
TextFormField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<EventCategory>(
|
||||
segments: const <ButtonSegment<EventCategory>>[
|
||||
ButtonSegment<EventCategory>(
|
||||
value: EventCategory.tournament,
|
||||
label: Text('TOURNAMENT'),
|
||||
icon: Icon(Icons.emoji_events_outlined),
|
||||
),
|
||||
ButtonSegment<EventCategory>(
|
||||
value: EventCategory.pickup,
|
||||
label: Text('PICK-UP'),
|
||||
icon: Icon(Icons.sports_soccer),
|
||||
),
|
||||
],
|
||||
selected: <EventCategory>{_category},
|
||||
onSelectionChanged: (set) =>
|
||||
setState(() => _category = set.first),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Location'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DateField(
|
||||
label: 'Event date & time',
|
||||
value: df.format(_date),
|
||||
onTap: () => _pickDate(registration: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DateField(
|
||||
label: 'Registration deadline',
|
||||
value: df.format(_registrationDeadline),
|
||||
onTap: () => _pickDate(registration: true),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _teamsRegisteredCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Teams registered',
|
||||
),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _maxTeamsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Max teams'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _imageUrlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Image URL (optional)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Cancelled', style: theme.textTheme.bodyLarge),
|
||||
subtitle: Text(
|
||||
'Mark this event as cancelled',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _isCancelled,
|
||||
onChanged: (v) => setState(() => _isCancelled = v),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE EVENT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _validateInt(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n == null || n < 0) return 'Enter a non-negative number';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Event _placeholderEvent() => Event(
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
date: DateTime.now(),
|
||||
location: '',
|
||||
registrationDeadline: DateTime.now(),
|
||||
teamsRegistered: 0,
|
||||
maxTeams: 0,
|
||||
);
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
const _DateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
suffixIcon: const Icon(Icons.calendar_today, size: 18),
|
||||
),
|
||||
child: Text(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../events/domain/event.dart';
|
||||
import '../../application/admin_events_notifier.dart';
|
||||
|
||||
class AdminEventsScreen extends ConsumerWidget {
|
||||
const AdminEventsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final eventsAsync = ref.watch(adminEventsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: eventsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: '$err',
|
||||
onRetry: () => ref.invalidate(adminEventsStreamProvider),
|
||||
),
|
||||
data: (events) {
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.event_busy_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No events yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the + button to create your first event.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) => _EventRow(event: events[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/admin/events/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('NEW EVENT'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventRow extends ConsumerWidget {
|
||||
const _EventRow({required this.event});
|
||||
|
||||
final Event event;
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete event?'),
|
||||
content: Text(
|
||||
'"${event.title}" will be permanently removed. This cannot be undone.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).delete(event.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${event.title}"')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final dateLabel = DateFormat.yMMMd().add_jm().format(event.date);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (event.isCancelled)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'CANCELLED',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${event.teamsRegistered}/${event.maxTeams} teams',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.location,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/admin/events/${event.id}/edit'),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Could not load events',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user