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,791 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../brackets/domain/bracket.dart';
import '../../../events/domain/event.dart';
import '../../../teams/domain/team.dart';
import '../../../teams/infrastructure/teams_repository.dart';
import '../../application/admin_brackets_notifier.dart';
import '../../application/admin_events_notifier.dart';
/// Form for creating or editing a tournament bracket.
///
/// The shape (round count + matches per round) is set up front when creating.
/// After creation — or when editing — admins can adjust team labels, set
/// scores, change match status, and pick a winner per match.
class AdminBracketFormScreen extends ConsumerStatefulWidget {
const AdminBracketFormScreen({super.key, this.bracketId});
final String? bracketId;
bool get isEdit => bracketId != null;
@override
ConsumerState<AdminBracketFormScreen> createState() =>
_AdminBracketFormScreenState();
}
class _AdminBracketFormScreenState
extends ConsumerState<AdminBracketFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
String? _eventId;
DateTime _createdAt = DateTime.now();
List<_RoundDraft> _rounds = <_RoundDraft>[];
// Setup-mode controls (visible when creating a brand-new bracket)
int _setupRounds = 3;
int _setupTeams = 8;
bool _hydrated = false;
bool _submitting = false;
@override
void initState() {
super.initState();
if (!widget.isEdit) {
_hydrated = true;
_generateRounds(rounds: _setupRounds, teams: _setupTeams);
}
}
@override
void dispose() {
_nameCtrl.dispose();
for (final r in _rounds) {
r.dispose();
}
super.dispose();
}
void _hydrateFrom(Bracket bracket) {
if (_hydrated) return;
_nameCtrl.text = bracket.name;
_eventId = bracket.eventId.isEmpty ? null : bracket.eventId;
_createdAt = bracket.createdAt;
_rounds = bracket.rounds.map(_RoundDraft.fromRound).toList();
_hydrated = true;
}
void _generateRounds({required int rounds, required int teams}) {
// For a single-elimination shape, round 1 holds teams/2 matches, round 2
// holds teams/4, etc. We round up to handle odd team counts gracefully.
for (final r in _rounds) {
r.dispose();
}
_rounds = <_RoundDraft>[];
var matchesInRound = (teams / 2).ceil();
for (var i = 0; i < rounds; i++) {
_rounds.add(
_RoundDraft(
roundNumber: i + 1,
label: _defaultRoundLabel(i, rounds),
matches: List.generate(
matchesInRound < 1 ? 1 : matchesInRound,
(m) => _MatchDraft.empty(id: 'r${i + 1}_m${m + 1}'),
),
),
);
matchesInRound = (matchesInRound / 2).ceil();
}
setState(() {});
}
String _defaultRoundLabel(int index, int total) {
final distanceFromEnd = total - 1 - index;
switch (distanceFromEnd) {
case 0:
return 'Final';
case 1:
return 'Semifinals';
case 2:
return 'Quarterfinals';
case 3:
return 'Round of 16';
default:
return 'Round ${index + 1}';
}
}
Future<void> _randomizeTeams() async {
final messenger = ScaffoldMessenger.of(context);
final List<Team> teams = await ref
.read(teamsRepositoryProvider)
.watchTeams()
.first;
if (!mounted) return;
if (teams.length < 2) {
messenger.showSnackBar(
const SnackBar(
content: Text('Need at least 2 teams in Firestore to randomize.'),
),
);
return;
}
if (_rounds.isEmpty || _rounds.first.matches.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('No round-1 matches to fill.')),
);
return;
}
final shuffled = List<Team>.of(teams)..shuffle();
final round1 = _rounds.first;
setState(() {
var teamIdx = 0;
for (final match in round1.matches) {
if (teamIdx < shuffled.length) {
final a = shuffled[teamIdx++];
match.teamANameCtrl.text = a.name;
match.teamAId = a.id;
} else {
match.teamANameCtrl.text = '';
match.teamAId = null;
}
if (teamIdx < shuffled.length) {
final b = shuffled[teamIdx++];
match.teamBNameCtrl.text = b.name;
match.teamBId = b.id;
} else {
match.teamBNameCtrl.text = '';
match.teamBId = null;
}
}
});
messenger.showSnackBar(
SnackBar(
content: Text('Randomized ${shuffled.length} teams into round 1.'),
),
);
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_eventId == null || _eventId!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pick an event for this bracket.')),
);
return;
}
final id = widget.bracketId ?? '';
final bracket = Bracket(
id: id,
eventId: _eventId!,
name: _nameCtrl.text.trim(),
createdAt: _createdAt,
rounds: _rounds.map((r) => r.toRound()).toList(growable: false),
);
setState(() => _submitting = true);
try {
if (widget.isEdit) {
await ref.read(adminBracketsNotifierProvider.notifier).save(bracket);
} else {
await ref.read(adminBracketsNotifierProvider.notifier).create(bracket);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.isEdit ? 'Bracket updated' : 'Bracket created'),
),
);
context.go('/admin/brackets');
} 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 bracketsAsync = ref.watch(adminBracketsStreamProvider);
final brackets = bracketsAsync.valueOrNull;
if (brackets != null) {
Bracket? match;
for (final b in brackets) {
if (b.id == widget.bracketId) {
match = b;
break;
}
}
if (match != null) {
final found = match;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _hydrateFrom(found));
});
}
}
}
final eventsAsync = ref.watch(adminEventsStreamProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.isEdit ? 'EDIT BRACKET' : 'NEW BRACKET'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/admin/brackets'),
),
),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Bracket name'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
eventsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text('Could not load events: $e'),
data: (events) => _EventPicker(
events: events,
selected: _eventId,
onChanged: (id) => setState(() => _eventId = id),
),
),
if (!widget.isEdit) ...<Widget>[
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _submitting ? null : _randomizeTeams,
icon: const Text('🎲', style: TextStyle(fontSize: 16)),
label: const Text('RANDOMIZE TEAMS'),
),
),
],
const SizedBox(height: 20),
if (!widget.isEdit) ...<Widget>[
Text(
'BRACKET SHAPE',
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.5,
),
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Expanded(
child: _NumberStepper(
label: 'Rounds',
value: _setupRounds,
min: 1,
max: 6,
onChanged: (v) => setState(() {
_setupRounds = v;
_generateRounds(rounds: v, teams: _setupTeams);
}),
),
),
const SizedBox(width: 12),
Expanded(
child: _NumberStepper(
label: 'Teams in round 1',
value: _setupTeams,
min: 2,
max: 32,
step: 2,
onChanged: (v) => setState(() {
_setupTeams = v;
_generateRounds(rounds: _setupRounds, teams: v);
}),
),
),
],
),
const SizedBox(height: 16),
],
..._rounds.map(
(round) => _RoundEditor(
round: round,
onChanged: () => setState(() {}),
),
),
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 BRACKET'),
),
],
),
),
),
);
}
}
class _EventPicker extends StatelessWidget {
const _EventPicker({
required this.events,
required this.selected,
required this.onChanged,
});
final List<Event> events;
final String? selected;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
final items = <DropdownMenuItem<String>>[
const DropdownMenuItem<String>(
value: null,
child: Text('— select event —'),
),
...events.map(
(e) => DropdownMenuItem<String>(
value: e.id,
child: Text(e.title, overflow: TextOverflow.ellipsis),
),
),
];
final currentValue = events.any((e) => e.id == selected) ? selected : null;
return DropdownButtonFormField<String>(
initialValue: currentValue,
items: items,
onChanged: onChanged,
decoration: const InputDecoration(labelText: 'Event'),
);
}
}
class _NumberStepper extends StatelessWidget {
const _NumberStepper({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
this.step = 1,
});
final String label;
final int value;
final int min;
final int max;
final int step;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Row(
children: <Widget>[
IconButton(
icon: const Icon(Icons.remove),
onPressed: value - step >= min
? () => onChanged(value - step)
: null,
),
Expanded(
child: Text(
'$value',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: value + step <= max
? () => onChanged(value + step)
: null,
),
],
),
],
),
);
}
}
class _RoundEditor extends StatelessWidget {
const _RoundEditor({required this.round, required this.onChanged});
final _RoundDraft round;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextField(
controller: round.labelCtrl,
decoration: InputDecoration(
labelText: 'Round ${round.roundNumber} label',
hintText: 'e.g. Quarterfinals',
),
),
const SizedBox(height: 12),
for (var i = 0; i < round.matches.length; i++)
_MatchEditor(
index: i,
match: round.matches[i],
onChanged: onChanged,
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () {
round.matches.add(
_MatchDraft.empty(
id: 'r${round.roundNumber}_m${round.matches.length + 1}',
),
);
onChanged();
},
icon: const Icon(Icons.add, size: 18),
label: Text('Add match', style: theme.textTheme.labelMedium),
),
),
],
),
),
);
}
}
class _MatchEditor extends StatelessWidget {
const _MatchEditor({
required this.index,
required this.match,
required this.onChanged,
});
final int index;
final _MatchDraft match;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
'MATCH ${index + 1}',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 1.2,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
DropdownButton<MatchStatus>(
value: match.status,
onChanged: (v) {
if (v == null) return;
match.status = v;
onChanged();
},
items: MatchStatus.values
.map(
(s) => DropdownMenuItem<MatchStatus>(
value: s,
child: Text(_statusLabel(s)),
),
)
.toList(),
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Expanded(
child: TextField(
controller: match.teamANameCtrl,
decoration: const InputDecoration(labelText: 'Team A'),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: match.scoreACtrl,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: const InputDecoration(labelText: 'Score'),
),
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Expanded(
child: TextField(
controller: match.teamBNameCtrl,
decoration: const InputDecoration(labelText: 'Team B'),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: match.scoreBCtrl,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: const InputDecoration(labelText: 'Score'),
),
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Text(
'Winner: ',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
Expanded(
child: SegmentedButton<_WinnerSelection>(
segments: const <ButtonSegment<_WinnerSelection>>[
ButtonSegment(
value: _WinnerSelection.none,
label: Text('TBD'),
),
ButtonSegment(
value: _WinnerSelection.teamA,
label: Text('A'),
),
ButtonSegment(
value: _WinnerSelection.teamB,
label: Text('B'),
),
],
selected: <_WinnerSelection>{match.winner},
onSelectionChanged: (set) {
match.winner = set.first;
onChanged();
},
),
),
],
),
],
),
);
}
String _statusLabel(MatchStatus s) {
switch (s) {
case MatchStatus.scheduled:
return 'Scheduled';
case MatchStatus.inProgress:
return 'In progress';
case MatchStatus.completed:
return 'Completed';
}
}
}
enum _WinnerSelection { none, teamA, teamB }
class _RoundDraft {
_RoundDraft({
required this.roundNumber,
required String label,
required this.matches,
}) : labelCtrl = TextEditingController(text: label);
factory _RoundDraft.fromRound(BracketRound round) {
return _RoundDraft(
roundNumber: round.roundNumber,
label: round.label,
matches: round.matches.map(_MatchDraft.fromMatch).toList(),
);
}
final int roundNumber;
final TextEditingController labelCtrl;
final List<_MatchDraft> matches;
void dispose() {
labelCtrl.dispose();
for (final m in matches) {
m.dispose();
}
}
BracketRound toRound() {
return BracketRound(
roundNumber: roundNumber,
label: labelCtrl.text.trim().isEmpty
? 'Round $roundNumber'
: labelCtrl.text.trim(),
matches: matches.map((m) => m.toMatch()).toList(growable: false),
);
}
}
class _MatchDraft {
_MatchDraft({
required this.id,
required String teamA,
required String teamB,
required int? scoreA,
required int? scoreB,
required this.status,
required this.winner,
this.teamAId,
this.teamBId,
}) : teamANameCtrl = TextEditingController(text: teamA),
teamBNameCtrl = TextEditingController(text: teamB),
scoreACtrl = TextEditingController(
text: scoreA == null ? '' : '$scoreA',
),
scoreBCtrl = TextEditingController(
text: scoreB == null ? '' : '$scoreB',
);
factory _MatchDraft.empty({required String id}) {
return _MatchDraft(
id: id,
teamA: '',
teamB: '',
scoreA: null,
scoreB: null,
status: MatchStatus.scheduled,
winner: _WinnerSelection.none,
);
}
factory _MatchDraft.fromMatch(BracketMatch match) {
final winner = match.winnerId == null
? _WinnerSelection.none
: match.isTeamAWinner
? _WinnerSelection.teamA
: match.isTeamBWinner
? _WinnerSelection.teamB
: _WinnerSelection.none;
return _MatchDraft(
id: match.id,
teamA: match.teamA?.name ?? '',
teamB: match.teamB?.name ?? '',
scoreA: match.scoreA,
scoreB: match.scoreB,
status: match.status,
winner: winner,
teamAId: match.teamA?.id,
teamBId: match.teamB?.id,
);
}
final String id;
final TextEditingController teamANameCtrl;
final TextEditingController teamBNameCtrl;
final TextEditingController scoreACtrl;
final TextEditingController scoreBCtrl;
MatchStatus status;
_WinnerSelection winner;
String? teamAId;
String? teamBId;
void dispose() {
teamANameCtrl.dispose();
teamBNameCtrl.dispose();
scoreACtrl.dispose();
scoreBCtrl.dispose();
}
BracketMatch toMatch() {
final aName = teamANameCtrl.text.trim();
final bName = teamBNameCtrl.text.trim();
final teamA = aName.isEmpty
? null
: BracketTeam(id: teamAId ?? _slug(aName), name: aName);
final teamB = bName.isEmpty
? null
: BracketTeam(id: teamBId ?? _slug(bName), name: bName);
String? winnerId;
switch (winner) {
case _WinnerSelection.none:
winnerId = null;
break;
case _WinnerSelection.teamA:
winnerId = teamA?.id;
break;
case _WinnerSelection.teamB:
winnerId = teamB?.id;
break;
}
return BracketMatch(
id: id,
teamA: teamA,
teamB: teamB,
scoreA: int.tryParse(scoreACtrl.text.trim()),
scoreB: int.tryParse(scoreBCtrl.text.trim()),
status: status,
winnerId: winnerId,
);
}
static String _slug(String input) {
final lower = input.toLowerCase();
final cleaned = lower
.replaceAll(RegExp(r'[^a-z0-9]+'), '_')
.replaceAll(RegExp(r'^_|_$'), '');
return cleaned.isEmpty ? 'team' : cleaned;
}
}
@@ -0,0 +1,184 @@
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 '../../../brackets/domain/bracket.dart';
import '../../application/admin_brackets_notifier.dart';
class AdminBracketsScreen extends ConsumerWidget {
const AdminBracketsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bracketsAsync = ref.watch(adminBracketsStreamProvider);
final theme = Theme.of(context);
return Scaffold(
body: bracketsAsync.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 brackets',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'$err',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
data: (brackets) {
if (brackets.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.account_tree_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No brackets yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Tap the + button to create your first bracket.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
itemCount: brackets.length,
itemBuilder: (context, i) => _BracketRow(bracket: brackets[i]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.go('/admin/brackets/new'),
icon: const Icon(Icons.add),
label: const Text('NEW BRACKET'),
),
);
}
}
class _BracketRow extends ConsumerWidget {
const _BracketRow({required this.bracket});
final Bracket bracket;
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete bracket?'),
content: Text(
'"${bracket.name}" and all its match data 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(adminBracketsNotifierProvider.notifier).delete(bracket.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted "${bracket.name}"')),
);
}
} 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 created = DateFormat.yMMMd().format(bracket.createdAt);
final totalMatches = bracket.rounds.fold<int>(
0,
(sum, r) => sum + r.matches.length,
);
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(bracket.name, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(
'${bracket.rounds.length} round${bracket.rounds.length == 1 ? '' : 's'} · $totalMatches match${totalMatches == 1 ? '' : 'es'} · Created $created',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
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/brackets/${bracket.id}/edit'),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit'),
),
],
),
],
),
),
);
}
}