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,132 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Shell for the admin panel. On screens 640px wide and below it shows a
/// bottom NavigationBar; on wider viewports it switches to a NavigationRail
/// so admins can sweep through tabs with a single click on web/desktop.
class AdminShell extends StatelessWidget {
const AdminShell({super.key, required this.child});
final Widget child;
static const _tabs = <_AdminTab>[
_AdminTab(
label: 'EVENTS',
icon: Icons.event_outlined,
activeIcon: Icons.event,
path: '/admin/events',
),
_AdminTab(
label: 'TEAMS',
icon: Icons.groups_outlined,
activeIcon: Icons.groups,
path: '/admin/teams',
),
_AdminTab(
label: 'BRACKETS',
icon: Icons.account_tree_outlined,
activeIcon: Icons.account_tree,
path: '/admin/brackets',
),
_AdminTab(
label: 'IDEAS',
icon: Icons.lightbulb_outline,
activeIcon: Icons.lightbulb,
path: '/admin/suggestions',
),
_AdminTab(
label: 'PENDING',
icon: Icons.pending_actions_outlined,
activeIcon: Icons.pending_actions,
path: '/admin/pending',
),
];
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final idx = _tabs.indexWhere((t) => location.startsWith(t.path));
return idx < 0 ? 0 : idx;
}
@override
Widget build(BuildContext context) {
final currentIndex = _currentIndex(context);
return Scaffold(
appBar: AppBar(
title: const Text('ADMIN'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to app',
onPressed: () => context.go('/events'),
),
),
body: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 640;
if (isWide) {
return Row(
children: <Widget>[
NavigationRail(
selectedIndex: currentIndex,
onDestinationSelected: (i) => context.go(_tabs[i].path),
labelType: NavigationRailLabelType.all,
destinations: _tabs
.map(
(t) => NavigationRailDestination(
icon: Icon(t.icon),
selectedIcon: Icon(t.activeIcon),
label: Text(t.label),
),
)
.toList(),
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
);
}
return child;
},
),
bottomNavigationBar: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 640;
if (isWide) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(height: 1, color: const Color(0xFF8B30C8)),
NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (i) => context.go(_tabs[i].path),
destinations: _tabs
.map(
(t) => NavigationDestination(
icon: Icon(t.icon),
selectedIcon: Icon(t.activeIcon),
label: t.label,
),
)
.toList(),
),
],
);
},
),
);
}
}
class _AdminTab {
const _AdminTab({
required this.label,
required this.icon,
required this.activeIcon,
required this.path,
});
final String label;
final IconData icon;
final IconData activeIcon;
final String path;
}
@@ -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'),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,339 @@
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 '../../../events/domain/event.dart';
import '../../application/admin_events_notifier.dart';
class AdminEventFormScreen extends ConsumerStatefulWidget {
const AdminEventFormScreen({super.key, this.eventId});
/// Null when creating a new event; otherwise the id of the event being
/// edited.
final String? eventId;
bool get isEdit => eventId != null;
@override
ConsumerState<AdminEventFormScreen> createState() =>
_AdminEventFormScreenState();
}
class _AdminEventFormScreenState extends ConsumerState<AdminEventFormScreen> {
final _formKey = GlobalKey<FormState>();
final _titleCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _locationCtrl = TextEditingController();
final _imageUrlCtrl = TextEditingController();
final _teamsRegisteredCtrl = TextEditingController(text: '0');
final _maxTeamsCtrl = TextEditingController(text: '8');
DateTime _date = DateTime.now().add(const Duration(days: 7));
DateTime _registrationDeadline = DateTime.now().add(const Duration(days: 6));
EventCategory _category = EventCategory.pickup;
bool _isCancelled = false;
bool _hydrated = false;
bool _submitting = false;
@override
void initState() {
super.initState();
if (!widget.isEdit) _hydrated = true;
}
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
_locationCtrl.dispose();
_imageUrlCtrl.dispose();
_teamsRegisteredCtrl.dispose();
_maxTeamsCtrl.dispose();
super.dispose();
}
void _hydrateFrom(Event event) {
if (_hydrated) return;
_titleCtrl.text = event.title;
_descCtrl.text = event.description;
_locationCtrl.text = event.location;
_imageUrlCtrl.text = event.imageUrl ?? '';
_teamsRegisteredCtrl.text = event.teamsRegistered.toString();
_maxTeamsCtrl.text = event.maxTeams.toString();
_date = event.date;
_registrationDeadline = event.registrationDeadline;
_category = event.category;
_isCancelled = event.isCancelled;
_hydrated = true;
}
Future<void> _pickDate({required bool registration}) async {
final initial = registration ? _registrationDeadline : _date;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked == null || !mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(initial),
);
if (time == null) return;
final merged = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
setState(() {
if (registration) {
_registrationDeadline = merged;
} else {
_date = merged;
}
});
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final id = widget.eventId ?? '';
final event = Event(
id: id,
title: _titleCtrl.text.trim(),
description: _descCtrl.text.trim(),
date: _date,
location: _locationCtrl.text.trim(),
registrationDeadline: _registrationDeadline,
teamsRegistered: int.tryParse(_teamsRegisteredCtrl.text.trim()) ?? 0,
maxTeams: int.tryParse(_maxTeamsCtrl.text.trim()) ?? 0,
category: _category,
imageUrl: _imageUrlCtrl.text.trim().isEmpty
? null
: _imageUrlCtrl.text.trim(),
isCancelled: _isCancelled,
);
setState(() => _submitting = true);
try {
if (widget.isEdit) {
await ref.read(adminEventsNotifierProvider.notifier).save(event);
} else {
await ref.read(adminEventsNotifierProvider.notifier).create(event);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.isEdit ? 'Event updated' : 'Event created'),
),
);
context.go('/admin/events');
} 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 eventsAsync = ref.watch(adminEventsStreamProvider);
final events = eventsAsync.valueOrNull;
if (events != null) {
final match = events.firstWhere(
(e) => e.id == widget.eventId,
orElse: () => _placeholderEvent(),
);
if (match.id.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _hydrateFrom(match));
});
}
}
}
final theme = Theme.of(context);
final df = DateFormat.yMMMd().add_jm();
return Scaffold(
appBar: AppBar(
title: Text(widget.isEdit ? 'EDIT EVENT' : 'NEW EVENT'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/admin/events'),
),
),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
TextFormField(
controller: _titleCtrl,
decoration: const InputDecoration(labelText: 'Title'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<EventCategory>(
segments: const <ButtonSegment<EventCategory>>[
ButtonSegment<EventCategory>(
value: EventCategory.tournament,
label: Text('TOURNAMENT'),
icon: Icon(Icons.emoji_events_outlined),
),
ButtonSegment<EventCategory>(
value: EventCategory.pickup,
label: Text('PICK-UP'),
icon: Icon(Icons.sports_soccer),
),
],
selected: <EventCategory>{_category},
onSelectionChanged: (set) =>
setState(() => _category = set.first),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'Description'),
minLines: 3,
maxLines: 6,
),
const SizedBox(height: 12),
TextFormField(
controller: _locationCtrl,
decoration: const InputDecoration(labelText: 'Location'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
_DateField(
label: 'Event date & time',
value: df.format(_date),
onTap: () => _pickDate(registration: false),
),
const SizedBox(height: 12),
_DateField(
label: 'Registration deadline',
value: df.format(_registrationDeadline),
onTap: () => _pickDate(registration: true),
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: TextFormField(
controller: _teamsRegisteredCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Teams registered',
),
validator: _validateInt,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _maxTeamsCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Max teams'),
validator: _validateInt,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _imageUrlCtrl,
decoration: const InputDecoration(
labelText: 'Image URL (optional)',
),
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text('Cancelled', style: theme.textTheme.bodyLarge),
subtitle: Text(
'Mark this event as cancelled',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _isCancelled,
onChanged: (v) => setState(() => _isCancelled = v),
),
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 EVENT'),
),
],
),
),
),
);
}
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;
}
}
Event _placeholderEvent() => Event(
id: '',
title: '',
description: '',
date: DateTime.now(),
location: '',
registrationDeadline: DateTime.now(),
teamsRegistered: 0,
maxTeams: 0,
);
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
required this.value,
required this.onTap,
});
final String label;
final String value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_today, size: 18),
),
child: Text(value),
),
);
}
}
@@ -0,0 +1,270 @@
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 '../../../events/domain/event.dart';
import '../../application/admin_events_notifier.dart';
class AdminEventsScreen extends ConsumerWidget {
const AdminEventsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final eventsAsync = ref.watch(adminEventsStreamProvider);
final theme = Theme.of(context);
return Scaffold(
body: eventsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorState(
message: '$err',
onRetry: () => ref.invalidate(adminEventsStreamProvider),
),
data: (events) {
if (events.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.event_busy_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No events yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Tap the + button to create your first event.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
itemCount: events.length,
itemBuilder: (context, index) => _EventRow(event: events[index]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.go('/admin/events/new'),
icon: const Icon(Icons.add),
label: const Text('NEW EVENT'),
),
);
}
}
class _EventRow extends ConsumerWidget {
const _EventRow({required this.event});
final Event event;
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete event?'),
content: Text(
'"${event.title}" will be permanently removed. This cannot be undone.',
),
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(adminEventsNotifierProvider.notifier).delete(event.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted "${event.title}"')),
);
}
} 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 dateLabel = DateFormat.yMMMd().add_jm().format(event.date);
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>[
Expanded(
child: Text(
event.title,
style: theme.textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (event.isCancelled)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'CANCELLED',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Icon(
Icons.schedule,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Text(
'${event.teamsRegistered}/${event.maxTeams} teams',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
Row(
children: <Widget>[
Icon(
Icons.location_on_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
event.location,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
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/events/${event.id}/edit'),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit'),
),
],
),
],
),
),
);
}
}
class _ErrorState extends StatelessWidget {
const _ErrorState({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return 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 events',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
),
],
),
),
);
}
}
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../teams/domain/team.dart';
import '../../../teams/infrastructure/teams_repository.dart';
import '../../application/admin_teams_notifier.dart';
/// Admin panel tab listing teams awaiting approval. Approve and reject
/// actions are surfaced as a row of buttons per card.
class AdminPendingScreen extends ConsumerWidget {
const AdminPendingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final async = ref.watch(adminTeamsStreamProvider);
return Scaffold(
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('Could not load teams: $e'),
),
),
data: (teams) {
final pending = teams.where((t) => t.isPending).toList();
if (pending.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.pending_actions_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No pending teams',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'New manager-submitted teams will appear here for '
'review.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
itemCount: pending.length,
itemBuilder: (context, i) => _PendingTeamCard(team: pending[i]),
);
},
),
);
}
}
class _PendingTeamCard extends ConsumerStatefulWidget {
const _PendingTeamCard({required this.team});
final Team team;
@override
ConsumerState<_PendingTeamCard> createState() => _PendingTeamCardState();
}
class _PendingTeamCardState extends ConsumerState<_PendingTeamCard> {
bool _busy = false;
Future<void> _setStatus(String status) async {
setState(() => _busy = true);
try {
await ref
.read(teamsRepositoryProvider)
.updateTeamStatus(widget.team.id, status);
if (!mounted) return;
final label = status == TeamStatus.approved ? 'approved' : 'rejected';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${widget.team.name} $label')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Action failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = widget.team;
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>[
CircleAvatar(
backgroundColor: scheme.primaryContainer,
child: Text(
t.name.isEmpty ? '?' : t.name.characters.first,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.name.isEmpty ? 'Unnamed team' : t.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
t.managerEmail.isEmpty
? 'No manager email'
: t.managerEmail,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'PENDING',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.amber.shade300,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
),
),
),
],
),
if (t.description != null && t.description!.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
t.description!,
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 10),
Row(
children: <Widget>[
Icon(
Icons.group_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'${t.players.length} '
'${t.players.length == 1 ? "player" : "players"}',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton.icon(
onPressed: _busy
? null
: () => _setStatus(TeamStatus.rejected),
icon: const Icon(Icons.close, size: 18),
label: const Text('REJECT'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: _busy
? null
: () => _setStatus(TeamStatus.approved),
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check, size: 18),
label: const Text('APPROVE'),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../suggestions/domain/suggestion.dart';
import '../../application/admin_suggestions_notifier.dart';
class AdminSuggestionsScreen extends ConsumerWidget {
const AdminSuggestionsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(adminSuggestionsStreamProvider);
final theme = Theme.of(context);
return Scaffold(
body: async.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 suggestions',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'$err',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
data: (suggestions) {
if (suggestions.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.lightbulb_outline,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No suggestions yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Community ideas will show up here as they come in.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
itemCount: suggestions.length,
itemBuilder: (context, i) =>
_AdminSuggestionRow(suggestion: suggestions[i]),
);
},
),
);
}
}
class _AdminSuggestionRow extends ConsumerWidget {
const _AdminSuggestionRow({required this.suggestion});
final Suggestion suggestion;
String _author() {
if (suggestion.isAnonymous) return 'Anonymous';
final name = suggestion.displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final userId = suggestion.userId?.trim();
if (userId != null && userId.isNotEmpty) return 'User $userId';
return 'Unknown';
}
Future<void> _changeStatus(BuildContext context, WidgetRef ref) async {
final picked = await showDialog<SuggestionStatus>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Update status'),
children: SuggestionStatus.values
.map(
(s) => SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(s),
child: Row(
children: <Widget>[
Icon(
s == suggestion.status
? Icons.radio_button_checked
: Icons.radio_button_off,
size: 18,
),
const SizedBox(width: 12),
Text(_statusLabel(s)),
],
),
),
)
.toList(),
),
);
if (picked == null || picked == suggestion.status) return;
if (!context.mounted) return;
try {
await ref
.read(adminSuggestionsNotifierProvider.notifier)
.updateStatus(suggestion.id, picked);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Status set to ${_statusLabel(picked)}')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Update failed: $e')),
);
}
}
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete suggestion?'),
content: const Text(
'This will permanently remove the suggestion. Use for spam or duplicates.',
),
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(adminSuggestionsNotifierProvider.notifier)
.delete(suggestion.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Suggestion deleted')),
);
}
} 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 colors = theme.colorScheme;
final dateLabel =
DateFormat.yMMMd().add_jm().format(suggestion.submittedAt);
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Text(
suggestion.text,
style: theme.textTheme.bodyLarge,
),
),
const SizedBox(width: 8),
_StatusChip(status: suggestion.status),
],
),
const SizedBox(height: 10),
Row(
children: <Widget>[
Icon(
suggestion.isAnonymous
? Icons.visibility_off_outlined
: Icons.person_outline,
size: 14,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
_author(),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
Icon(Icons.schedule, size: 14, color: colors.onSurfaceVariant),
const SizedBox(width: 4),
Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.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: () => _changeStatus(context, ref),
icon: const Icon(Icons.flag_outlined, size: 18),
label: const Text('Status'),
),
],
),
],
),
),
);
}
}
String _statusLabel(SuggestionStatus s) {
switch (s) {
case SuggestionStatus.pending:
return 'Pending';
case SuggestionStatus.reviewed:
return 'Reviewed';
case SuggestionStatus.implemented:
return 'Implemented';
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final SuggestionStatus status;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final (Color background, Color foreground, String label) = switch (status) {
SuggestionStatus.pending => (
colors.surfaceContainerHighest,
colors.onSurfaceVariant,
'Pending',
),
SuggestionStatus.reviewed => (
Colors.amber.withValues(alpha: 0.18),
Colors.amber.shade300,
'Reviewed',
),
SuggestionStatus.implemented => (
Colors.green.withValues(alpha: 0.18),
Colors.green.shade300,
'Implemented',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -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'),
),
],
),
],
),
),
);
}
}