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