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