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,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../../profile/infrastructure/profile_repository.dart';
|
||||
import '../domain/player.dart';
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
|
||||
/// Public-facing form for any logged-in user to register a new team.
|
||||
///
|
||||
/// Differs from the admin form in two ways:
|
||||
/// 1. Manager email/phone are first-class fields so the league can contact
|
||||
/// whoever created the team.
|
||||
/// 2. Win/loss/draw counters are not exposed — those are reserved for admins.
|
||||
class CreateTeamScreen extends ConsumerStatefulWidget {
|
||||
const CreateTeamScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateTeamScreen> createState() => _CreateTeamScreenState();
|
||||
}
|
||||
|
||||
class _CreateTeamScreenState extends ConsumerState<CreateTeamScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _logoUrlCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final _managerEmailCtrl = TextEditingController();
|
||||
final _managerPhoneCtrl = TextEditingController();
|
||||
|
||||
final List<_PlayerDraft> _roster = <_PlayerDraft>[];
|
||||
|
||||
bool _hydratedEmail = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_logoUrlCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_managerEmailCtrl.dispose();
|
||||
_managerPhoneCtrl.dispose();
|
||||
for (final p in _roster) {
|
||||
p.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _addPlayerRow() {
|
||||
setState(() => _roster.add(_PlayerDraft()));
|
||||
}
|
||||
|
||||
void _removePlayerRow(_PlayerDraft draft) {
|
||||
setState(() {
|
||||
_roster.remove(draft);
|
||||
draft.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final user = ref.read(authNotifierProvider).valueOrNull;
|
||||
final id = 'team_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final players = <Player>[];
|
||||
for (var i = 0; i < _roster.length; i++) {
|
||||
final draft = _roster[i];
|
||||
final name = draft.nameCtrl.text.trim();
|
||||
if (name.isEmpty) continue;
|
||||
players.add(
|
||||
Player(
|
||||
id: '${id}_p$i',
|
||||
name: name,
|
||||
jerseyNumber: int.tryParse(draft.jerseyCtrl.text.trim()),
|
||||
position: draft.positionCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: draft.positionCtrl.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
managerId: user?.uid,
|
||||
managerEmail: _managerEmailCtrl.text.trim(),
|
||||
managerPhone: _managerPhoneCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _managerPhoneCtrl.text.trim(),
|
||||
players: players,
|
||||
// Manager-submitted teams require admin approval before going public.
|
||||
status: TeamStatus.pending,
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final newId = await ref.read(teamsRepositoryProvider).createTeam(team);
|
||||
// Stamp the team on the manager's profile so the dashboard finds it.
|
||||
if (user != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.updateTeamId(user.uid, newId);
|
||||
} catch (_) {
|
||||
// Profile may not exist yet for legacy accounts — non-fatal.
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Team submitted — awaiting admin approval.'),
|
||||
),
|
||||
);
|
||||
context.go('/manager');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not create team: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
if (!_hydratedEmail && user != null) {
|
||||
_managerEmailCtrl.text = user.email;
|
||||
_hydratedEmail = true;
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('NEW TEAM'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/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: _logoUrlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Logo URL (optional)',
|
||||
hintText: 'https://...',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'CONTACT',
|
||||
style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 1.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _managerEmailCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Manager email',
|
||||
prefixIcon: Icon(Icons.mail_outline),
|
||||
),
|
||||
readOnly: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _managerPhoneCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Manager phone (optional)',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'ROSTER',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _addPlayerRow,
|
||||
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 yet — tap "Add player" to start your roster.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._roster.map(
|
||||
(draft) => _PlayerRow(
|
||||
draft: draft,
|
||||
onRemove: () => _removePlayerRow(draft),
|
||||
),
|
||||
),
|
||||
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.add_circle_outline),
|
||||
label: const Text('CREATE TEAM'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerDraft {
|
||||
_PlayerDraft()
|
||||
: nameCtrl = TextEditingController(),
|
||||
jerseyCtrl = TextEditingController(),
|
||||
positionCtrl = TextEditingController();
|
||||
|
||||
final TextEditingController nameCtrl;
|
||||
final TextEditingController jerseyCtrl;
|
||||
final TextEditingController positionCtrl;
|
||||
|
||||
void dispose() {
|
||||
nameCtrl.dispose();
|
||||
jerseyCtrl.dispose();
|
||||
positionCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerRow extends StatelessWidget {
|
||||
const _PlayerRow({required this.draft, required this.onRemove});
|
||||
|
||||
final _PlayerDraft draft;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: draft.nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Player name'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
tooltip: 'Remove player',
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: TextFormField(
|
||||
controller: draft.jerseyCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Jersey #'),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: draft.positionCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Position'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../../profile/application/profile_notifier.dart';
|
||||
import '../../profile/domain/user_profile.dart';
|
||||
import '../application/teams_notifier.dart';
|
||||
import '../domain/join_request.dart';
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
import 'widgets/player_tile.dart';
|
||||
import 'widgets/team_record_badge.dart';
|
||||
|
||||
/// Full-screen view of a single team: header (logo + record), summary stats,
|
||||
/// and roster.
|
||||
class TeamDetailScreen extends ConsumerWidget {
|
||||
const TeamDetailScreen({super.key, required this.teamId});
|
||||
|
||||
final String teamId;
|
||||
|
||||
/// Web reads better when long content is centered in a column; ~760px is
|
||||
/// the same max we use across other detail screens.
|
||||
static const double _maxContentWidth = 760;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final team = ref.watch(teamByIdProvider(teamId));
|
||||
|
||||
if (team == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Team'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/teams'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('Team not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/teams'),
|
||||
),
|
||||
title: Text(team.name),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
||||
child: _TeamDetailBody(team: team),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamDetailBody extends StatelessWidget {
|
||||
const _TeamDetailBody({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _TeamHeader(team: team)),
|
||||
SliverToBoxAdapter(child: _JoinTeamSection(team: team)),
|
||||
SliverToBoxAdapter(child: _StatsRow(team: team)),
|
||||
SliverToBoxAdapter(child: _ContactSection(team: team)),
|
||||
const SliverToBoxAdapter(child: _SectionDivider(title: 'Roster')),
|
||||
if (team.players.isEmpty)
|
||||
const SliverToBoxAdapter(child: _EmptyRoster())
|
||||
else
|
||||
SliverList.separated(
|
||||
itemCount: team.players.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) =>
|
||||
PlayerTile(player: team.players[index]),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one of four states for a logged-in player viewing a team:
|
||||
/// * already on this team → 'YOUR TEAM' chip
|
||||
/// * already on another team → no CTA
|
||||
/// * pending request out → disabled 'Request pending' button
|
||||
/// * no request yet → primary 'Request to join' OutlinedButton
|
||||
///
|
||||
/// Returns SizedBox.shrink for managers, admins, viewers, or while role is
|
||||
/// resolving — they have no use for the action.
|
||||
class _JoinTeamSection extends ConsumerWidget {
|
||||
const _JoinTeamSection({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
final role = ref.watch(currentUserRoleProvider);
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
|
||||
if (user == null || role != UserRole.player || profile == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Player is already on this team.
|
||||
if (profile.teamId == team.id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'YOUR TEAM',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.green.shade300,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Player is on a different team — no join CTA shown.
|
||||
if (profile.hasTeam) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final requestsAsync = ref.watch(
|
||||
joinRequestsForPlayerProvider(user.uid),
|
||||
);
|
||||
|
||||
return requestsAsync.when(
|
||||
loading: () => const SizedBox(height: 0),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (requests) {
|
||||
final hasPendingForThisTeam = requests.any(
|
||||
(r) =>
|
||||
r.teamId == team.id && r.status == JoinRequestStatus.pending,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
|
||||
child: hasPendingForThisTeam
|
||||
? OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.hourglass_bottom, size: 18),
|
||||
label: const Text('REQUEST PENDING'),
|
||||
)
|
||||
: OutlinedButton.icon(
|
||||
onPressed: () => _submit(context, ref, profile),
|
||||
icon: const Icon(Icons.person_add_alt_1, size: 18),
|
||||
label: const Text('REQUEST TO JOIN'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
UserProfile profile,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(teamsRepositoryProvider).submitJoinRequest(
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
playerId: profile.uid,
|
||||
playerName: profile.displayName.isEmpty
|
||||
? profile.email
|
||||
: profile.displayName,
|
||||
playerEmail: profile.email,
|
||||
);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Request sent!')),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not send request: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamHeader extends StatelessWidget {
|
||||
const _TeamHeader({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 hasLogo = team.logoUrl != null && team.logoUrl!.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 24,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: hasLogo
|
||||
? CircleAvatar(
|
||||
radius: 48,
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
backgroundImage: NetworkImage(team.logoUrl!),
|
||||
)
|
||||
: Text(
|
||||
initial.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 44,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
team.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (team.description != null && team.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
team.description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TeamRecordBadge(
|
||||
wins: team.wins,
|
||||
draws: team.draws,
|
||||
losses: team.losses,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final winPct = (team.winPercentage * 100).round();
|
||||
final topScorer = team.topScorer;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatColumn(label: 'Games', value: '${team.totalGames}'),
|
||||
),
|
||||
_VerticalDivider(color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _StatColumn(label: 'Win %', value: '$winPct%'),
|
||||
),
|
||||
_VerticalDivider(color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _StatColumn(
|
||||
label: 'Top scorer',
|
||||
value: topScorer == null
|
||||
? '—'
|
||||
: '${topScorer.name.split(' ').first} '
|
||||
'(${topScorer.goalsScored})',
|
||||
small: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactSection extends StatelessWidget {
|
||||
const _ContactSection({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasEmail = team.managerEmail.isNotEmpty;
|
||||
final hasPhone = team.managerPhone != null && team.managerPhone!.isNotEmpty;
|
||||
if (!hasEmail && !hasPhone) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'CONTACT',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.8,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (hasEmail)
|
||||
_ContactRow(icon: Icons.mail_outline, label: team.managerEmail),
|
||||
if (hasEmail && hasPhone) const SizedBox(height: 6),
|
||||
if (hasPhone)
|
||||
_ContactRow(
|
||||
icon: Icons.phone_outlined,
|
||||
label: team.managerPhone!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactRow extends StatelessWidget {
|
||||
const _ContactRow({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatColumn extends StatelessWidget {
|
||||
const _StatColumn({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.small = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool small;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
(small
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.headlineSmall)
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerticalDivider extends StatelessWidget {
|
||||
const _VerticalDivider({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 36,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionDivider extends StatelessWidget {
|
||||
const _SectionDivider({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Divider(color: theme.colorScheme.outlineVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRoster extends StatelessWidget {
|
||||
const _EmptyRoster();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Text(
|
||||
'No players on the roster yet.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
import 'widgets/team_card.dart';
|
||||
|
||||
/// Top-level Teams tab. Renders a responsive grid of team cards on wider
|
||||
/// viewports and a single-column list on mobile.
|
||||
class TeamsScreen extends ConsumerWidget {
|
||||
const TeamsScreen({super.key});
|
||||
|
||||
/// Width at which the layout switches from list to 2-column grid.
|
||||
static const double _gridBreakpoint = 640;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final teamsAsync = ref.watch(teamsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Teams')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/teams/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('CREATE A TEAM'),
|
||||
),
|
||||
body: teamsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(teamsStreamProvider),
|
||||
),
|
||||
data: (teams) {
|
||||
if (teams.isEmpty) return const _EmptyState();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= _gridBreakpoint;
|
||||
return isWide
|
||||
? _TeamsGrid(teams: teams, maxWidth: constraints.maxWidth)
|
||||
: _TeamsList(teams: teams);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamsList extends StatelessWidget {
|
||||
const _TeamsList({required this.teams});
|
||||
|
||||
final List<Team> teams;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
itemCount: teams.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) => TeamCard(team: teams[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamsGrid extends StatelessWidget {
|
||||
const _TeamsGrid({required this.teams, required this.maxWidth});
|
||||
|
||||
final List<Team> teams;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Wider viewports get more columns: 2 up to ~1024, then 3.
|
||||
final crossAxisCount = maxWidth >= 1024 ? 3 : 2;
|
||||
// Slightly taller than wide so the top-scorer pill never crowds.
|
||||
const aspect = 1.55;
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: teams.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: aspect,
|
||||
),
|
||||
itemBuilder: (context, index) => TeamCard(team: teams[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@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.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(
|
||||
'Teams will appear here once rosters are submitted for an event.',
|
||||
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 teams', 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user