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,349 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/stats_notifier.dart';
import 'widgets/leaderboard_tile.dart';
import 'widgets/stat_bar_chart.dart';
import 'widgets/stats_filter_bar.dart';
/// Stats hub: standings + player leaderboards. Driven by [DefaultTabController]
/// so the [StatsFilterBar] in the AppBar bottom slot stays in sync with the
/// [TabBarView] below.
class StatsScreen extends ConsumerWidget {
const StatsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Stats'),
bottom: const StatsFilterBar(),
),
body: const TabBarView(
children: [
_StandingsTab(),
_ScorersTab(),
_AssistsTab(),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Standings
// ---------------------------------------------------------------------------
class _StandingsTab extends ConsumerWidget {
const _StandingsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final standingsAsync = ref.watch(teamStandingsProvider);
return standingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorState(
message: err.toString(),
onRetry: () => ref.invalidate(teamStandingsProvider),
),
data: (teams) {
if (teams.isEmpty) {
return const _EmptyState(
icon: Icons.emoji_events_outlined,
title: 'No standings yet',
body: 'League standings will appear once teams have played games.',
);
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
const _SectionHeader(label: 'League standings'),
const _StandingsHeaderRow(),
for (var i = 0; i < teams.length; i++)
LeaderboardTile.team(
rank: i + 1,
team: teams[i],
navContext: context,
),
const SizedBox(height: 24),
const _SectionHeader(label: 'Wins by team'),
StatBarChart(
valueLabel: 'Wins',
data: [
for (final t in teams)
StatBarDatum(label: t.name, value: t.wins),
],
),
const SizedBox(height: 24),
],
);
},
);
}
}
class _StandingsHeaderRow extends StatelessWidget {
const _StandingsHeaderRow();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final style = theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
);
return Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
child: Row(
children: [
SizedBox(width: 34, child: Text('#', style: style)),
const SizedBox(width: 12),
Expanded(child: Text('TEAM', style: style)),
const SizedBox(width: 12),
SizedBox(width: 70, child: Text('W · D · L', style: style)),
SizedBox(
width: 48,
child: Text(
'PTS',
style: style,
textAlign: TextAlign.right,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Player leaderboards
// ---------------------------------------------------------------------------
class _ScorersTab extends ConsumerWidget {
const _ScorersTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scorersAsync = ref.watch(topScorersProvider);
return scorersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorState(
message: err.toString(),
onRetry: () => ref.invalidate(topScorersProvider),
),
data: (entries) => _PlayerLeaderboardView(
entries: entries,
statSelector: (p) => p.player.goalsScored,
statLabel: 'goals',
chartLabel: 'Goals',
headerTitle: 'Top scorers',
chartTitle: 'Top 6 scorers',
emptyTitle: 'No goals yet',
emptyBody: 'Player goal tallies will appear here once games are logged.',
),
);
}
}
class _AssistsTab extends ConsumerWidget {
const _AssistsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final assistsAsync = ref.watch(topAssistersProvider);
return assistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorState(
message: err.toString(),
onRetry: () => ref.invalidate(topAssistersProvider),
),
data: (entries) => _PlayerLeaderboardView(
entries: entries,
statSelector: (p) => p.player.assists,
statLabel: 'assists',
chartLabel: 'Assists',
headerTitle: 'Top assists',
chartTitle: 'Top 6 assist leaders',
emptyTitle: 'No assists yet',
emptyBody:
'Player assist tallies will appear here once games are logged.',
),
);
}
}
/// Shared layout for the two player-stat tabs: chart on top, ranked list below.
class _PlayerLeaderboardView extends StatelessWidget {
const _PlayerLeaderboardView({
required this.entries,
required this.statSelector,
required this.statLabel,
required this.chartLabel,
required this.headerTitle,
required this.chartTitle,
required this.emptyTitle,
required this.emptyBody,
});
final List<PlayerWithTeam> entries;
final int Function(PlayerWithTeam) statSelector;
final String statLabel;
final String chartLabel;
final String headerTitle;
final String chartTitle;
final String emptyTitle;
final String emptyBody;
@override
Widget build(BuildContext context) {
// Drop players who haven't scored anything in the active category so the
// chart and list stay meaningful when the season just started.
final ranked = entries.where((e) => statSelector(e) > 0).toList();
if (ranked.isEmpty) {
return _EmptyState(
icon: Icons.bar_chart_outlined,
title: emptyTitle,
body: emptyBody,
);
}
final top = ranked.take(6).toList(growable: false);
return ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
_SectionHeader(label: chartTitle),
StatBarChart(
valueLabel: chartLabel,
data: [
for (final e in top)
StatBarDatum(label: e.player.name, value: statSelector(e)),
],
),
const SizedBox(height: 16),
_SectionHeader(label: headerTitle),
for (var i = 0; i < ranked.length; i++)
LeaderboardTile.player(
rank: i + 1,
entry: ranked[i],
statValue: statSelector(ranked[i]),
statLabel: statLabel,
navContext: context,
),
const SizedBox(height: 24),
],
);
}
}
// ---------------------------------------------------------------------------
// Shared bits
// ---------------------------------------------------------------------------
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@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: [
Icon(icon, size: 64, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
body,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
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: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text('Could not load stats', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
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'),
),
],
),
),
);
}
}