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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user