Files
philip b239ae3e5f 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>
2026-05-14 20:13:57 -07:00

271 lines
8.6 KiB
Dart

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'),
),
],
),
),
);
}
}