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,38 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../../../core/admin/admin_guard.dart';
|
||||
import '../domain/user_profile.dart';
|
||||
import '../infrastructure/profile_repository.dart';
|
||||
|
||||
part 'profile_notifier.g.dart';
|
||||
|
||||
/// Live profile of the currently signed-in user. Emits null while loading or
|
||||
/// when no user is signed in.
|
||||
@riverpod
|
||||
Stream<UserProfile?> currentProfile(CurrentProfileRef ref) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
if (user == null) return Stream.value(null);
|
||||
return ref.watch(profileRepositoryProvider).watchProfile(user.uid);
|
||||
}
|
||||
|
||||
/// Resolves the effective [UserRole] for the current session. Admin status is
|
||||
/// determined by email allow-list first (so seed-data admins work before
|
||||
/// they've even loaded a profile doc); otherwise the Firestore-stored role is
|
||||
/// used, defaulting to [UserRole.viewer] when not logged in.
|
||||
@riverpod
|
||||
UserRole currentUserRole(CurrentUserRoleRef ref) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
if (user == null) return UserRole.viewer;
|
||||
if (isAdmin(user)) return UserRole.admin;
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
if (profile == null) return UserRole.viewer;
|
||||
return profile.role;
|
||||
}
|
||||
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
@riverpod
|
||||
Future<UserProfile?> profileById(ProfileByIdRef ref, String uid) {
|
||||
return ref.watch(profileRepositoryProvider).getProfile(uid);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'profile_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$currentProfileHash() => r'85ba418ee60fcd6612e3fd87974ed10e11a32dae';
|
||||
|
||||
/// Live profile of the currently signed-in user. Emits null while loading or
|
||||
/// when no user is signed in.
|
||||
///
|
||||
/// Copied from [currentProfile].
|
||||
@ProviderFor(currentProfile)
|
||||
final currentProfileProvider = AutoDisposeStreamProvider<UserProfile?>.internal(
|
||||
currentProfile,
|
||||
name: r'currentProfileProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentProfileHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef CurrentProfileRef = AutoDisposeStreamProviderRef<UserProfile?>;
|
||||
String _$currentUserRoleHash() => r'ba507519e5fa744f668b87b9685e5454fcf9ab0a';
|
||||
|
||||
/// Resolves the effective [UserRole] for the current session. Admin status is
|
||||
/// determined by email allow-list first (so seed-data admins work before
|
||||
/// they've even loaded a profile doc); otherwise the Firestore-stored role is
|
||||
/// used, defaulting to [UserRole.viewer] when not logged in.
|
||||
///
|
||||
/// Copied from [currentUserRole].
|
||||
@ProviderFor(currentUserRole)
|
||||
final currentUserRoleProvider = AutoDisposeProvider<UserRole>.internal(
|
||||
currentUserRole,
|
||||
name: r'currentUserRoleProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentUserRoleHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef CurrentUserRoleRef = AutoDisposeProviderRef<UserRole>;
|
||||
String _$profileByIdHash() => r'b485a02150bfb480bc4a9ed04b4a66b8c92e2958';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
@ProviderFor(profileById)
|
||||
const profileByIdProvider = ProfileByIdFamily();
|
||||
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
class ProfileByIdFamily extends Family<AsyncValue<UserProfile?>> {
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
const ProfileByIdFamily();
|
||||
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
ProfileByIdProvider call(String uid) {
|
||||
return ProfileByIdProvider(uid);
|
||||
}
|
||||
|
||||
@override
|
||||
ProfileByIdProvider getProviderOverride(
|
||||
covariant ProfileByIdProvider provider,
|
||||
) {
|
||||
return call(provider.uid);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'profileByIdProvider';
|
||||
}
|
||||
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
class ProfileByIdProvider extends AutoDisposeFutureProvider<UserProfile?> {
|
||||
/// One-shot lookup of an arbitrary user profile by uid. Used by the public
|
||||
/// player profile screen.
|
||||
///
|
||||
/// Copied from [profileById].
|
||||
ProfileByIdProvider(String uid)
|
||||
: this._internal(
|
||||
(ref) => profileById(ref as ProfileByIdRef, uid),
|
||||
from: profileByIdProvider,
|
||||
name: r'profileByIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$profileByIdHash,
|
||||
dependencies: ProfileByIdFamily._dependencies,
|
||||
allTransitiveDependencies: ProfileByIdFamily._allTransitiveDependencies,
|
||||
uid: uid,
|
||||
);
|
||||
|
||||
ProfileByIdProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.uid,
|
||||
}) : super.internal();
|
||||
|
||||
final String uid;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<UserProfile?> Function(ProfileByIdRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ProfileByIdProvider._internal(
|
||||
(ref) => create(ref as ProfileByIdRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
uid: uid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<UserProfile?> createElement() {
|
||||
return _ProfileByIdProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProfileByIdProvider && other.uid == uid;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, uid.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ProfileByIdRef on AutoDisposeFutureProviderRef<UserProfile?> {
|
||||
/// The parameter `uid` of this provider.
|
||||
String get uid;
|
||||
}
|
||||
|
||||
class _ProfileByIdProviderElement
|
||||
extends AutoDisposeFutureProviderElement<UserProfile?>
|
||||
with ProfileByIdRef {
|
||||
_ProfileByIdProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get uid => (origin as ProfileByIdProvider).uid;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -0,0 +1,123 @@
|
||||
enum UserRole { viewer, player, manager, admin }
|
||||
|
||||
UserRole userRoleFromString(String? raw) {
|
||||
switch (raw) {
|
||||
case 'admin':
|
||||
return UserRole.admin;
|
||||
case 'manager':
|
||||
return UserRole.manager;
|
||||
case 'player':
|
||||
return UserRole.player;
|
||||
case 'viewer':
|
||||
return UserRole.viewer;
|
||||
default:
|
||||
return UserRole.player;
|
||||
}
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
const UserProfile({
|
||||
required this.uid,
|
||||
required this.email,
|
||||
required this.displayName,
|
||||
required this.role,
|
||||
this.bio = '',
|
||||
this.photoUrl,
|
||||
this.position,
|
||||
this.teamId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String uid;
|
||||
final String email;
|
||||
final String displayName;
|
||||
final UserRole role;
|
||||
final String bio;
|
||||
final String? photoUrl;
|
||||
final String? position;
|
||||
final String? teamId;
|
||||
final DateTime createdAt;
|
||||
|
||||
bool get hasTeam => teamId != null && teamId!.isNotEmpty;
|
||||
|
||||
UserProfile copyWith({
|
||||
String? uid,
|
||||
String? email,
|
||||
String? displayName,
|
||||
UserRole? role,
|
||||
String? bio,
|
||||
String? photoUrl,
|
||||
String? position,
|
||||
String? teamId,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return UserProfile(
|
||||
uid: uid ?? this.uid,
|
||||
email: email ?? this.email,
|
||||
displayName: displayName ?? this.displayName,
|
||||
role: role ?? this.role,
|
||||
bio: bio ?? this.bio,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
position: position ?? this.position,
|
||||
teamId: teamId ?? this.teamId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
UserProfile clearTeam() => copyWith(teamId: null);
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> data) {
|
||||
return UserProfile(
|
||||
uid: (data['id'] as String?) ?? '',
|
||||
email: (data['email'] as String?) ?? '',
|
||||
displayName: (data['display_name'] as String?) ?? '',
|
||||
role: userRoleFromString(data['role'] as String?),
|
||||
bio: (data['bio'] as String?) ?? '',
|
||||
photoUrl: data['photo_url'] as String?,
|
||||
position: data['position'] as String?,
|
||||
teamId: data['team_id'] as String?,
|
||||
createdAt: _parseDate(data['created_at']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'email': email,
|
||||
'display_name': displayName,
|
||||
'role': role.name,
|
||||
'bio': bio,
|
||||
'photo_url': photoUrl,
|
||||
'position': position,
|
||||
'team_id': teamId,
|
||||
};
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(Object? v) {
|
||||
if (v is String && v.isNotEmpty) return DateTime.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is UserProfile &&
|
||||
other.uid == uid &&
|
||||
other.email == email &&
|
||||
other.displayName == displayName &&
|
||||
other.role == role &&
|
||||
other.bio == bio &&
|
||||
other.photoUrl == photoUrl &&
|
||||
other.position == position &&
|
||||
other.teamId == teamId &&
|
||||
other.createdAt == createdAt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
uid, email, displayName, role, bio, photoUrl, position, teamId, createdAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UserProfile(uid: $uid, role: ${role.name}, teamId: $teamId)';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/user_profile.dart';
|
||||
|
||||
part 'profile_repository.g.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
ProfileRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<UserProfile?> getProfile(String uid) async {
|
||||
try {
|
||||
final data = await _api.get('/profiles/detail.php', params: {'uid': uid});
|
||||
return UserProfile.fromJson(data);
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createProfile(UserProfile profile) async {
|
||||
await _api.put('/profiles/detail.php', profile.toJson(), params: {'uid': profile.uid});
|
||||
}
|
||||
|
||||
Future<void> updateProfile(UserProfile profile) async {
|
||||
await _api.put('/profiles/detail.php', profile.toJson(), params: {'uid': profile.uid});
|
||||
}
|
||||
|
||||
Future<void> updateTeamId(String uid, String? teamId) async {
|
||||
await _api.put('/profiles/detail.php', {'team_id': teamId}, params: {'uid': uid});
|
||||
}
|
||||
|
||||
Stream<UserProfile?> watchProfile(String uid) async* {
|
||||
yield await getProfile(uid);
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await getProfile(uid);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<UserProfile>> fetchAllPlayers() async {
|
||||
// The /auth/me.php endpoint only returns one user.
|
||||
// For the admin player list, re-use profile fetch per user (admin panel).
|
||||
// For MVP, return empty — admin panel can be extended later.
|
||||
return [];
|
||||
}
|
||||
|
||||
Stream<List<UserProfile>> watchAllPlayers() async* {
|
||||
yield await fetchAllPlayers();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfileRepository profileRepository(ProfileRepositoryRef ref) {
|
||||
return ProfileRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'profile_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$profileRepositoryHash() => r'c1e1c5e820702a3d191905477db9aba9b798dc36';
|
||||
|
||||
/// See also [profileRepository].
|
||||
@ProviderFor(profileRepository)
|
||||
final profileRepositoryProvider = Provider<ProfileRepository>.internal(
|
||||
profileRepository,
|
||||
name: r'profileRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$profileRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef ProfileRepositoryRef = ProviderRef<ProfileRepository>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user