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,143 @@
import 'package:flutter/material.dart';
import '../../domain/player.dart';
/// ListTile-style row for one player in a team roster.
class PlayerTile extends StatelessWidget {
const PlayerTile({super.key, required this.player});
final Player player;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final initial = player.name.isEmpty ? '?' : player.name.characters.first;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (player.jerseyNumber != null) ...<Widget>[
_JerseyBadge(number: player.jerseyNumber!),
const SizedBox(width: 8),
],
CircleAvatar(
backgroundColor: scheme.secondaryContainer,
foregroundColor: scheme.onSecondaryContainer,
child: Text(
initial.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
title: Text(
player.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: player.position == null
? null
: Text(
player.position!,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_StatPill(
icon: Icons.sports_soccer,
value: player.goalsScored,
color: scheme.primary,
tooltip: 'Goals',
),
const SizedBox(width: 8),
_StatPill(
icon: Icons.handshake_outlined,
value: player.assists,
color: scheme.tertiary,
tooltip: 'Assists',
),
],
),
);
}
}
class _JerseyBadge extends StatelessWidget {
const _JerseyBadge({required this.number});
final int number;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
constraints: const BoxConstraints(minWidth: 36),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.primary.withValues(alpha: 0.4)),
),
child: Text(
'#$number',
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
);
}
}
class _StatPill extends StatelessWidget {
const _StatPill({
required this.icon,
required this.value,
required this.color,
required this.tooltip,
});
final IconData icon;
final int value;
final Color color;
final String tooltip;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
'$value',
style: TextStyle(
color: scheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 12.5,
),
),
],
),
),
);
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../domain/team.dart';
import 'team_record_badge.dart';
/// Card summarizing one team in the grid/list. Tapping navigates to
/// `/teams/:id`.
class TeamCard extends StatelessWidget {
const TeamCard({super.key, required this.team});
final Team team;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final initial = team.name.isEmpty ? '?' : team.name.characters.first;
final topScorer = team.topScorer;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/teams/${team.id}'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_TeamInitialAvatar(initial: initial, size: 52),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
team.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.group_outlined,
size: 14,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${team.players.length} players',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
const SizedBox(height: 12),
TeamRecordBadge(
wins: team.wins,
draws: team.draws,
losses: team.losses,
dense: true,
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: topScorer == null
? Text(
'No goals scored yet',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
)
: Row(
children: [
Icon(
Icons.sports_soccer,
size: 14,
color: scheme.primary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Top scorer: ${topScorer.name}'
'${topScorer.goalsScored} '
'${topScorer.goalsScored == 1 ? 'goal' : 'goals'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
);
}
}
class _TeamInitialAvatar extends StatelessWidget {
const _TeamInitialAvatar({required this.initial, required this.size});
final String initial;
final double size;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: scheme.primaryContainer,
shape: BoxShape.circle,
),
child: Text(
initial.toUpperCase(),
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
fontSize: size * 0.46,
),
),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Compact W / D / L pill row used on team cards and the detail header.
///
/// Win / loss / draw are universally readable colors, so they bypass the
/// color scheme and use semantic green / red / grey tints that remain stable
/// across light and dark themes.
class TeamRecordBadge extends StatelessWidget {
const TeamRecordBadge({
super.key,
required this.wins,
required this.draws,
required this.losses,
this.dense = false,
});
final int wins;
final int draws;
final int losses;
/// Shrinks padding and font size for tight spaces (e.g. inside a card).
final bool dense;
@override
Widget build(BuildContext context) {
final spacing = dense ? 6.0 : 8.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_RecordChip(
label: 'W',
value: wins,
color: Colors.green,
dense: dense,
),
SizedBox(width: spacing),
_RecordChip(
label: 'D',
value: draws,
color: Colors.grey,
dense: dense,
),
SizedBox(width: spacing),
_RecordChip(
label: 'L',
value: losses,
color: Colors.red,
dense: dense,
),
],
);
}
}
class _RecordChip extends StatelessWidget {
const _RecordChip({
required this.label,
required this.value,
required this.color,
required this.dense,
});
final String label;
final int value;
final MaterialColor color;
final bool dense;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final tint = isDark ? color.shade200 : color.shade800;
final bg = (isDark ? color.shade900 : color.shade100).withValues(
alpha: isDark ? 0.45 : 1.0,
);
final hPad = dense ? 8.0 : 10.0;
final vPad = dense ? 4.0 : 6.0;
final fontSize = dense ? 11.0 : 12.5;
return Container(
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: vPad),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tint.withValues(alpha: 0.35)),
),
child: Text(
'$label: $value',
style: TextStyle(
color: tint,
fontWeight: FontWeight.w700,
fontSize: fontSize,
letterSpacing: 0.3,
),
),
);
}
}