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,40 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../domain/join_request.dart';
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
|
||||
part 'teams_notifier.g.dart';
|
||||
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
@riverpod
|
||||
Team? teamById(TeamByIdRef ref, String id) {
|
||||
final teams = ref.watch(teamsStreamProvider).valueOrNull;
|
||||
if (teams == null) return null;
|
||||
for (final team in teams) {
|
||||
if (team.id == id) return team;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
@riverpod
|
||||
Stream<List<JoinRequest>> joinRequestsForTeam(
|
||||
JoinRequestsForTeamRef ref,
|
||||
String teamId,
|
||||
) {
|
||||
return ref.watch(teamsRepositoryProvider).watchJoinRequestsForTeam(teamId);
|
||||
}
|
||||
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
@riverpod
|
||||
Stream<List<JoinRequest>> joinRequestsForPlayer(
|
||||
JoinRequestsForPlayerRef ref,
|
||||
String playerId,
|
||||
) {
|
||||
return ref
|
||||
.watch(teamsRepositoryProvider)
|
||||
.watchJoinRequestsForPlayer(playerId);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'teams_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$teamByIdHash() => r'321ea04a62f6a3e9788f820c36d7d6bea6bc968f';
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
@ProviderFor(teamById)
|
||||
const teamByIdProvider = TeamByIdFamily();
|
||||
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
class TeamByIdFamily extends Family<Team?> {
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
const TeamByIdFamily();
|
||||
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
TeamByIdProvider call(String id) {
|
||||
return TeamByIdProvider(id);
|
||||
}
|
||||
|
||||
@override
|
||||
TeamByIdProvider getProviderOverride(covariant TeamByIdProvider provider) {
|
||||
return call(provider.id);
|
||||
}
|
||||
|
||||
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'teamByIdProvider';
|
||||
}
|
||||
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
class TeamByIdProvider extends AutoDisposeProvider<Team?> {
|
||||
/// Resolves a single [Team] by id out of the teams stream. Returns null while
|
||||
/// loading or if no team matches.
|
||||
///
|
||||
/// Copied from [teamById].
|
||||
TeamByIdProvider(String id)
|
||||
: this._internal(
|
||||
(ref) => teamById(ref as TeamByIdRef, id),
|
||||
from: teamByIdProvider,
|
||||
name: r'teamByIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$teamByIdHash,
|
||||
dependencies: TeamByIdFamily._dependencies,
|
||||
allTransitiveDependencies: TeamByIdFamily._allTransitiveDependencies,
|
||||
id: id,
|
||||
);
|
||||
|
||||
TeamByIdProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.id,
|
||||
}) : super.internal();
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Override overrideWith(Team? Function(TeamByIdRef provider) create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: TeamByIdProvider._internal(
|
||||
(ref) => create(ref as TeamByIdRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
id: id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<Team?> createElement() {
|
||||
return _TeamByIdProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is TeamByIdProvider && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, id.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin TeamByIdRef on AutoDisposeProviderRef<Team?> {
|
||||
/// The parameter `id` of this provider.
|
||||
String get id;
|
||||
}
|
||||
|
||||
class _TeamByIdProviderElement extends AutoDisposeProviderElement<Team?>
|
||||
with TeamByIdRef {
|
||||
_TeamByIdProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get id => (origin as TeamByIdProvider).id;
|
||||
}
|
||||
|
||||
String _$joinRequestsForTeamHash() =>
|
||||
r'fd951881199d04c8ca5a7be49aef3bb3faccb76d';
|
||||
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
@ProviderFor(joinRequestsForTeam)
|
||||
const joinRequestsForTeamProvider = JoinRequestsForTeamFamily();
|
||||
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
class JoinRequestsForTeamFamily extends Family<AsyncValue<List<JoinRequest>>> {
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
const JoinRequestsForTeamFamily();
|
||||
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
JoinRequestsForTeamProvider call(String teamId) {
|
||||
return JoinRequestsForTeamProvider(teamId);
|
||||
}
|
||||
|
||||
@override
|
||||
JoinRequestsForTeamProvider getProviderOverride(
|
||||
covariant JoinRequestsForTeamProvider provider,
|
||||
) {
|
||||
return call(provider.teamId);
|
||||
}
|
||||
|
||||
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'joinRequestsForTeamProvider';
|
||||
}
|
||||
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
class JoinRequestsForTeamProvider
|
||||
extends AutoDisposeStreamProvider<List<JoinRequest>> {
|
||||
/// Streams every join request for [teamId]. Used by the manager dashboard.
|
||||
///
|
||||
/// Copied from [joinRequestsForTeam].
|
||||
JoinRequestsForTeamProvider(String teamId)
|
||||
: this._internal(
|
||||
(ref) => joinRequestsForTeam(ref as JoinRequestsForTeamRef, teamId),
|
||||
from: joinRequestsForTeamProvider,
|
||||
name: r'joinRequestsForTeamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$joinRequestsForTeamHash,
|
||||
dependencies: JoinRequestsForTeamFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
JoinRequestsForTeamFamily._allTransitiveDependencies,
|
||||
teamId: teamId,
|
||||
);
|
||||
|
||||
JoinRequestsForTeamProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.teamId,
|
||||
}) : super.internal();
|
||||
|
||||
final String teamId;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
Stream<List<JoinRequest>> Function(JoinRequestsForTeamRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: JoinRequestsForTeamProvider._internal(
|
||||
(ref) => create(ref as JoinRequestsForTeamRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
teamId: teamId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeStreamProviderElement<List<JoinRequest>> createElement() {
|
||||
return _JoinRequestsForTeamProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is JoinRequestsForTeamProvider && other.teamId == teamId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, teamId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin JoinRequestsForTeamRef
|
||||
on AutoDisposeStreamProviderRef<List<JoinRequest>> {
|
||||
/// The parameter `teamId` of this provider.
|
||||
String get teamId;
|
||||
}
|
||||
|
||||
class _JoinRequestsForTeamProviderElement
|
||||
extends AutoDisposeStreamProviderElement<List<JoinRequest>>
|
||||
with JoinRequestsForTeamRef {
|
||||
_JoinRequestsForTeamProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get teamId => (origin as JoinRequestsForTeamProvider).teamId;
|
||||
}
|
||||
|
||||
String _$joinRequestsForPlayerHash() =>
|
||||
r'47ea047439ef88b65daee31c4e108ed6a805adf6';
|
||||
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
@ProviderFor(joinRequestsForPlayer)
|
||||
const joinRequestsForPlayerProvider = JoinRequestsForPlayerFamily();
|
||||
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
class JoinRequestsForPlayerFamily
|
||||
extends Family<AsyncValue<List<JoinRequest>>> {
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
const JoinRequestsForPlayerFamily();
|
||||
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
JoinRequestsForPlayerProvider call(String playerId) {
|
||||
return JoinRequestsForPlayerProvider(playerId);
|
||||
}
|
||||
|
||||
@override
|
||||
JoinRequestsForPlayerProvider getProviderOverride(
|
||||
covariant JoinRequestsForPlayerProvider provider,
|
||||
) {
|
||||
return call(provider.playerId);
|
||||
}
|
||||
|
||||
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'joinRequestsForPlayerProvider';
|
||||
}
|
||||
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
class JoinRequestsForPlayerProvider
|
||||
extends AutoDisposeStreamProvider<List<JoinRequest>> {
|
||||
/// Streams every join request submitted by [playerId]. Used to decide
|
||||
/// whether to show "Request pending" on a team detail page.
|
||||
///
|
||||
/// Copied from [joinRequestsForPlayer].
|
||||
JoinRequestsForPlayerProvider(String playerId)
|
||||
: this._internal(
|
||||
(ref) =>
|
||||
joinRequestsForPlayer(ref as JoinRequestsForPlayerRef, playerId),
|
||||
from: joinRequestsForPlayerProvider,
|
||||
name: r'joinRequestsForPlayerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$joinRequestsForPlayerHash,
|
||||
dependencies: JoinRequestsForPlayerFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
JoinRequestsForPlayerFamily._allTransitiveDependencies,
|
||||
playerId: playerId,
|
||||
);
|
||||
|
||||
JoinRequestsForPlayerProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.playerId,
|
||||
}) : super.internal();
|
||||
|
||||
final String playerId;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
Stream<List<JoinRequest>> Function(JoinRequestsForPlayerRef provider)
|
||||
create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: JoinRequestsForPlayerProvider._internal(
|
||||
(ref) => create(ref as JoinRequestsForPlayerRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
playerId: playerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeStreamProviderElement<List<JoinRequest>> createElement() {
|
||||
return _JoinRequestsForPlayerProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is JoinRequestsForPlayerProvider && other.playerId == playerId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, playerId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin JoinRequestsForPlayerRef
|
||||
on AutoDisposeStreamProviderRef<List<JoinRequest>> {
|
||||
/// The parameter `playerId` of this provider.
|
||||
String get playerId;
|
||||
}
|
||||
|
||||
class _JoinRequestsForPlayerProviderElement
|
||||
extends AutoDisposeStreamProviderElement<List<JoinRequest>>
|
||||
with JoinRequestsForPlayerRef {
|
||||
_JoinRequestsForPlayerProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get playerId => (origin as JoinRequestsForPlayerProvider).playerId;
|
||||
}
|
||||
|
||||
// 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,111 @@
|
||||
enum JoinRequestStatus { pending, approved, rejected }
|
||||
|
||||
JoinRequestStatus joinRequestStatusFromString(String? raw) {
|
||||
switch (raw) {
|
||||
case 'approved':
|
||||
return JoinRequestStatus.approved;
|
||||
case 'rejected':
|
||||
return JoinRequestStatus.rejected;
|
||||
default:
|
||||
return JoinRequestStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
class JoinRequest {
|
||||
const JoinRequest({
|
||||
required this.id,
|
||||
required this.teamId,
|
||||
required this.teamName,
|
||||
required this.playerId,
|
||||
required this.playerName,
|
||||
required this.playerEmail,
|
||||
required this.status,
|
||||
required this.requestedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String teamId;
|
||||
final String teamName;
|
||||
final String playerId;
|
||||
final String playerName;
|
||||
final String playerEmail;
|
||||
final JoinRequestStatus status;
|
||||
final DateTime requestedAt;
|
||||
|
||||
JoinRequest copyWith({
|
||||
String? id,
|
||||
String? teamId,
|
||||
String? teamName,
|
||||
String? playerId,
|
||||
String? playerName,
|
||||
String? playerEmail,
|
||||
JoinRequestStatus? status,
|
||||
DateTime? requestedAt,
|
||||
}) {
|
||||
return JoinRequest(
|
||||
id: id ?? this.id,
|
||||
teamId: teamId ?? this.teamId,
|
||||
teamName: teamName ?? this.teamName,
|
||||
playerId: playerId ?? this.playerId,
|
||||
playerName: playerName ?? this.playerName,
|
||||
playerEmail: playerEmail ?? this.playerEmail,
|
||||
status: status ?? this.status,
|
||||
requestedAt: requestedAt ?? this.requestedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory JoinRequest.fromJson(Map<String, dynamic> data) {
|
||||
return JoinRequest(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
teamId: (data['team_id'] as String?) ?? '',
|
||||
teamName: (data['team_name'] as String?) ?? '',
|
||||
playerId: (data['player_id'] as String?) ?? '',
|
||||
playerName: (data['player_name'] as String?) ?? '',
|
||||
playerEmail: (data['player_email'] as String?) ?? '',
|
||||
status: joinRequestStatusFromString(data['status'] as String?),
|
||||
requestedAt: _parseDate(data['requested_at']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'team_id': teamId,
|
||||
'team_name': teamName,
|
||||
'player_id': playerId,
|
||||
'player_name': playerName,
|
||||
'player_email': playerEmail,
|
||||
'status': status.name,
|
||||
'requested_at': requestedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
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 JoinRequest &&
|
||||
other.id == id &&
|
||||
other.teamId == teamId &&
|
||||
other.teamName == teamName &&
|
||||
other.playerId == playerId &&
|
||||
other.playerName == playerName &&
|
||||
other.playerEmail == playerEmail &&
|
||||
other.status == status &&
|
||||
other.requestedAt == requestedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, teamId, teamName, playerId,
|
||||
playerName, playerEmail, status, requestedAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'JoinRequest(id: $id, team: $teamName, player: $playerName, '
|
||||
'status: ${status.name})';
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
class Player {
|
||||
const Player({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.position,
|
||||
this.avatarUrl,
|
||||
this.jerseyNumber,
|
||||
this.goalsScored = 0,
|
||||
this.assists = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? position;
|
||||
final String? avatarUrl;
|
||||
final int? jerseyNumber;
|
||||
final int goalsScored;
|
||||
final int assists;
|
||||
|
||||
Player copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? position,
|
||||
String? avatarUrl,
|
||||
int? jerseyNumber,
|
||||
int? goalsScored,
|
||||
int? assists,
|
||||
}) {
|
||||
return Player(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
position: position ?? this.position,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
jerseyNumber: jerseyNumber ?? this.jerseyNumber,
|
||||
goalsScored: goalsScored ?? this.goalsScored,
|
||||
assists: assists ?? this.assists,
|
||||
);
|
||||
}
|
||||
|
||||
factory Player.fromMap(Map<String, dynamic> data) {
|
||||
return Player(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
name: (data['name'] as String?) ?? '',
|
||||
position: data['position'] as String?,
|
||||
avatarUrl: data['avatar_url'] as String?,
|
||||
jerseyNumber: (data['jersey_number'] as num?)?.toInt(),
|
||||
goalsScored: (data['goals_scored'] as num?)?.toInt() ?? 0,
|
||||
assists: (data['assists'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return <String, Object?>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'position': position,
|
||||
'avatar_url': avatarUrl,
|
||||
'jersey_number': jerseyNumber,
|
||||
'goals_scored': goalsScored,
|
||||
'assists': assists,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Player &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.position == position &&
|
||||
other.avatarUrl == avatarUrl &&
|
||||
other.jerseyNumber == jerseyNumber &&
|
||||
other.goalsScored == goalsScored &&
|
||||
other.assists == assists;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, name, position, avatarUrl, jerseyNumber, goalsScored, assists,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Player(id: $id, name: $name)';
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'player.dart';
|
||||
|
||||
class TeamStatus {
|
||||
TeamStatus._();
|
||||
static const String pending = 'pending';
|
||||
static const String approved = 'approved';
|
||||
static const String rejected = 'rejected';
|
||||
|
||||
static String normalize(String? raw) {
|
||||
switch (raw) {
|
||||
case pending:
|
||||
case approved:
|
||||
case rejected:
|
||||
return raw!;
|
||||
default:
|
||||
return approved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Team {
|
||||
const Team({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.logoUrl,
|
||||
this.description,
|
||||
this.wins = 0,
|
||||
this.losses = 0,
|
||||
this.draws = 0,
|
||||
this.players = const <Player>[],
|
||||
this.primaryColor,
|
||||
this.managerId,
|
||||
this.managerEmail = '',
|
||||
this.managerPhone,
|
||||
this.status = TeamStatus.approved,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? logoUrl;
|
||||
final String? description;
|
||||
final int wins;
|
||||
final int losses;
|
||||
final int draws;
|
||||
final List<Player> players;
|
||||
final String? primaryColor;
|
||||
final String? managerId;
|
||||
final String managerEmail;
|
||||
final String? managerPhone;
|
||||
final String status;
|
||||
|
||||
bool get isApproved => status == TeamStatus.approved;
|
||||
bool get isPending => status == TeamStatus.pending;
|
||||
bool get isRejected => status == TeamStatus.rejected;
|
||||
|
||||
int get totalGames => wins + losses + draws;
|
||||
String get record => '$wins-$losses-$draws';
|
||||
double get winPercentage => totalGames == 0 ? 0 : wins / totalGames;
|
||||
|
||||
Player? get topScorer {
|
||||
if (players.isEmpty) return null;
|
||||
Player best = players.first;
|
||||
for (final p in players) {
|
||||
if (p.goalsScored > best.goalsScored) best = p;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
Team copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? logoUrl,
|
||||
String? description,
|
||||
int? wins,
|
||||
int? losses,
|
||||
int? draws,
|
||||
List<Player>? players,
|
||||
String? primaryColor,
|
||||
String? managerId,
|
||||
String? managerEmail,
|
||||
String? managerPhone,
|
||||
String? status,
|
||||
}) {
|
||||
return Team(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
logoUrl: logoUrl ?? this.logoUrl,
|
||||
description: description ?? this.description,
|
||||
wins: wins ?? this.wins,
|
||||
losses: losses ?? this.losses,
|
||||
draws: draws ?? this.draws,
|
||||
players: players ?? this.players,
|
||||
primaryColor: primaryColor ?? this.primaryColor,
|
||||
managerId: managerId ?? this.managerId,
|
||||
managerEmail: managerEmail ?? this.managerEmail,
|
||||
managerPhone: managerPhone ?? this.managerPhone,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
factory Team.fromJson(Map<String, dynamic> data) {
|
||||
final rawPlayers = (data['players'] as List?) ?? const [];
|
||||
return Team(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
name: (data['name'] as String?) ?? '',
|
||||
logoUrl: data['logo_url'] as String?,
|
||||
description: data['description'] as String?,
|
||||
wins: (data['wins'] as num?)?.toInt() ?? 0,
|
||||
losses: (data['losses'] as num?)?.toInt() ?? 0,
|
||||
draws: (data['draws'] as num?)?.toInt() ?? 0,
|
||||
players: rawPlayers
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Player.fromMap)
|
||||
.toList(growable: false),
|
||||
primaryColor: data['primary_color'] as String?,
|
||||
managerId: data['manager_id'] as String?,
|
||||
managerEmail: (data['manager_email'] as String?) ?? '',
|
||||
managerPhone: data['manager_phone'] as String?,
|
||||
status: TeamStatus.normalize(data['status'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'name': name,
|
||||
'logo_url': logoUrl,
|
||||
'description': description,
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'draws': draws,
|
||||
'primary_color': primaryColor,
|
||||
'manager_id': managerId,
|
||||
'manager_email': managerEmail,
|
||||
'manager_phone': managerPhone,
|
||||
'status': status,
|
||||
'players': players.map((p) => p.toMap()).toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! Team) return false;
|
||||
if (other.id != id) return false;
|
||||
if (other.name != name) return false;
|
||||
if (other.logoUrl != logoUrl) return false;
|
||||
if (other.description != description) return false;
|
||||
if (other.wins != wins) return false;
|
||||
if (other.losses != losses) return false;
|
||||
if (other.draws != draws) return false;
|
||||
if (other.primaryColor != primaryColor) return false;
|
||||
if (other.managerId != managerId) return false;
|
||||
if (other.managerEmail != managerEmail) return false;
|
||||
if (other.managerPhone != managerPhone) return false;
|
||||
if (other.status != status) return false;
|
||||
if (other.players.length != players.length) return false;
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
if (other.players[i] != players[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, name, logoUrl, description, wins, losses, draws,
|
||||
primaryColor, managerId, managerEmail, managerPhone,
|
||||
status, Object.hashAll(players),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Team(id: $id, name: $name, status: $status, record: $record, '
|
||||
'players: ${players.length})';
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/join_request.dart';
|
||||
import '../domain/team.dart';
|
||||
|
||||
part 'teams_repository.g.dart';
|
||||
|
||||
class TeamsRepository {
|
||||
TeamsRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<Team>> fetchTeams({bool adminAll = false}) async {
|
||||
final params = adminAll ? <String, String>{'all': '1'} : null;
|
||||
final data = await _api.get('/teams/index.php', params: params);
|
||||
final list = (data['teams'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Team.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<Team?> getTeam(String id) async {
|
||||
try {
|
||||
final data = await _api.get('/teams/detail.php', params: {'id': id});
|
||||
return Team.fromJson(data);
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createTeam(Team team) async {
|
||||
final data = await _api.post('/teams/index.php', team.toJson());
|
||||
return data['id'] as String;
|
||||
}
|
||||
|
||||
Future<Team> updateTeam(Team team) async {
|
||||
final data = await _api.put(
|
||||
'/teams/detail.php',
|
||||
team.toJson(),
|
||||
params: {'id': team.id},
|
||||
);
|
||||
return Team.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> updateTeamStatus(String teamId, String status) async {
|
||||
await _api.put('/teams/detail.php', {'status': status}, params: {'id': teamId});
|
||||
}
|
||||
|
||||
Future<void> deleteTeam(String id) async {
|
||||
await _api.delete('/teams/detail.php', params: {'id': id});
|
||||
}
|
||||
|
||||
Future<String> submitJoinRequest({
|
||||
required String teamId,
|
||||
required String teamName,
|
||||
required String playerId,
|
||||
required String playerName,
|
||||
required String playerEmail,
|
||||
}) async {
|
||||
final data = await _api.post('/teams/join_requests.php', {
|
||||
'team_id': teamId,
|
||||
'team_name': teamName,
|
||||
'player_name': playerName,
|
||||
'player_email': playerEmail,
|
||||
});
|
||||
return data['id'] as String;
|
||||
}
|
||||
|
||||
Future<List<JoinRequest>> fetchJoinRequestsForTeam(String teamId) async {
|
||||
final data = await _api.get(
|
||||
'/teams/join_requests.php',
|
||||
params: {'team_id': teamId},
|
||||
);
|
||||
final list = (data['requests'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(JoinRequest.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<List<JoinRequest>> fetchJoinRequestsForPlayer(String playerId) async {
|
||||
final data = await _api.get(
|
||||
'/teams/join_requests.php',
|
||||
params: {'player_id': playerId},
|
||||
);
|
||||
final list = (data['requests'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(JoinRequest.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<void> updateJoinRequestStatus(String requestId, String status) async {
|
||||
await _api.put(
|
||||
'/teams/join_requests.php',
|
||||
{'id': requestId, 'status': status},
|
||||
params: {'id': requestId},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<Team>> watchTeams() async* {
|
||||
yield await fetchTeams();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchTeams();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<Team>> adminWatchAllTeams() async* {
|
||||
yield await fetchTeams(adminAll: true);
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchTeams(adminAll: true);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<JoinRequest>> watchJoinRequestsForTeam(String teamId) async* {
|
||||
yield await fetchJoinRequestsForTeam(teamId);
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchJoinRequestsForTeam(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<JoinRequest>> watchJoinRequestsForPlayer(String playerId) async* {
|
||||
yield await fetchJoinRequestsForPlayer(playerId);
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchJoinRequestsForPlayer(playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TeamsRepository teamsRepository(TeamsRepositoryRef ref) {
|
||||
return TeamsRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Stream<List<Team>> teamsStream(TeamsStreamRef ref) {
|
||||
return ref.watch(teamsRepositoryProvider).watchTeams();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'teams_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$teamsRepositoryHash() => r'eb7ca229935756d7a761b8dd59a29ffe6238c841';
|
||||
|
||||
/// See also [teamsRepository].
|
||||
@ProviderFor(teamsRepository)
|
||||
final teamsRepositoryProvider = Provider<TeamsRepository>.internal(
|
||||
teamsRepository,
|
||||
name: r'teamsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$teamsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef TeamsRepositoryRef = ProviderRef<TeamsRepository>;
|
||||
String _$teamsStreamHash() => r'1a8b1558c8b4419188620e8a0a11f63260cd382c';
|
||||
|
||||
/// Stream of teams surfaced to the UI. Currently emits the mock list as a
|
||||
/// single tick — swap to `ref.watch(teamsRepositoryProvider).watchTeams()`
|
||||
/// once Firestore is seeded.
|
||||
///
|
||||
/// Copied from [teamsStream].
|
||||
@ProviderFor(teamsStream)
|
||||
final teamsStreamProvider = AutoDisposeStreamProvider<List<Team>>.internal(
|
||||
teamsStream,
|
||||
name: r'teamsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$teamsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef TeamsStreamRef = AutoDisposeStreamProviderRef<List<Team>>;
|
||||
// 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,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../../profile/infrastructure/profile_repository.dart';
|
||||
import '../domain/player.dart';
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
|
||||
/// Public-facing form for any logged-in user to register a new team.
|
||||
///
|
||||
/// Differs from the admin form in two ways:
|
||||
/// 1. Manager email/phone are first-class fields so the league can contact
|
||||
/// whoever created the team.
|
||||
/// 2. Win/loss/draw counters are not exposed — those are reserved for admins.
|
||||
class CreateTeamScreen extends ConsumerStatefulWidget {
|
||||
const CreateTeamScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateTeamScreen> createState() => _CreateTeamScreenState();
|
||||
}
|
||||
|
||||
class _CreateTeamScreenState extends ConsumerState<CreateTeamScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _logoUrlCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final _managerEmailCtrl = TextEditingController();
|
||||
final _managerPhoneCtrl = TextEditingController();
|
||||
|
||||
final List<_PlayerDraft> _roster = <_PlayerDraft>[];
|
||||
|
||||
bool _hydratedEmail = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_logoUrlCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_managerEmailCtrl.dispose();
|
||||
_managerPhoneCtrl.dispose();
|
||||
for (final p in _roster) {
|
||||
p.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _addPlayerRow() {
|
||||
setState(() => _roster.add(_PlayerDraft()));
|
||||
}
|
||||
|
||||
void _removePlayerRow(_PlayerDraft draft) {
|
||||
setState(() {
|
||||
_roster.remove(draft);
|
||||
draft.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final user = ref.read(authNotifierProvider).valueOrNull;
|
||||
final id = 'team_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final players = <Player>[];
|
||||
for (var i = 0; i < _roster.length; i++) {
|
||||
final draft = _roster[i];
|
||||
final name = draft.nameCtrl.text.trim();
|
||||
if (name.isEmpty) continue;
|
||||
players.add(
|
||||
Player(
|
||||
id: '${id}_p$i',
|
||||
name: name,
|
||||
jerseyNumber: int.tryParse(draft.jerseyCtrl.text.trim()),
|
||||
position: draft.positionCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: draft.positionCtrl.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final team = Team(
|
||||
id: id,
|
||||
name: _nameCtrl.text.trim(),
|
||||
logoUrl: _logoUrlCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _logoUrlCtrl.text.trim(),
|
||||
description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
|
||||
managerId: user?.uid,
|
||||
managerEmail: _managerEmailCtrl.text.trim(),
|
||||
managerPhone: _managerPhoneCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _managerPhoneCtrl.text.trim(),
|
||||
players: players,
|
||||
// Manager-submitted teams require admin approval before going public.
|
||||
status: TeamStatus.pending,
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final newId = await ref.read(teamsRepositoryProvider).createTeam(team);
|
||||
// Stamp the team on the manager's profile so the dashboard finds it.
|
||||
if (user != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.updateTeamId(user.uid, newId);
|
||||
} catch (_) {
|
||||
// Profile may not exist yet for legacy accounts — non-fatal.
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Team submitted — awaiting admin approval.'),
|
||||
),
|
||||
);
|
||||
context.go('/manager');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not create team: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
if (!_hydratedEmail && user != null) {
|
||||
_managerEmailCtrl.text = user.email;
|
||||
_hydratedEmail = true;
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('NEW TEAM'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/teams'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Team name'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _logoUrlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Logo URL (optional)',
|
||||
hintText: 'https://...',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'CONTACT',
|
||||
style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 1.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _managerEmailCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Manager email',
|
||||
prefixIcon: Icon(Icons.mail_outline),
|
||||
),
|
||||
readOnly: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _managerPhoneCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Manager phone (optional)',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'ROSTER',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _addPlayerRow,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add player'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_roster.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'No players yet — tap "Add player" to start your roster.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._roster.map(
|
||||
(draft) => _PlayerRow(
|
||||
draft: draft,
|
||||
onRemove: () => _removePlayerRow(draft),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.add_circle_outline),
|
||||
label: const Text('CREATE TEAM'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerDraft {
|
||||
_PlayerDraft()
|
||||
: nameCtrl = TextEditingController(),
|
||||
jerseyCtrl = TextEditingController(),
|
||||
positionCtrl = TextEditingController();
|
||||
|
||||
final TextEditingController nameCtrl;
|
||||
final TextEditingController jerseyCtrl;
|
||||
final TextEditingController positionCtrl;
|
||||
|
||||
void dispose() {
|
||||
nameCtrl.dispose();
|
||||
jerseyCtrl.dispose();
|
||||
positionCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerRow extends StatelessWidget {
|
||||
const _PlayerRow({required this.draft, required this.onRemove});
|
||||
|
||||
final _PlayerDraft draft;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: draft.nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Player name'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
tooltip: 'Remove player',
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: TextFormField(
|
||||
controller: draft.jerseyCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Jersey #'),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: draft.positionCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Position'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
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 '../../profile/application/profile_notifier.dart';
|
||||
import '../../profile/domain/user_profile.dart';
|
||||
import '../application/teams_notifier.dart';
|
||||
import '../domain/join_request.dart';
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
import 'widgets/player_tile.dart';
|
||||
import 'widgets/team_record_badge.dart';
|
||||
|
||||
/// Full-screen view of a single team: header (logo + record), summary stats,
|
||||
/// and roster.
|
||||
class TeamDetailScreen extends ConsumerWidget {
|
||||
const TeamDetailScreen({super.key, required this.teamId});
|
||||
|
||||
final String teamId;
|
||||
|
||||
/// Web reads better when long content is centered in a column; ~760px is
|
||||
/// the same max we use across other detail screens.
|
||||
static const double _maxContentWidth = 760;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final team = ref.watch(teamByIdProvider(teamId));
|
||||
|
||||
if (team == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Team'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/teams'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('Team not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/teams'),
|
||||
),
|
||||
title: Text(team.name),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
||||
child: _TeamDetailBody(team: team),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamDetailBody extends StatelessWidget {
|
||||
const _TeamDetailBody({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _TeamHeader(team: team)),
|
||||
SliverToBoxAdapter(child: _JoinTeamSection(team: team)),
|
||||
SliverToBoxAdapter(child: _StatsRow(team: team)),
|
||||
SliverToBoxAdapter(child: _ContactSection(team: team)),
|
||||
const SliverToBoxAdapter(child: _SectionDivider(title: 'Roster')),
|
||||
if (team.players.isEmpty)
|
||||
const SliverToBoxAdapter(child: _EmptyRoster())
|
||||
else
|
||||
SliverList.separated(
|
||||
itemCount: team.players.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) =>
|
||||
PlayerTile(player: team.players[index]),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one of four states for a logged-in player viewing a team:
|
||||
/// * already on this team → 'YOUR TEAM' chip
|
||||
/// * already on another team → no CTA
|
||||
/// * pending request out → disabled 'Request pending' button
|
||||
/// * no request yet → primary 'Request to join' OutlinedButton
|
||||
///
|
||||
/// Returns SizedBox.shrink for managers, admins, viewers, or while role is
|
||||
/// resolving — they have no use for the action.
|
||||
class _JoinTeamSection extends ConsumerWidget {
|
||||
const _JoinTeamSection({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(authNotifierProvider).valueOrNull;
|
||||
final role = ref.watch(currentUserRoleProvider);
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
|
||||
if (user == null || role != UserRole.player || profile == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Player is already on this team.
|
||||
if (profile.teamId == team.id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'YOUR TEAM',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.green.shade300,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Player is on a different team — no join CTA shown.
|
||||
if (profile.hasTeam) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final requestsAsync = ref.watch(
|
||||
joinRequestsForPlayerProvider(user.uid),
|
||||
);
|
||||
|
||||
return requestsAsync.when(
|
||||
loading: () => const SizedBox(height: 0),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (requests) {
|
||||
final hasPendingForThisTeam = requests.any(
|
||||
(r) =>
|
||||
r.teamId == team.id && r.status == JoinRequestStatus.pending,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
|
||||
child: hasPendingForThisTeam
|
||||
? OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.hourglass_bottom, size: 18),
|
||||
label: const Text('REQUEST PENDING'),
|
||||
)
|
||||
: OutlinedButton.icon(
|
||||
onPressed: () => _submit(context, ref, profile),
|
||||
icon: const Icon(Icons.person_add_alt_1, size: 18),
|
||||
label: const Text('REQUEST TO JOIN'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
UserProfile profile,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(teamsRepositoryProvider).submitJoinRequest(
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
playerId: profile.uid,
|
||||
playerName: profile.displayName.isEmpty
|
||||
? profile.email
|
||||
: profile.displayName,
|
||||
playerEmail: profile.email,
|
||||
);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Request sent!')),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not send request: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamHeader extends StatelessWidget {
|
||||
const _TeamHeader({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;
|
||||
|
||||
final hasLogo = team.logoUrl != null && team.logoUrl!.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
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: hasLogo
|
||||
? CircleAvatar(
|
||||
radius: 48,
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
backgroundImage: NetworkImage(team.logoUrl!),
|
||||
)
|
||||
: Text(
|
||||
initial.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 44,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
team.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (team.description != null && team.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
team.description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TeamRecordBadge(
|
||||
wins: team.wins,
|
||||
draws: team.draws,
|
||||
losses: team.losses,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final winPct = (team.winPercentage * 100).round();
|
||||
final topScorer = team.topScorer;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatColumn(label: 'Games', value: '${team.totalGames}'),
|
||||
),
|
||||
_VerticalDivider(color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _StatColumn(label: 'Win %', value: '$winPct%'),
|
||||
),
|
||||
_VerticalDivider(color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _StatColumn(
|
||||
label: 'Top scorer',
|
||||
value: topScorer == null
|
||||
? '—'
|
||||
: '${topScorer.name.split(' ').first} '
|
||||
'(${topScorer.goalsScored})',
|
||||
small: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactSection extends StatelessWidget {
|
||||
const _ContactSection({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasEmail = team.managerEmail.isNotEmpty;
|
||||
final hasPhone = team.managerPhone != null && team.managerPhone!.isNotEmpty;
|
||||
if (!hasEmail && !hasPhone) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'CONTACT',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.8,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (hasEmail)
|
||||
_ContactRow(icon: Icons.mail_outline, label: team.managerEmail),
|
||||
if (hasEmail && hasPhone) const SizedBox(height: 6),
|
||||
if (hasPhone)
|
||||
_ContactRow(
|
||||
icon: Icons.phone_outlined,
|
||||
label: team.managerPhone!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactRow extends StatelessWidget {
|
||||
const _ContactRow({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatColumn extends StatelessWidget {
|
||||
const _StatColumn({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.small = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool small;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
(small
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.headlineSmall)
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerticalDivider extends StatelessWidget {
|
||||
const _VerticalDivider({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 36,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionDivider extends StatelessWidget {
|
||||
const _SectionDivider({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Divider(color: theme.colorScheme.outlineVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRoster extends StatelessWidget {
|
||||
const _EmptyRoster();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Text(
|
||||
'No players on the roster yet.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../domain/team.dart';
|
||||
import '../infrastructure/teams_repository.dart';
|
||||
import 'widgets/team_card.dart';
|
||||
|
||||
/// Top-level Teams tab. Renders a responsive grid of team cards on wider
|
||||
/// viewports and a single-column list on mobile.
|
||||
class TeamsScreen extends ConsumerWidget {
|
||||
const TeamsScreen({super.key});
|
||||
|
||||
/// Width at which the layout switches from list to 2-column grid.
|
||||
static const double _gridBreakpoint = 640;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final teamsAsync = ref.watch(teamsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Teams')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/teams/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('CREATE A TEAM'),
|
||||
),
|
||||
body: teamsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(teamsStreamProvider),
|
||||
),
|
||||
data: (teams) {
|
||||
if (teams.isEmpty) return const _EmptyState();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= _gridBreakpoint;
|
||||
return isWide
|
||||
? _TeamsGrid(teams: teams, maxWidth: constraints.maxWidth)
|
||||
: _TeamsList(teams: teams);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamsList extends StatelessWidget {
|
||||
const _TeamsList({required this.teams});
|
||||
|
||||
final List<Team> teams;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
itemCount: teams.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) => TeamCard(team: teams[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamsGrid extends StatelessWidget {
|
||||
const _TeamsGrid({required this.teams, required this.maxWidth});
|
||||
|
||||
final List<Team> teams;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Wider viewports get more columns: 2 up to ~1024, then 3.
|
||||
final crossAxisCount = maxWidth >= 1024 ? 3 : 2;
|
||||
// Slightly taller than wide so the top-scorer pill never crowds.
|
||||
const aspect = 1.55;
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: teams.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: aspect,
|
||||
),
|
||||
itemBuilder: (context, index) => TeamCard(team: teams[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@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: [
|
||||
Icon(
|
||||
Icons.groups_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('No teams yet', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Teams will appear here once rosters are submitted for an event.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@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: [
|
||||
Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text('Could not load teams', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/player.dart';
|
||||
|
||||
/// ListTile-style row for one player in a team roster.
|
||||
class PlayerTile extends StatelessWidget {
|
||||
const PlayerTile({super.key, required this.player});
|
||||
|
||||
final Player player;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final initial = player.name.isEmpty ? '?' : player.name.characters.first;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (player.jerseyNumber != null) ...<Widget>[
|
||||
_JerseyBadge(number: player.jerseyNumber!),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
CircleAvatar(
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
foregroundColor: scheme.onSecondaryContainer,
|
||||
child: Text(
|
||||
initial.toUpperCase(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
player.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: player.position == null
|
||||
? null
|
||||
: Text(
|
||||
player.position!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_StatPill(
|
||||
icon: Icons.sports_soccer,
|
||||
value: player.goalsScored,
|
||||
color: scheme.primary,
|
||||
tooltip: 'Goals',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatPill(
|
||||
icon: Icons.handshake_outlined,
|
||||
value: player.assists,
|
||||
color: scheme.tertiary,
|
||||
tooltip: 'Assists',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JerseyBadge extends StatelessWidget {
|
||||
const _JerseyBadge({required this.number});
|
||||
|
||||
final int number;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minWidth: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
'#$number',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatPill extends StatelessWidget {
|
||||
const _StatPill({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.tooltip,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final int value;
|
||||
final Color color;
|
||||
final String tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$value',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../domain/team.dart';
|
||||
import 'team_record_badge.dart';
|
||||
|
||||
/// Card summarizing one team in the grid/list. Tapping navigates to
|
||||
/// `/teams/:id`.
|
||||
class TeamCard extends StatelessWidget {
|
||||
const TeamCard({super.key, 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;
|
||||
final topScorer = team.topScorer;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/teams/${team.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_TeamInitialAvatar(initial: initial, size: 52),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
team.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.group_outlined,
|
||||
size: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${team.players.length} players',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TeamRecordBadge(
|
||||
wins: team.wins,
|
||||
draws: team.draws,
|
||||
losses: team.losses,
|
||||
dense: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: topScorer == null
|
||||
? Text(
|
||||
'No goals scored yet',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_soccer,
|
||||
size: 14,
|
||||
color: scheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Top scorer: ${topScorer.name} — '
|
||||
'${topScorer.goalsScored} '
|
||||
'${topScorer.goalsScored == 1 ? 'goal' : 'goals'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamInitialAvatar extends StatelessWidget {
|
||||
const _TeamInitialAvatar({required this.initial, required this.size});
|
||||
|
||||
final String initial;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
initial.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: size * 0.46,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Compact W / D / L pill row used on team cards and the detail header.
|
||||
///
|
||||
/// Win / loss / draw are universally readable colors, so they bypass the
|
||||
/// color scheme and use semantic green / red / grey tints that remain stable
|
||||
/// across light and dark themes.
|
||||
class TeamRecordBadge extends StatelessWidget {
|
||||
const TeamRecordBadge({
|
||||
super.key,
|
||||
required this.wins,
|
||||
required this.draws,
|
||||
required this.losses,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final int wins;
|
||||
final int draws;
|
||||
final int losses;
|
||||
|
||||
/// Shrinks padding and font size for tight spaces (e.g. inside a card).
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spacing = dense ? 6.0 : 8.0;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RecordChip(
|
||||
label: 'W',
|
||||
value: wins,
|
||||
color: Colors.green,
|
||||
dense: dense,
|
||||
),
|
||||
SizedBox(width: spacing),
|
||||
_RecordChip(
|
||||
label: 'D',
|
||||
value: draws,
|
||||
color: Colors.grey,
|
||||
dense: dense,
|
||||
),
|
||||
SizedBox(width: spacing),
|
||||
_RecordChip(
|
||||
label: 'L',
|
||||
value: losses,
|
||||
color: Colors.red,
|
||||
dense: dense,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordChip extends StatelessWidget {
|
||||
const _RecordChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.dense,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final MaterialColor color;
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final tint = isDark ? color.shade200 : color.shade800;
|
||||
final bg = (isDark ? color.shade900 : color.shade100).withValues(
|
||||
alpha: isDark ? 0.45 : 1.0,
|
||||
);
|
||||
|
||||
final hPad = dense ? 8.0 : 10.0;
|
||||
final vPad = dense ? 4.0 : 6.0;
|
||||
final fontSize = dense ? 11.0 : 12.5;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: vPad),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: tint.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
'$label: $value',
|
||||
style: TextStyle(
|
||||
color: tint,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: fontSize,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user