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,478 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../teams/domain/player.dart';
import '../../../teams/domain/team.dart';
import '../../application/admin_teams_notifier.dart';
class AdminTeamFormScreen extends ConsumerStatefulWidget {
const AdminTeamFormScreen({super.key, this.teamId});
final String? teamId;
bool get isEdit => teamId != null;
@override
ConsumerState<AdminTeamFormScreen> createState() =>
_AdminTeamFormScreenState();
}
class _AdminTeamFormScreenState extends ConsumerState<AdminTeamFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _logoUrlCtrl = TextEditingController();
final _primaryColorCtrl = TextEditingController();
final _winsCtrl = TextEditingController(text: '0');
final _lossesCtrl = TextEditingController(text: '0');
final _drawsCtrl = TextEditingController(text: '0');
final List<Player> _roster = <Player>[];
/// Preserved across edits so saving doesn't reset a pending team to
/// approved or vice versa. New teams default to [TeamStatus.approved].
String _status = TeamStatus.approved;
bool _hydrated = false;
bool _submitting = false;
@override
void initState() {
super.initState();
if (!widget.isEdit) _hydrated = true;
}
@override
void dispose() {
_nameCtrl.dispose();
_descCtrl.dispose();
_logoUrlCtrl.dispose();
_primaryColorCtrl.dispose();
_winsCtrl.dispose();
_lossesCtrl.dispose();
_drawsCtrl.dispose();
super.dispose();
}
void _hydrateFrom(Team team) {
if (_hydrated) return;
_nameCtrl.text = team.name;
_descCtrl.text = team.description ?? '';
_logoUrlCtrl.text = team.logoUrl ?? '';
_primaryColorCtrl.text = team.primaryColor ?? '';
_winsCtrl.text = team.wins.toString();
_lossesCtrl.text = team.losses.toString();
_drawsCtrl.text = team.draws.toString();
_roster
..clear()
..addAll(team.players);
_status = team.status;
_hydrated = true;
}
Future<void> _editPlayer({Player? existing}) async {
final result = await showDialog<Player>(
context: context,
builder: (ctx) => _PlayerEditorDialog(player: existing),
);
if (result == null) return;
setState(() {
if (existing == null) {
_roster.add(result);
} else {
final idx = _roster.indexWhere((p) => p.id == existing.id);
if (idx >= 0) _roster[idx] = result;
}
});
}
void _removePlayer(Player p) {
setState(() => _roster.removeWhere((x) => x.id == p.id));
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final id = widget.teamId ?? '';
final team = Team(
id: id,
name: _nameCtrl.text.trim(),
logoUrl: _logoUrlCtrl.text.trim().isEmpty
? null
: _logoUrlCtrl.text.trim(),
description:
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
wins: int.tryParse(_winsCtrl.text.trim()) ?? 0,
losses: int.tryParse(_lossesCtrl.text.trim()) ?? 0,
draws: int.tryParse(_drawsCtrl.text.trim()) ?? 0,
primaryColor: _primaryColorCtrl.text.trim().isEmpty
? null
: _primaryColorCtrl.text.trim(),
players: List<Player>.unmodifiable(_roster),
status: _status,
);
setState(() => _submitting = true);
try {
if (widget.isEdit) {
await ref.read(adminTeamsNotifierProvider.notifier).save(team);
} else {
await ref.read(adminTeamsNotifierProvider.notifier).create(team);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(widget.isEdit ? 'Team updated' : 'Team created')),
);
context.go('/admin/teams');
} 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 teamsAsync = ref.watch(adminTeamsStreamProvider);
final teams = teamsAsync.valueOrNull;
if (teams != null) {
final match = teams.firstWhere(
(t) => t.id == widget.teamId,
orElse: () => const Team(id: '', name: ''),
);
if (match.id.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _hydrateFrom(match));
});
}
}
}
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.isEdit ? 'EDIT TEAM' : 'NEW TEAM'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/admin/teams'),
),
),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Team name'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'Description'),
minLines: 2,
maxLines: 5,
),
const SizedBox(height: 12),
TextFormField(
controller: _logoUrlCtrl,
decoration:
const InputDecoration(labelText: 'Logo URL (optional)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _primaryColorCtrl,
decoration: const InputDecoration(
labelText: 'Primary color hex (e.g. #2E7D32)',
),
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: TextFormField(
controller: _winsCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Wins'),
validator: _validateInt,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _lossesCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Losses'),
validator: _validateInt,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _drawsCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Draws'),
validator: _validateInt,
),
),
],
),
const SizedBox(height: 24),
Row(
children: <Widget>[
Expanded(
child: Text(
'ROSTER',
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.5,
),
),
),
OutlinedButton.icon(
onPressed: () => _editPlayer(),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add player'),
),
],
),
const SizedBox(height: 8),
if (_roster.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'No players on the roster yet.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
..._roster.map(
(p) => _PlayerRow(
player: p,
onEdit: () => _editPlayer(existing: p),
onDelete: () => _removePlayer(p),
),
),
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 TEAM'),
),
],
),
),
),
);
}
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;
}
}
class _PlayerRow extends StatelessWidget {
const _PlayerRow({
required this.player,
required this.onEdit,
required this.onDelete,
});
final Player player;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(player.name),
subtitle: Text(
[
if (player.position != null && player.position!.isNotEmpty)
player.position!,
'G ${player.goalsScored}',
'A ${player.assists}',
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: onEdit,
tooltip: 'Edit player',
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Remove player',
),
],
),
),
);
}
}
class _PlayerEditorDialog extends StatefulWidget {
const _PlayerEditorDialog({this.player});
final Player? player;
@override
State<_PlayerEditorDialog> createState() => _PlayerEditorDialogState();
}
class _PlayerEditorDialogState extends State<_PlayerEditorDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _positionCtrl;
late final TextEditingController _goalsCtrl;
late final TextEditingController _assistsCtrl;
late final TextEditingController _avatarCtrl;
@override
void initState() {
super.initState();
final p = widget.player;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_positionCtrl = TextEditingController(text: p?.position ?? '');
_goalsCtrl = TextEditingController(text: (p?.goalsScored ?? 0).toString());
_assistsCtrl = TextEditingController(text: (p?.assists ?? 0).toString());
_avatarCtrl = TextEditingController(text: p?.avatarUrl ?? '');
}
@override
void dispose() {
_nameCtrl.dispose();
_positionCtrl.dispose();
_goalsCtrl.dispose();
_assistsCtrl.dispose();
_avatarCtrl.dispose();
super.dispose();
}
void _save() {
if (!(_formKey.currentState?.validate() ?? false)) return;
final id = widget.player?.id ??
'p_${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}';
final result = Player(
id: id,
name: _nameCtrl.text.trim(),
position: _positionCtrl.text.trim().isEmpty
? null
: _positionCtrl.text.trim(),
avatarUrl: _avatarCtrl.text.trim().isEmpty
? null
: _avatarCtrl.text.trim(),
goalsScored: int.tryParse(_goalsCtrl.text.trim()) ?? 0,
assists: int.tryParse(_assistsCtrl.text.trim()) ?? 0,
);
Navigator.of(context).pop(result);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.player == null ? 'Add player' : 'Edit player'),
content: SizedBox(
width: 360,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _positionCtrl,
decoration:
const InputDecoration(labelText: 'Position (optional)'),
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: TextFormField(
controller: _goalsCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Goals'),
validator: _validateInt,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _assistsCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Assists'),
validator: _validateInt,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _avatarCtrl,
decoration: const InputDecoration(
labelText: 'Avatar URL (optional)',
),
),
],
),
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
);
}
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;
}
}
@@ -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'),
),
],
),
],
),
),
);
}
}