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>
539 lines
16 KiB
Dart
539 lines
16 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|