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,798 @@
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 'package:intl/intl.dart';
import '../../auth/application/auth_notifier.dart';
import '../../teams/application/teams_notifier.dart';
import '../../teams/domain/join_request.dart';
import '../../teams/domain/player.dart';
import '../../teams/domain/team.dart';
import '../../teams/infrastructure/teams_repository.dart';
import '../application/profile_notifier.dart';
import '../infrastructure/profile_repository.dart';
/// Dashboard for managers — their team's roster, stats inputs, and pending
/// join requests live here.
///
/// The route redirect guard in `app_router.dart` ensures only managers reach
/// this screen, so we don't re-check inside.
class ManagerDashboardScreen extends ConsumerWidget {
const ManagerDashboardScreen({super.key});
static const double _maxContentWidth = 760;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authNotifierProvider).valueOrNull;
final profileAsync = ref.watch(currentProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('MANAGER DASHBOARD'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/events'),
),
),
body: profileAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Could not load: $e')),
data: (profile) {
if (profile == null || user == null) {
return const Center(child: Text('Not signed in.'));
}
if (!profile.hasTeam) {
return _NoTeamYet(onCreate: () => context.go('/teams/new'));
}
final team = ref.watch(teamByIdProvider(profile.teamId!));
if (team == null) {
// We have the id but the team stream may be filtered (pending
// teams are excluded from the public feed). Fall back to a
// direct fetch.
return _ManagerForPendingTeam(teamId: profile.teamId!);
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: _DashboardBody(team: team),
),
);
},
),
);
}
}
class _NoTeamYet extends StatelessWidget {
const _NoTeamYet({required this.onCreate});
final VoidCallback onCreate;
@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: <Widget>[
Icon(
Icons.add_business_outlined,
size: 64,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'No team yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Create your team to start managing rosters, stats, and join '
'requests. Admins review new teams before they appear publicly.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add),
label: const Text('CREATE A TEAM'),
),
],
),
),
);
}
}
/// Loads the team document directly when the manager's team is pending and
/// therefore excluded from the public teams stream.
class _ManagerForPendingTeam extends ConsumerStatefulWidget {
const _ManagerForPendingTeam({required this.teamId});
final String teamId;
@override
ConsumerState<_ManagerForPendingTeam> createState() =>
_ManagerForPendingTeamState();
}
class _ManagerForPendingTeamState
extends ConsumerState<_ManagerForPendingTeam> {
Team? _team;
bool _loading = true;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final team = await ref.read(teamsRepositoryProvider).getTeam(
widget.teamId,
);
if (!mounted) return;
setState(() {
_team = team;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Text('Could not load team: $_error'));
}
if (_team == null) {
return const Center(child: Text('Team not found.'));
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: _DashboardBody(team: _team!),
),
);
}
}
class _DashboardBody extends ConsumerWidget {
const _DashboardBody({required this.team});
final Team team;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: <Widget>[
_TeamHeaderCard(team: team),
if (team.isPending) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.amber.withValues(alpha: 0.4),
),
),
child: Row(
children: <Widget>[
Icon(Icons.hourglass_bottom, color: Colors.amber.shade300),
const SizedBox(width: 10),
Expanded(
child: Text(
'Awaiting admin approval. The team will appear publicly '
'once approved.',
style: theme.textTheme.bodyMedium,
),
),
],
),
),
],
if (team.isRejected) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.error.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scheme.error.withValues(alpha: 0.4),
),
),
child: Row(
children: <Widget>[
Icon(Icons.block, color: scheme.error),
const SizedBox(width: 10),
Expanded(
child: Text(
'This team was rejected by an admin. Contact the league '
'for next steps.',
style: theme.textTheme.bodyMedium,
),
),
],
),
),
],
if (team.isApproved) ...[
const SizedBox(height: 24),
_SectionHeader(title: 'ROSTER (${team.players.length})'),
const SizedBox(height: 8),
if (team.players.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No players yet — approved join requests will appear here as '
'roster entries.',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
)
else
...team.players.map((p) => _RosterRow(team: team, player: p)),
const SizedBox(height: 24),
_SectionHeader(title: 'JOIN REQUESTS'),
const SizedBox(height: 8),
_JoinRequestsList(team: team),
],
],
);
}
}
class _TeamHeaderCard extends StatelessWidget {
const _TeamHeaderCard({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;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: <Widget>[
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: scheme.primaryContainer,
shape: BoxShape.circle,
),
child: Text(
initial.toUpperCase(),
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
fontSize: 26,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
team.name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Record ${team.record} - ${team.players.length} players',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
_TeamStatusChip(status: team.status),
],
),
),
);
}
}
class _TeamStatusChip extends StatelessWidget {
const _TeamStatusChip({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final (Color bg, Color fg, String label) = switch (status) {
TeamStatus.pending => (
Colors.amber.withValues(alpha: 0.18),
Colors.amber.shade300,
'PENDING',
),
TeamStatus.rejected => (
scheme.error.withValues(alpha: 0.18),
scheme.error,
'REJECTED',
),
_ => (
Colors.green.withValues(alpha: 0.18),
Colors.green.shade300,
'APPROVED',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: <Widget>[
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.4,
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 12),
Expanded(child: Divider(color: theme.colorScheme.outlineVariant)),
],
);
}
}
class _RosterRow extends ConsumerWidget {
const _RosterRow({required this.team, required this.player});
final Team team;
final Player player;
Future<void> _editStats(BuildContext context, WidgetRef ref) async {
final result = await showDialog<_StatEdit>(
context: context,
builder: (_) => _EditStatsDialog(player: player),
);
if (result == null) return;
if (!context.mounted) return;
final updatedPlayers = team.players
.map(
(p) => p.id == player.id
? p.copyWith(goalsScored: result.goals, assists: result.assists)
: p,
)
.toList(growable: false);
try {
await ref.read(teamsRepositoryProvider).updateTeam(
team.copyWith(players: updatedPlayers),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Updated stats for ${player.name}')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Update failed: $e')),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: scheme.primaryContainer,
child: Text(
player.name.isEmpty ? '?' : player.name.characters.first,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
title: Text(
player.name,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'Goals ${player.goalsScored} - Assists ${player.assists}'
'${player.position == null ? '' : ' - ${player.position}'}',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
trailing: const Icon(Icons.edit_outlined),
onTap: () => _editStats(context, ref),
),
);
}
}
class _StatEdit {
const _StatEdit({required this.goals, required this.assists});
final int goals;
final int assists;
}
class _EditStatsDialog extends StatefulWidget {
const _EditStatsDialog({required this.player});
final Player player;
@override
State<_EditStatsDialog> createState() => _EditStatsDialogState();
}
class _EditStatsDialogState extends State<_EditStatsDialog> {
late int _goals = widget.player.goalsScored;
late int _assists = widget.player.assists;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Edit ${widget.player.name}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_StatStepper(
label: 'Goals',
value: _goals,
onChanged: (v) => setState(() => _goals = v),
),
const SizedBox(height: 12),
_StatStepper(
label: 'Assists',
value: _assists,
onChanged: (v) => setState(() => _assists = v),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('CANCEL'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(
_StatEdit(goals: _goals, assists: _assists),
),
child: const Text('SAVE'),
),
],
);
}
}
class _StatStepper extends StatelessWidget {
const _StatStepper({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final int value;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Row(
children: <Widget>[
SizedBox(
width: 80,
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
letterSpacing: 1.0,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline),
color: scheme.error,
onPressed: value <= 0 ? null : () => onChanged(value - 1),
),
Container(
width: 56,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$value',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
color: scheme.primary,
onPressed: () => onChanged(value + 1),
),
const Spacer(),
SizedBox(
width: 56,
child: TextFormField(
initialValue: '$value',
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(isDense: true),
onChanged: (raw) {
final v = int.tryParse(raw);
if (v != null && v >= 0) onChanged(v);
},
),
),
],
);
}
}
class _JoinRequestsList extends ConsumerWidget {
const _JoinRequestsList({required this.team});
final Team team;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final async = ref.watch(joinRequestsForTeamProvider(team.id));
return async.when(
loading: () =>
const Padding(padding: EdgeInsets.all(16), child: LinearProgressIndicator()),
error: (e, _) => Text('Could not load requests: $e'),
data: (requests) {
final pending = requests
.where((r) => r.status == JoinRequestStatus.pending)
.toList();
if (pending.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No pending requests.',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
);
}
return Column(
children: pending
.map((r) => _RequestRow(team: team, request: r))
.toList(),
);
},
);
}
}
class _RequestRow extends ConsumerStatefulWidget {
const _RequestRow({required this.team, required this.request});
final Team team;
final JoinRequest request;
@override
ConsumerState<_RequestRow> createState() => _RequestRowState();
}
class _RequestRowState extends ConsumerState<_RequestRow> {
bool _busy = false;
Future<void> _act({required bool approve}) async {
setState(() => _busy = true);
final repo = ref.read(teamsRepositoryProvider);
final profileRepo = ref.read(profileRepositoryProvider);
try {
if (approve) {
// Mark the request approved.
await repo.updateJoinRequestStatus(
widget.request.id,
JoinRequestStatus.approved.name,
);
// Stamp the team on the player's profile.
await profileRepo.updateTeamId(
widget.request.playerId,
widget.team.id,
);
// Add the player to the team roster (if not already there).
final alreadyOnRoster = widget.team.players.any(
(p) => p.id == widget.request.playerId,
);
if (!alreadyOnRoster) {
final updated = <Player>[
...widget.team.players,
Player(
id: widget.request.playerId,
name: widget.request.playerName,
),
];
await repo.updateTeam(widget.team.copyWith(players: updated));
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${widget.request.playerName} approved')),
);
} else {
await repo.updateJoinRequestStatus(
widget.request.id,
JoinRequestStatus.rejected.name,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${widget.request.playerName} rejected')),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Action failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final date = DateFormat.yMMMd().format(widget.request.requestedAt);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
CircleAvatar(
backgroundColor: scheme.primaryContainer,
child: Text(
widget.request.playerName.isEmpty
? '?'
: widget.request.playerName.characters.first,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.request.playerName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
widget.request.playerEmail,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
Text(
date,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _act(approve: false),
icon: const Icon(Icons.close, size: 18),
label: const Text('REJECT'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _act(approve: true),
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check, size: 18),
label: const Text('APPROVE'),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,538 @@
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 '../../teams/application/teams_notifier.dart';
import '../application/profile_notifier.dart';
import '../domain/user_profile.dart';
import '../infrastructure/profile_repository.dart';
import 'widgets/role_chip.dart';
/// Editable profile screen for the signed-in user.
///
/// Reads from [currentProfileProvider] and writes back through
/// [profileRepositoryProvider]. Position is a fixed list of four options
/// plus an "unspecified" sentinel so the UI matches the data model.
class MyProfileScreen extends ConsumerStatefulWidget {
const MyProfileScreen({super.key});
static const double _maxContentWidth = 760;
/// Mirrors the values surfaced in the dropdown — `null` means
/// "no position selected" and round-trips as a null Firestore field.
static const List<String> positions = <String>[
'Forward',
'Midfielder',
'Defender',
'Goalkeeper',
];
@override
ConsumerState<MyProfileScreen> createState() => _MyProfileScreenState();
}
class _MyProfileScreenState extends ConsumerState<MyProfileScreen> {
final _bioCtrl = TextEditingController();
final _photoUrlCtrl = TextEditingController();
String? _position;
bool _editing = false;
bool _saving = false;
String? _hydratedForUid;
@override
void dispose() {
_bioCtrl.dispose();
_photoUrlCtrl.dispose();
super.dispose();
}
void _hydrate(UserProfile profile) {
if (_hydratedForUid == profile.uid) return;
_bioCtrl.text = profile.bio;
_photoUrlCtrl.text = profile.photoUrl ?? '';
_position = profile.position;
_hydratedForUid = profile.uid;
}
Future<void> _save(UserProfile current) async {
setState(() => _saving = true);
try {
final updated = current.copyWith(
bio: _bioCtrl.text.trim(),
photoUrl: _photoUrlCtrl.text.trim().isEmpty
? null
: _photoUrlCtrl.text.trim(),
position: _position,
);
await ref.read(profileRepositoryProvider).updateProfile(updated);
if (!mounted) return;
setState(() => _editing = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile saved')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Save failed: $e')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authNotifierProvider).valueOrNull;
final async = ref.watch(currentProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('MY PROFILE'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/events'),
),
actions: <Widget>[
if (!_editing)
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit profile',
onPressed: () => setState(() => _editing = true),
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ProfileError(message: e.toString()),
data: (profile) {
if (profile == null) {
return _ProfileMissing(email: user?.email ?? '');
}
_hydrate(profile);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: MyProfileScreen._maxContentWidth,
),
child: _MyProfileBody(
profile: profile,
editing: _editing,
saving: _saving,
bioCtrl: _bioCtrl,
photoUrlCtrl: _photoUrlCtrl,
position: _position,
onPositionChanged: (v) => setState(() => _position = v),
onCancel: () {
setState(() {
_editing = false;
// Re-hydrate so any edited fields are discarded.
_bioCtrl.text = profile.bio;
_photoUrlCtrl.text = profile.photoUrl ?? '';
_position = profile.position;
});
},
onSave: () => _save(profile),
),
),
);
},
),
);
}
}
class _MyProfileBody extends ConsumerWidget {
const _MyProfileBody({
required this.profile,
required this.editing,
required this.saving,
required this.bioCtrl,
required this.photoUrlCtrl,
required this.position,
required this.onPositionChanged,
required this.onCancel,
required this.onSave,
});
final UserProfile profile;
final bool editing;
final bool saving;
final TextEditingController bioCtrl;
final TextEditingController photoUrlCtrl;
final String? position;
final ValueChanged<String?> onPositionChanged;
final VoidCallback onCancel;
final VoidCallback onSave;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final hasPhoto = profile.photoUrl != null && profile.photoUrl!.isNotEmpty;
final initial = profile.displayName.isEmpty
? (profile.email.isEmpty ? '?' : profile.email.characters.first)
: profile.displayName.characters.first;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
children: <Widget>[
Center(
child: Container(
width: 112,
height: 112,
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: hasPhoto
? CircleAvatar(
radius: 56,
backgroundColor: scheme.primaryContainer,
backgroundImage: NetworkImage(profile.photoUrl!),
)
: Text(
initial.toUpperCase(),
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
fontSize: 52,
),
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
profile.displayName.isEmpty ? 'Unnamed' : profile.displayName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 4),
Center(
child: Text(
profile.email,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 12),
Center(child: RoleChip(role: profile.role)),
const SizedBox(height: 24),
_TeamMembershipCard(profile: profile),
const SizedBox(height: 24),
Text(
'POSITION',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
if (editing)
DropdownButtonFormField<String?>(
value: position,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.sports_outlined),
),
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text(''),
),
...MyProfileScreen.positions.map(
(p) => DropdownMenuItem<String?>(value: p, child: Text(p)),
),
],
onChanged: saving ? null : onPositionChanged,
)
else
_ReadOnlyField(
icon: Icons.sports_outlined,
value: position == null || position!.isEmpty ? '' : position!,
),
const SizedBox(height: 20),
Text(
'BIO',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
if (editing)
TextField(
controller: bioCtrl,
enabled: !saving,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
hintText: 'A few words about your game...',
),
)
else
_ReadOnlyField(
icon: Icons.notes_outlined,
value: profile.bio.isEmpty ? '' : profile.bio,
multiline: true,
),
const SizedBox(height: 20),
Text(
'PHOTO URL',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
if (editing)
TextField(
controller: photoUrlCtrl,
enabled: !saving,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.image_outlined),
hintText: 'https://...',
),
)
else
_ReadOnlyField(
icon: Icons.image_outlined,
value: (profile.photoUrl ?? '').isEmpty
? ''
: profile.photoUrl!,
),
const SizedBox(height: 28),
if (editing)
Row(
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: saving ? null : onCancel,
child: const Text('CANCEL'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: saving ? null : onSave,
child: saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('SAVE'),
),
),
],
),
],
);
}
}
class _TeamMembershipCard extends ConsumerWidget {
const _TeamMembershipCard({required this.profile});
final UserProfile profile;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
if (!profile.hasTeam && profile.role != UserRole.manager) {
return const SizedBox.shrink();
}
final team = profile.hasTeam
? ref.watch(teamByIdProvider(profile.teamId!))
: null;
final isManager = profile.role == UserRole.manager;
String label;
if (team != null) {
label = team.name;
} else if (profile.hasTeam) {
label = 'Loading team...';
} else {
label = 'No team yet';
}
return Card(
child: ListTile(
leading: Icon(
isManager ? Icons.shield_outlined : Icons.groups_outlined,
color: scheme.primary,
),
title: Text(
isManager ? 'YOUR TEAM' : 'TEAM MEMBERSHIP',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
label,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
trailing: profile.hasTeam
? const Icon(Icons.chevron_right)
: (isManager
? TextButton(
onPressed: () => context.go('/teams/new'),
child: const Text('CREATE'),
)
: null),
onTap: profile.hasTeam
? () => context.go('/teams/${profile.teamId}')
: null,
),
);
}
}
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({
required this.icon,
required this.value,
this.multiline = false,
});
final IconData icon;
final String value;
final bool multiline;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: scheme.outlineVariant),
),
child: Row(
crossAxisAlignment: multiline
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 18, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
),
maxLines: multiline ? null : 1,
overflow: multiline ? null : TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _ProfileMissing extends StatelessWidget {
const _ProfileMissing({required this.email});
final String email;
@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: <Widget>[
Icon(
Icons.person_off_outlined,
size: 56,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No profile yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
email.isEmpty
? 'Sign back in to finish setting up your profile.'
: 'A profile for $email has not been created.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _ProfileError extends StatelessWidget {
const _ProfileError({required this.message});
final String message;
@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: <Widget>[
Icon(
Icons.error_outline,
size: 56,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text('Could not load profile',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../teams/application/teams_notifier.dart';
import '../application/profile_notifier.dart';
import '../domain/user_profile.dart';
import 'widgets/role_chip.dart';
/// Public, read-only profile page for any player or manager.
///
/// Anyone (including signed-out viewers) can land here from a team roster or
/// shared link.
class PlayerProfileScreen extends ConsumerWidget {
const PlayerProfileScreen({super.key, required this.uid});
final String uid;
static const double _maxContentWidth = 760;
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(profileByIdProvider(uid));
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/teams'),
),
title: const Text('PLAYER'),
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Could not load: $e')),
data: (profile) {
if (profile == null) {
return const Center(child: Text('Player not found.'));
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: _Body(profile: profile),
),
);
},
),
);
}
}
class _Body extends ConsumerWidget {
const _Body({required this.profile});
final UserProfile profile;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final hasPhoto = profile.photoUrl != null && profile.photoUrl!.isNotEmpty;
final initial = profile.displayName.isEmpty
? '?'
: profile.displayName.characters.first;
final team = profile.hasTeam
? ref.watch(teamByIdProvider(profile.teamId!))
: null;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
children: <Widget>[
Center(
child: Container(
width: 112,
height: 112,
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: hasPhoto
? CircleAvatar(
radius: 56,
backgroundColor: scheme.primaryContainer,
backgroundImage: NetworkImage(profile.photoUrl!),
)
: Text(
initial.toUpperCase(),
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
fontSize: 52,
),
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
profile.displayName.isEmpty ? 'Unnamed' : profile.displayName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 8),
Center(child: RoleChip(role: profile.role)),
if (profile.position != null && profile.position!.isNotEmpty) ...[
const SizedBox(height: 12),
Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
profile.position!.toUpperCase(),
style: theme.textTheme.labelMedium?.copyWith(
color: scheme.onSurface,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
),
),
],
const SizedBox(height: 24),
if (team != null)
Card(
child: ListTile(
leading: Icon(Icons.groups_outlined, color: scheme.primary),
title: Text(
'TEAM',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
team.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/teams/${team.id}'),
),
),
if (profile.bio.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'BIO',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: scheme.outlineVariant),
),
child: Text(
profile.bio,
style: theme.textTheme.bodyMedium,
),
),
],
],
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../../domain/user_profile.dart';
/// Small color-coded label that names the user's role. Used in the profile
/// header so the role is glanceable on phone widths.
class RoleChip extends StatelessWidget {
const RoleChip({super.key, required this.role});
final UserRole role;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final (Color background, Color foreground, IconData icon, String label) =
switch (role) {
UserRole.admin => (
scheme.primary.withValues(alpha: 0.18),
scheme.primary,
Icons.verified_user_outlined,
'ADMIN',
),
UserRole.manager => (
Colors.amber.withValues(alpha: 0.18),
Colors.amber.shade300,
Icons.shield_outlined,
'MANAGER',
),
UserRole.player => (
Colors.green.withValues(alpha: 0.18),
Colors.green.shade300,
Icons.sports_soccer,
'PLAYER',
),
UserRole.viewer => (
scheme.surfaceContainerHighest,
scheme.onSurfaceVariant,
Icons.visibility_outlined,
'VIEWER',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, size: 14, color: foreground),
const SizedBox(width: 6),
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: foreground,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
),
],
),
);
}
}