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,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../teams/domain/team.dart';
import '../../application/admin_teams_notifier.dart';
class AdminTeamsScreen extends ConsumerWidget {
const AdminTeamsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamsAsync = ref.watch(adminTeamsStreamProvider);
final theme = Theme.of(context);
return Scaffold(
body: teamsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => 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 teams', style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(
'$err',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
data: (teams) {
if (teams.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.groups_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No teams yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Tap the + button to create your first team.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
itemCount: teams.length,
itemBuilder: (context, index) => _TeamRow(team: teams[index]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.go('/admin/teams/new'),
icon: const Icon(Icons.add),
label: const Text('NEW TEAM'),
),
);
}
}
class _TeamRow extends ConsumerWidget {
const _TeamRow({required this.team});
final Team team;
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete team?'),
content: Text(
'"${team.name}" and its roster will be permanently removed.',
),
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(adminTeamsNotifierProvider.notifier).delete(team.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted "${team.name}"')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: $e')),
);
}
}
}
Color? _parseColor(String? hex) {
if (hex == null) return null;
final cleaned = hex.replaceAll('#', '').trim();
if (cleaned.length != 6) return null;
final value = int.tryParse(cleaned, radix: 16);
if (value == null) return null;
return Color(0xFF000000 | value);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final accent = _parseColor(team.primaryColor) ?? theme.colorScheme.primary;
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>[
Container(
width: 12,
height: 36,
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(team.name, style: theme.textTheme.titleMedium),
Text(
'${team.record} · ${team.players.length} player${team.players.length == 1 ? '' : 's'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
if (team.description != null && team.description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
team.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
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/teams/${team.id}/edit'),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit'),
),
],
),
],
),
),
);
}
}