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