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,340 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../auth/application/auth_notifier.dart';
import '../../profile/infrastructure/profile_repository.dart';
import '../domain/player.dart';
import '../domain/team.dart';
import '../infrastructure/teams_repository.dart';
/// Public-facing form for any logged-in user to register a new team.
///
/// Differs from the admin form in two ways:
/// 1. Manager email/phone are first-class fields so the league can contact
/// whoever created the team.
/// 2. Win/loss/draw counters are not exposed — those are reserved for admins.
class CreateTeamScreen extends ConsumerStatefulWidget {
const CreateTeamScreen({super.key});
@override
ConsumerState<CreateTeamScreen> createState() => _CreateTeamScreenState();
}
class _CreateTeamScreenState extends ConsumerState<CreateTeamScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _logoUrlCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _managerEmailCtrl = TextEditingController();
final _managerPhoneCtrl = TextEditingController();
final List<_PlayerDraft> _roster = <_PlayerDraft>[];
bool _hydratedEmail = false;
bool _submitting = false;
@override
void dispose() {
_nameCtrl.dispose();
_logoUrlCtrl.dispose();
_descCtrl.dispose();
_managerEmailCtrl.dispose();
_managerPhoneCtrl.dispose();
for (final p in _roster) {
p.dispose();
}
super.dispose();
}
void _addPlayerRow() {
setState(() => _roster.add(_PlayerDraft()));
}
void _removePlayerRow(_PlayerDraft draft) {
setState(() {
_roster.remove(draft);
draft.dispose();
});
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final user = ref.read(authNotifierProvider).valueOrNull;
final id = 'team_${DateTime.now().millisecondsSinceEpoch}';
final players = <Player>[];
for (var i = 0; i < _roster.length; i++) {
final draft = _roster[i];
final name = draft.nameCtrl.text.trim();
if (name.isEmpty) continue;
players.add(
Player(
id: '${id}_p$i',
name: name,
jerseyNumber: int.tryParse(draft.jerseyCtrl.text.trim()),
position: draft.positionCtrl.text.trim().isEmpty
? null
: draft.positionCtrl.text.trim(),
),
);
}
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(),
managerId: user?.uid,
managerEmail: _managerEmailCtrl.text.trim(),
managerPhone: _managerPhoneCtrl.text.trim().isEmpty
? null
: _managerPhoneCtrl.text.trim(),
players: players,
// Manager-submitted teams require admin approval before going public.
status: TeamStatus.pending,
);
setState(() => _submitting = true);
try {
final newId = await ref.read(teamsRepositoryProvider).createTeam(team);
// Stamp the team on the manager's profile so the dashboard finds it.
if (user != null) {
try {
await ref
.read(profileRepositoryProvider)
.updateTeamId(user.uid, newId);
} catch (_) {
// Profile may not exist yet for legacy accounts — non-fatal.
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Team submitted — awaiting admin approval.'),
),
);
context.go('/manager');
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Could not create team: $e')));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authNotifierProvider).valueOrNull;
if (!_hydratedEmail && user != null) {
_managerEmailCtrl.text = user.email;
_hydratedEmail = true;
}
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('NEW TEAM'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/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: _logoUrlCtrl,
decoration: const InputDecoration(
labelText: 'Logo URL (optional)',
hintText: 'https://...',
),
),
const SizedBox(height: 12),
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(
labelText: 'Description (optional)',
),
minLines: 2,
maxLines: 5,
),
const SizedBox(height: 24),
Text(
'CONTACT',
style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 1.5),
),
const SizedBox(height: 8),
TextFormField(
controller: _managerEmailCtrl,
decoration: const InputDecoration(
labelText: 'Manager email',
prefixIcon: Icon(Icons.mail_outline),
),
readOnly: true,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _managerPhoneCtrl,
decoration: const InputDecoration(
labelText: 'Manager phone (optional)',
prefixIcon: Icon(Icons.phone_outlined),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 24),
Row(
children: <Widget>[
Expanded(
child: Text(
'ROSTER',
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.5,
),
),
),
OutlinedButton.icon(
onPressed: _addPlayerRow,
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 yet — tap "Add player" to start your roster.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
..._roster.map(
(draft) => _PlayerRow(
draft: draft,
onRemove: () => _removePlayerRow(draft),
),
),
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.add_circle_outline),
label: const Text('CREATE TEAM'),
),
],
),
),
),
);
}
}
class _PlayerDraft {
_PlayerDraft()
: nameCtrl = TextEditingController(),
jerseyCtrl = TextEditingController(),
positionCtrl = TextEditingController();
final TextEditingController nameCtrl;
final TextEditingController jerseyCtrl;
final TextEditingController positionCtrl;
void dispose() {
nameCtrl.dispose();
jerseyCtrl.dispose();
positionCtrl.dispose();
}
}
class _PlayerRow extends StatelessWidget {
const _PlayerRow({required this.draft, required this.onRemove});
final _PlayerDraft draft;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextFormField(
controller: draft.nameCtrl,
decoration: const InputDecoration(labelText: 'Player name'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
),
IconButton(
icon: Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.error,
),
tooltip: 'Remove player',
onPressed: onRemove,
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
SizedBox(
width: 88,
child: TextFormField(
controller: draft.jerseyCtrl,
decoration: const InputDecoration(labelText: 'Jersey #'),
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: draft.positionCtrl,
decoration: const InputDecoration(labelText: 'Position'),
),
),
],
),
],
),
),
);
}
}