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:
2026-05-14 20:13:57 -07:00
commit b239ae3e5f
208 changed files with 19187 additions and 0 deletions
@@ -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'),
),
],
),
),
);
}
}