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,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'),
),
),
],
),
],
),
),
);
}
}