b239ae3e5f
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>
341 lines
11 KiB
Dart
341 lines
11 KiB
Dart
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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|