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,57 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../brackets/domain/bracket.dart';
|
||||
import '../../brackets/infrastructure/brackets_repository.dart';
|
||||
|
||||
part 'admin_brackets_notifier.g.dart';
|
||||
|
||||
/// Live Firestore-backed stream of every bracket, used by the admin panel.
|
||||
@riverpod
|
||||
Stream<List<Bracket>> adminBracketsStream(AdminBracketsStreamRef ref) {
|
||||
final repo = ref.watch(bracketsRepositoryProvider);
|
||||
return repo.watchBrackets();
|
||||
}
|
||||
|
||||
/// Imperative wrapper around the brackets repository write methods.
|
||||
@riverpod
|
||||
class AdminBracketsNotifier extends _$AdminBracketsNotifier {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<String> create(Bracket bracket) async {
|
||||
final repo = ref.read(bracketsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final id = await repo.createBracket(bracket);
|
||||
state = const AsyncData(null);
|
||||
return id;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(Bracket bracket) async {
|
||||
final repo = ref.read(bracketsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.updateBracket(bracket));
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final repo = ref.read(bracketsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.deleteBracket(id));
|
||||
}
|
||||
|
||||
Future<void> updateMatch(
|
||||
String bracketId,
|
||||
String roundLabel,
|
||||
BracketMatch match,
|
||||
) async {
|
||||
final repo = ref.read(bracketsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => repo.updateMatch(bracketId, roundLabel, match),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_brackets_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$adminBracketsStreamHash() =>
|
||||
r'2a76ca85dc76fc7514b7b9ae17a5610f1c1760d9';
|
||||
|
||||
/// Live Firestore-backed stream of every bracket, used by the admin panel.
|
||||
///
|
||||
/// Copied from [adminBracketsStream].
|
||||
@ProviderFor(adminBracketsStream)
|
||||
final adminBracketsStreamProvider =
|
||||
AutoDisposeStreamProvider<List<Bracket>>.internal(
|
||||
adminBracketsStream,
|
||||
name: r'adminBracketsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminBracketsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AdminBracketsStreamRef = AutoDisposeStreamProviderRef<List<Bracket>>;
|
||||
String _$adminBracketsNotifierHash() =>
|
||||
r'ac2ba11f3c44e7feccf440538249e078c9a55031';
|
||||
|
||||
/// Imperative wrapper around the brackets repository write methods.
|
||||
///
|
||||
/// Copied from [AdminBracketsNotifier].
|
||||
@ProviderFor(AdminBracketsNotifier)
|
||||
final adminBracketsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminBracketsNotifier, void>.internal(
|
||||
AdminBracketsNotifier.new,
|
||||
name: r'adminBracketsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminBracketsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AdminBracketsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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,49 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../events/domain/event.dart';
|
||||
import '../../events/infrastructure/events_repository.dart';
|
||||
|
||||
part 'admin_events_notifier.g.dart';
|
||||
|
||||
/// Live Firestore-backed stream of every event in the system, used by the
|
||||
/// admin panel. The public-facing [eventsStreamProvider] still emits mocked
|
||||
/// data; admins read straight through to the real collection.
|
||||
@riverpod
|
||||
Stream<List<Event>> adminEventsStream(AdminEventsStreamRef ref) {
|
||||
final repo = ref.watch(eventsRepositoryProvider);
|
||||
return repo.watchEvents();
|
||||
}
|
||||
|
||||
/// Imperative wrapper around the events repository write methods. The notifier
|
||||
/// is `AsyncValue<void>`-shaped so screens can wire it up the same way as the
|
||||
/// existing auth/suggestions notifiers.
|
||||
@riverpod
|
||||
class AdminEventsNotifier extends _$AdminEventsNotifier {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<String> create(Event event) async {
|
||||
final repo = ref.read(eventsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final id = await repo.createEvent(event);
|
||||
state = const AsyncData(null);
|
||||
return id;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(Event event) async {
|
||||
final repo = ref.read(eventsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.updateEvent(event));
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final repo = ref.read(eventsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.deleteEvent(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_events_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$adminEventsStreamHash() => r'33d6cd9ec02f788540270db08f0933e9c46c72e8';
|
||||
|
||||
/// Live Firestore-backed stream of every event in the system, used by the
|
||||
/// admin panel. The public-facing [eventsStreamProvider] still emits mocked
|
||||
/// data; admins read straight through to the real collection.
|
||||
///
|
||||
/// Copied from [adminEventsStream].
|
||||
@ProviderFor(adminEventsStream)
|
||||
final adminEventsStreamProvider =
|
||||
AutoDisposeStreamProvider<List<Event>>.internal(
|
||||
adminEventsStream,
|
||||
name: r'adminEventsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminEventsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AdminEventsStreamRef = AutoDisposeStreamProviderRef<List<Event>>;
|
||||
String _$adminEventsNotifierHash() =>
|
||||
r'd39031c4b14120bba5d4ea0baeed2661eb336ec0';
|
||||
|
||||
/// Imperative wrapper around the events repository write methods. The notifier
|
||||
/// is `AsyncValue<void>`-shaped so screens can wire it up the same way as the
|
||||
/// existing auth/suggestions notifiers.
|
||||
///
|
||||
/// Copied from [AdminEventsNotifier].
|
||||
@ProviderFor(AdminEventsNotifier)
|
||||
final adminEventsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminEventsNotifier, void>.internal(
|
||||
AdminEventsNotifier.new,
|
||||
name: r'adminEventsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminEventsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AdminEventsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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,35 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../suggestions/domain/suggestion.dart';
|
||||
import '../../suggestions/infrastructure/suggestions_repository.dart';
|
||||
|
||||
part 'admin_suggestions_notifier.g.dart';
|
||||
|
||||
/// Live Firestore-backed stream of every suggestion, newest first, for the
|
||||
/// admin review dashboard.
|
||||
@riverpod
|
||||
Stream<List<Suggestion>> adminSuggestionsStream(
|
||||
AdminSuggestionsStreamRef ref,
|
||||
) {
|
||||
final repo = ref.watch(suggestionsRepositoryProvider);
|
||||
return repo.watchAllSuggestions();
|
||||
}
|
||||
|
||||
/// Imperative wrapper around the suggestion write methods.
|
||||
@riverpod
|
||||
class AdminSuggestionsNotifier extends _$AdminSuggestionsNotifier {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<void> updateStatus(String id, SuggestionStatus status) async {
|
||||
final repo = ref.read(suggestionsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.updateStatus(id, status));
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final repo = ref.read(suggestionsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.deleteSuggestion(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_suggestions_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$adminSuggestionsStreamHash() =>
|
||||
r'e87ca116c64b03bf5e62df4c390ff5c3dcfb4e0a';
|
||||
|
||||
/// Live Firestore-backed stream of every suggestion, newest first, for the
|
||||
/// admin review dashboard.
|
||||
///
|
||||
/// Copied from [adminSuggestionsStream].
|
||||
@ProviderFor(adminSuggestionsStream)
|
||||
final adminSuggestionsStreamProvider =
|
||||
AutoDisposeStreamProvider<List<Suggestion>>.internal(
|
||||
adminSuggestionsStream,
|
||||
name: r'adminSuggestionsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminSuggestionsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AdminSuggestionsStreamRef =
|
||||
AutoDisposeStreamProviderRef<List<Suggestion>>;
|
||||
String _$adminSuggestionsNotifierHash() =>
|
||||
r'fd85d538be1e2d9abad02812d9c964c2df2b547a';
|
||||
|
||||
/// Imperative wrapper around the suggestion write methods.
|
||||
///
|
||||
/// Copied from [AdminSuggestionsNotifier].
|
||||
@ProviderFor(AdminSuggestionsNotifier)
|
||||
final adminSuggestionsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminSuggestionsNotifier, void>.internal(
|
||||
AdminSuggestionsNotifier.new,
|
||||
name: r'adminSuggestionsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminSuggestionsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AdminSuggestionsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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,46 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../teams/domain/team.dart';
|
||||
import '../../teams/infrastructure/teams_repository.dart';
|
||||
|
||||
part 'admin_teams_notifier.g.dart';
|
||||
|
||||
/// Live Firestore-backed stream of every team (including pending and
|
||||
/// rejected), used by the admin panel.
|
||||
@riverpod
|
||||
Stream<List<Team>> adminTeamsStream(AdminTeamsStreamRef ref) {
|
||||
final repo = ref.watch(teamsRepositoryProvider);
|
||||
return repo.adminWatchAllTeams();
|
||||
}
|
||||
|
||||
/// Imperative wrapper around the teams repository write methods.
|
||||
@riverpod
|
||||
class AdminTeamsNotifier extends _$AdminTeamsNotifier {
|
||||
@override
|
||||
Future<void> build() async {}
|
||||
|
||||
Future<String> create(Team team) async {
|
||||
final repo = ref.read(teamsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final id = await repo.createTeam(team);
|
||||
state = const AsyncData(null);
|
||||
return id;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(Team team) async {
|
||||
final repo = ref.read(teamsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.updateTeam(team));
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final repo = ref.read(teamsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => repo.deleteTeam(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_teams_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$adminTeamsStreamHash() => r'f392e2c9de281c80912d4fccfaf56c0cbe8ef880';
|
||||
|
||||
/// Live Firestore-backed stream of every team (including pending and
|
||||
/// rejected), used by the admin panel.
|
||||
///
|
||||
/// Copied from [adminTeamsStream].
|
||||
@ProviderFor(adminTeamsStream)
|
||||
final adminTeamsStreamProvider = AutoDisposeStreamProvider<List<Team>>.internal(
|
||||
adminTeamsStream,
|
||||
name: r'adminTeamsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminTeamsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AdminTeamsStreamRef = AutoDisposeStreamProviderRef<List<Team>>;
|
||||
String _$adminTeamsNotifierHash() =>
|
||||
r'1f5febaa0f2eb35596538db76896c96dd240a1d8';
|
||||
|
||||
/// Imperative wrapper around the teams repository write methods.
|
||||
///
|
||||
/// Copied from [AdminTeamsNotifier].
|
||||
@ProviderFor(AdminTeamsNotifier)
|
||||
final adminTeamsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AdminTeamsNotifier, void>.internal(
|
||||
AdminTeamsNotifier.new,
|
||||
name: r'adminTeamsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$adminTeamsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AdminTeamsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Shell for the admin panel. On screens 640px wide and below it shows a
|
||||
/// bottom NavigationBar; on wider viewports it switches to a NavigationRail
|
||||
/// so admins can sweep through tabs with a single click on web/desktop.
|
||||
class AdminShell extends StatelessWidget {
|
||||
const AdminShell({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
static const _tabs = <_AdminTab>[
|
||||
_AdminTab(
|
||||
label: 'EVENTS',
|
||||
icon: Icons.event_outlined,
|
||||
activeIcon: Icons.event,
|
||||
path: '/admin/events',
|
||||
),
|
||||
_AdminTab(
|
||||
label: 'TEAMS',
|
||||
icon: Icons.groups_outlined,
|
||||
activeIcon: Icons.groups,
|
||||
path: '/admin/teams',
|
||||
),
|
||||
_AdminTab(
|
||||
label: 'BRACKETS',
|
||||
icon: Icons.account_tree_outlined,
|
||||
activeIcon: Icons.account_tree,
|
||||
path: '/admin/brackets',
|
||||
),
|
||||
_AdminTab(
|
||||
label: 'IDEAS',
|
||||
icon: Icons.lightbulb_outline,
|
||||
activeIcon: Icons.lightbulb,
|
||||
path: '/admin/suggestions',
|
||||
),
|
||||
_AdminTab(
|
||||
label: 'PENDING',
|
||||
icon: Icons.pending_actions_outlined,
|
||||
activeIcon: Icons.pending_actions,
|
||||
path: '/admin/pending',
|
||||
),
|
||||
];
|
||||
|
||||
int _currentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
final idx = _tabs.indexWhere((t) => location.startsWith(t.path));
|
||||
return idx < 0 ? 0 : idx;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentIndex = _currentIndex(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ADMIN'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back to app',
|
||||
onPressed: () => context.go('/events'),
|
||||
),
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 640;
|
||||
if (isWide) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
NavigationRail(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i].path),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: _tabs
|
||||
.map(
|
||||
(t) => NavigationRailDestination(
|
||||
icon: Icon(t.icon),
|
||||
selectedIcon: Icon(t.activeIcon),
|
||||
label: Text(t.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 640;
|
||||
if (isWide) return const SizedBox.shrink();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(height: 1, color: const Color(0xFF8B30C8)),
|
||||
NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i].path),
|
||||
destinations: _tabs
|
||||
.map(
|
||||
(t) => NavigationDestination(
|
||||
icon: Icon(t.icon),
|
||||
selectedIcon: Icon(t.activeIcon),
|
||||
label: t.label,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminTab {
|
||||
const _AdminTab({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String path;
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../brackets/domain/bracket.dart';
|
||||
import '../../../events/domain/event.dart';
|
||||
import '../../../teams/domain/team.dart';
|
||||
import '../../../teams/infrastructure/teams_repository.dart';
|
||||
import '../../application/admin_brackets_notifier.dart';
|
||||
import '../../application/admin_events_notifier.dart';
|
||||
|
||||
/// Form for creating or editing a tournament bracket.
|
||||
///
|
||||
/// The shape (round count + matches per round) is set up front when creating.
|
||||
/// After creation — or when editing — admins can adjust team labels, set
|
||||
/// scores, change match status, and pick a winner per match.
|
||||
class AdminBracketFormScreen extends ConsumerStatefulWidget {
|
||||
const AdminBracketFormScreen({super.key, this.bracketId});
|
||||
|
||||
final String? bracketId;
|
||||
|
||||
bool get isEdit => bracketId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<AdminBracketFormScreen> createState() =>
|
||||
_AdminBracketFormScreenState();
|
||||
}
|
||||
|
||||
class _AdminBracketFormScreenState
|
||||
extends ConsumerState<AdminBracketFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
String? _eventId;
|
||||
DateTime _createdAt = DateTime.now();
|
||||
|
||||
List<_RoundDraft> _rounds = <_RoundDraft>[];
|
||||
|
||||
// Setup-mode controls (visible when creating a brand-new bracket)
|
||||
int _setupRounds = 3;
|
||||
int _setupTeams = 8;
|
||||
|
||||
bool _hydrated = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEdit) {
|
||||
_hydrated = true;
|
||||
_generateRounds(rounds: _setupRounds, teams: _setupTeams);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
for (final r in _rounds) {
|
||||
r.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _hydrateFrom(Bracket bracket) {
|
||||
if (_hydrated) return;
|
||||
_nameCtrl.text = bracket.name;
|
||||
_eventId = bracket.eventId.isEmpty ? null : bracket.eventId;
|
||||
_createdAt = bracket.createdAt;
|
||||
_rounds = bracket.rounds.map(_RoundDraft.fromRound).toList();
|
||||
_hydrated = true;
|
||||
}
|
||||
|
||||
void _generateRounds({required int rounds, required int teams}) {
|
||||
// For a single-elimination shape, round 1 holds teams/2 matches, round 2
|
||||
// holds teams/4, etc. We round up to handle odd team counts gracefully.
|
||||
for (final r in _rounds) {
|
||||
r.dispose();
|
||||
}
|
||||
_rounds = <_RoundDraft>[];
|
||||
var matchesInRound = (teams / 2).ceil();
|
||||
for (var i = 0; i < rounds; i++) {
|
||||
_rounds.add(
|
||||
_RoundDraft(
|
||||
roundNumber: i + 1,
|
||||
label: _defaultRoundLabel(i, rounds),
|
||||
matches: List.generate(
|
||||
matchesInRound < 1 ? 1 : matchesInRound,
|
||||
(m) => _MatchDraft.empty(id: 'r${i + 1}_m${m + 1}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
matchesInRound = (matchesInRound / 2).ceil();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
String _defaultRoundLabel(int index, int total) {
|
||||
final distanceFromEnd = total - 1 - index;
|
||||
switch (distanceFromEnd) {
|
||||
case 0:
|
||||
return 'Final';
|
||||
case 1:
|
||||
return 'Semifinals';
|
||||
case 2:
|
||||
return 'Quarterfinals';
|
||||
case 3:
|
||||
return 'Round of 16';
|
||||
default:
|
||||
return 'Round ${index + 1}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _randomizeTeams() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final List<Team> teams = await ref
|
||||
.read(teamsRepositoryProvider)
|
||||
.watchTeams()
|
||||
.first;
|
||||
if (!mounted) return;
|
||||
if (teams.length < 2) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Need at least 2 teams in Firestore to randomize.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_rounds.isEmpty || _rounds.first.matches.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('No round-1 matches to fill.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final shuffled = List<Team>.of(teams)..shuffle();
|
||||
final round1 = _rounds.first;
|
||||
|
||||
setState(() {
|
||||
var teamIdx = 0;
|
||||
for (final match in round1.matches) {
|
||||
if (teamIdx < shuffled.length) {
|
||||
final a = shuffled[teamIdx++];
|
||||
match.teamANameCtrl.text = a.name;
|
||||
match.teamAId = a.id;
|
||||
} else {
|
||||
match.teamANameCtrl.text = '';
|
||||
match.teamAId = null;
|
||||
}
|
||||
if (teamIdx < shuffled.length) {
|
||||
final b = shuffled[teamIdx++];
|
||||
match.teamBNameCtrl.text = b.name;
|
||||
match.teamBId = b.id;
|
||||
} else {
|
||||
match.teamBNameCtrl.text = '';
|
||||
match.teamBId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Randomized ${shuffled.length} teams into round 1.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
if (_eventId == null || _eventId!.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Pick an event for this bracket.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final id = widget.bracketId ?? '';
|
||||
final bracket = Bracket(
|
||||
id: id,
|
||||
eventId: _eventId!,
|
||||
name: _nameCtrl.text.trim(),
|
||||
createdAt: _createdAt,
|
||||
rounds: _rounds.map((r) => r.toRound()).toList(growable: false),
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
if (widget.isEdit) {
|
||||
await ref.read(adminBracketsNotifierProvider.notifier).save(bracket);
|
||||
} else {
|
||||
await ref.read(adminBracketsNotifierProvider.notifier).create(bracket);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.isEdit ? 'Bracket updated' : 'Bracket created'),
|
||||
),
|
||||
);
|
||||
context.go('/admin/brackets');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isEdit && !_hydrated) {
|
||||
final bracketsAsync = ref.watch(adminBracketsStreamProvider);
|
||||
final brackets = bracketsAsync.valueOrNull;
|
||||
if (brackets != null) {
|
||||
Bracket? match;
|
||||
for (final b in brackets) {
|
||||
if (b.id == widget.bracketId) {
|
||||
match = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match != null) {
|
||||
final found = match;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hydrateFrom(found));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final eventsAsync = ref.watch(adminEventsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isEdit ? 'EDIT BRACKET' : 'NEW BRACKET'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/admin/brackets'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Bracket name'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
eventsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (e, _) => Text('Could not load events: $e'),
|
||||
data: (events) => _EventPicker(
|
||||
events: events,
|
||||
selected: _eventId,
|
||||
onChanged: (id) => setState(() => _eventId = id),
|
||||
),
|
||||
),
|
||||
if (!widget.isEdit) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _randomizeTeams,
|
||||
icon: const Text('🎲', style: TextStyle(fontSize: 16)),
|
||||
label: const Text('RANDOMIZE TEAMS'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
if (!widget.isEdit) ...<Widget>[
|
||||
Text(
|
||||
'BRACKET SHAPE',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _NumberStepper(
|
||||
label: 'Rounds',
|
||||
value: _setupRounds,
|
||||
min: 1,
|
||||
max: 6,
|
||||
onChanged: (v) => setState(() {
|
||||
_setupRounds = v;
|
||||
_generateRounds(rounds: v, teams: _setupTeams);
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _NumberStepper(
|
||||
label: 'Teams in round 1',
|
||||
value: _setupTeams,
|
||||
min: 2,
|
||||
max: 32,
|
||||
step: 2,
|
||||
onChanged: (v) => setState(() {
|
||||
_setupTeams = v;
|
||||
_generateRounds(rounds: _setupRounds, teams: v);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
..._rounds.map(
|
||||
(round) => _RoundEditor(
|
||||
round: round,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
),
|
||||
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.save_outlined),
|
||||
label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE BRACKET'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventPicker extends StatelessWidget {
|
||||
const _EventPicker({
|
||||
required this.events,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<Event> events;
|
||||
final String? selected;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = <DropdownMenuItem<String>>[
|
||||
const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('— select event —'),
|
||||
),
|
||||
...events.map(
|
||||
(e) => DropdownMenuItem<String>(
|
||||
value: e.id,
|
||||
child: Text(e.title, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
final currentValue = events.any((e) => e.id == selected) ? selected : null;
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: currentValue,
|
||||
items: items,
|
||||
onChanged: onChanged,
|
||||
decoration: const InputDecoration(labelText: 'Event'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NumberStepper extends StatelessWidget {
|
||||
const _NumberStepper({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.onChanged,
|
||||
this.step = 1,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final int min;
|
||||
final int max;
|
||||
final int step;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: value - step >= min
|
||||
? () => onChanged(value - step)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$value',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: value + step <= max
|
||||
? () => onChanged(value + step)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundEditor extends StatelessWidget {
|
||||
const _RoundEditor({required this.round, required this.onChanged});
|
||||
|
||||
final _RoundDraft round;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: round.labelCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Round ${round.roundNumber} label',
|
||||
hintText: 'e.g. Quarterfinals',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (var i = 0; i < round.matches.length; i++)
|
||||
_MatchEditor(
|
||||
index: i,
|
||||
match: round.matches[i],
|
||||
onChanged: onChanged,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
round.matches.add(
|
||||
_MatchDraft.empty(
|
||||
id: 'r${round.roundNumber}_m${round.matches.length + 1}',
|
||||
),
|
||||
);
|
||||
onChanged();
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text('Add match', style: theme.textTheme.labelMedium),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MatchEditor extends StatelessWidget {
|
||||
const _MatchEditor({
|
||||
required this.index,
|
||||
required this.match,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final _MatchDraft match;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'MATCH ${index + 1}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 1.2,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
DropdownButton<MatchStatus>(
|
||||
value: match.status,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
match.status = v;
|
||||
onChanged();
|
||||
},
|
||||
items: MatchStatus.values
|
||||
.map(
|
||||
(s) => DropdownMenuItem<MatchStatus>(
|
||||
value: s,
|
||||
child: Text(_statusLabel(s)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: match.teamANameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Team A'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
controller: match.scoreACtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(labelText: 'Score'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: match.teamBNameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Team B'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
controller: match.scoreBCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(labelText: 'Score'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Winner: ',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: SegmentedButton<_WinnerSelection>(
|
||||
segments: const <ButtonSegment<_WinnerSelection>>[
|
||||
ButtonSegment(
|
||||
value: _WinnerSelection.none,
|
||||
label: Text('TBD'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _WinnerSelection.teamA,
|
||||
label: Text('A'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _WinnerSelection.teamB,
|
||||
label: Text('B'),
|
||||
),
|
||||
],
|
||||
selected: <_WinnerSelection>{match.winner},
|
||||
onSelectionChanged: (set) {
|
||||
match.winner = set.first;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(MatchStatus s) {
|
||||
switch (s) {
|
||||
case MatchStatus.scheduled:
|
||||
return 'Scheduled';
|
||||
case MatchStatus.inProgress:
|
||||
return 'In progress';
|
||||
case MatchStatus.completed:
|
||||
return 'Completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _WinnerSelection { none, teamA, teamB }
|
||||
|
||||
class _RoundDraft {
|
||||
_RoundDraft({
|
||||
required this.roundNumber,
|
||||
required String label,
|
||||
required this.matches,
|
||||
}) : labelCtrl = TextEditingController(text: label);
|
||||
|
||||
factory _RoundDraft.fromRound(BracketRound round) {
|
||||
return _RoundDraft(
|
||||
roundNumber: round.roundNumber,
|
||||
label: round.label,
|
||||
matches: round.matches.map(_MatchDraft.fromMatch).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final int roundNumber;
|
||||
final TextEditingController labelCtrl;
|
||||
final List<_MatchDraft> matches;
|
||||
|
||||
void dispose() {
|
||||
labelCtrl.dispose();
|
||||
for (final m in matches) {
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
BracketRound toRound() {
|
||||
return BracketRound(
|
||||
roundNumber: roundNumber,
|
||||
label: labelCtrl.text.trim().isEmpty
|
||||
? 'Round $roundNumber'
|
||||
: labelCtrl.text.trim(),
|
||||
matches: matches.map((m) => m.toMatch()).toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MatchDraft {
|
||||
_MatchDraft({
|
||||
required this.id,
|
||||
required String teamA,
|
||||
required String teamB,
|
||||
required int? scoreA,
|
||||
required int? scoreB,
|
||||
required this.status,
|
||||
required this.winner,
|
||||
this.teamAId,
|
||||
this.teamBId,
|
||||
}) : teamANameCtrl = TextEditingController(text: teamA),
|
||||
teamBNameCtrl = TextEditingController(text: teamB),
|
||||
scoreACtrl = TextEditingController(
|
||||
text: scoreA == null ? '' : '$scoreA',
|
||||
),
|
||||
scoreBCtrl = TextEditingController(
|
||||
text: scoreB == null ? '' : '$scoreB',
|
||||
);
|
||||
|
||||
factory _MatchDraft.empty({required String id}) {
|
||||
return _MatchDraft(
|
||||
id: id,
|
||||
teamA: '',
|
||||
teamB: '',
|
||||
scoreA: null,
|
||||
scoreB: null,
|
||||
status: MatchStatus.scheduled,
|
||||
winner: _WinnerSelection.none,
|
||||
);
|
||||
}
|
||||
|
||||
factory _MatchDraft.fromMatch(BracketMatch match) {
|
||||
final winner = match.winnerId == null
|
||||
? _WinnerSelection.none
|
||||
: match.isTeamAWinner
|
||||
? _WinnerSelection.teamA
|
||||
: match.isTeamBWinner
|
||||
? _WinnerSelection.teamB
|
||||
: _WinnerSelection.none;
|
||||
return _MatchDraft(
|
||||
id: match.id,
|
||||
teamA: match.teamA?.name ?? '',
|
||||
teamB: match.teamB?.name ?? '',
|
||||
scoreA: match.scoreA,
|
||||
scoreB: match.scoreB,
|
||||
status: match.status,
|
||||
winner: winner,
|
||||
teamAId: match.teamA?.id,
|
||||
teamBId: match.teamB?.id,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final TextEditingController teamANameCtrl;
|
||||
final TextEditingController teamBNameCtrl;
|
||||
final TextEditingController scoreACtrl;
|
||||
final TextEditingController scoreBCtrl;
|
||||
MatchStatus status;
|
||||
_WinnerSelection winner;
|
||||
String? teamAId;
|
||||
String? teamBId;
|
||||
|
||||
void dispose() {
|
||||
teamANameCtrl.dispose();
|
||||
teamBNameCtrl.dispose();
|
||||
scoreACtrl.dispose();
|
||||
scoreBCtrl.dispose();
|
||||
}
|
||||
|
||||
BracketMatch toMatch() {
|
||||
final aName = teamANameCtrl.text.trim();
|
||||
final bName = teamBNameCtrl.text.trim();
|
||||
final teamA = aName.isEmpty
|
||||
? null
|
||||
: BracketTeam(id: teamAId ?? _slug(aName), name: aName);
|
||||
final teamB = bName.isEmpty
|
||||
? null
|
||||
: BracketTeam(id: teamBId ?? _slug(bName), name: bName);
|
||||
|
||||
String? winnerId;
|
||||
switch (winner) {
|
||||
case _WinnerSelection.none:
|
||||
winnerId = null;
|
||||
break;
|
||||
case _WinnerSelection.teamA:
|
||||
winnerId = teamA?.id;
|
||||
break;
|
||||
case _WinnerSelection.teamB:
|
||||
winnerId = teamB?.id;
|
||||
break;
|
||||
}
|
||||
|
||||
return BracketMatch(
|
||||
id: id,
|
||||
teamA: teamA,
|
||||
teamB: teamB,
|
||||
scoreA: int.tryParse(scoreACtrl.text.trim()),
|
||||
scoreB: int.tryParse(scoreBCtrl.text.trim()),
|
||||
status: status,
|
||||
winnerId: winnerId,
|
||||
);
|
||||
}
|
||||
|
||||
static String _slug(String input) {
|
||||
final lower = input.toLowerCase();
|
||||
final cleaned = lower
|
||||
.replaceAll(RegExp(r'[^a-z0-9]+'), '_')
|
||||
.replaceAll(RegExp(r'^_|_$'), '');
|
||||
return cleaned.isEmpty ? 'team' : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../brackets/domain/bracket.dart';
|
||||
import '../../application/admin_brackets_notifier.dart';
|
||||
|
||||
class AdminBracketsScreen extends ConsumerWidget {
|
||||
const AdminBracketsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bracketsAsync = ref.watch(adminBracketsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: bracketsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Could not load brackets',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$err',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (brackets) {
|
||||
if (brackets.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.account_tree_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No brackets yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the + button to create your first bracket.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
itemCount: brackets.length,
|
||||
itemBuilder: (context, i) => _BracketRow(bracket: brackets[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/admin/brackets/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('NEW BRACKET'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BracketRow extends ConsumerWidget {
|
||||
const _BracketRow({required this.bracket});
|
||||
|
||||
final Bracket bracket;
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete bracket?'),
|
||||
content: Text(
|
||||
'"${bracket.name}" and all its match data will be permanently removed.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref.read(adminBracketsNotifierProvider.notifier).delete(bracket.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${bracket.name}"')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final created = DateFormat.yMMMd().format(bracket.createdAt);
|
||||
final totalMatches = bracket.rounds.fold<int>(
|
||||
0,
|
||||
(sum, r) => sum + r.matches.length,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(bracket.name, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${bracket.rounds.length} round${bracket.rounds.length == 1 ? '' : 's'} · $totalMatches match${totalMatches == 1 ? '' : 'es'} · Created $created',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/admin/brackets/${bracket.id}/edit'),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../events/domain/event.dart';
|
||||
import '../../application/admin_events_notifier.dart';
|
||||
|
||||
class AdminEventFormScreen extends ConsumerStatefulWidget {
|
||||
const AdminEventFormScreen({super.key, this.eventId});
|
||||
|
||||
/// Null when creating a new event; otherwise the id of the event being
|
||||
/// edited.
|
||||
final String? eventId;
|
||||
|
||||
bool get isEdit => eventId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<AdminEventFormScreen> createState() =>
|
||||
_AdminEventFormScreenState();
|
||||
}
|
||||
|
||||
class _AdminEventFormScreenState extends ConsumerState<AdminEventFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final _locationCtrl = TextEditingController();
|
||||
final _imageUrlCtrl = TextEditingController();
|
||||
final _teamsRegisteredCtrl = TextEditingController(text: '0');
|
||||
final _maxTeamsCtrl = TextEditingController(text: '8');
|
||||
|
||||
DateTime _date = DateTime.now().add(const Duration(days: 7));
|
||||
DateTime _registrationDeadline = DateTime.now().add(const Duration(days: 6));
|
||||
EventCategory _category = EventCategory.pickup;
|
||||
bool _isCancelled = false;
|
||||
bool _hydrated = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEdit) _hydrated = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
_imageUrlCtrl.dispose();
|
||||
_teamsRegisteredCtrl.dispose();
|
||||
_maxTeamsCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _hydrateFrom(Event event) {
|
||||
if (_hydrated) return;
|
||||
_titleCtrl.text = event.title;
|
||||
_descCtrl.text = event.description;
|
||||
_locationCtrl.text = event.location;
|
||||
_imageUrlCtrl.text = event.imageUrl ?? '';
|
||||
_teamsRegisteredCtrl.text = event.teamsRegistered.toString();
|
||||
_maxTeamsCtrl.text = event.maxTeams.toString();
|
||||
_date = event.date;
|
||||
_registrationDeadline = event.registrationDeadline;
|
||||
_category = event.category;
|
||||
_isCancelled = event.isCancelled;
|
||||
_hydrated = true;
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool registration}) async {
|
||||
final initial = registration ? _registrationDeadline : _date;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(initial),
|
||||
);
|
||||
if (time == null) return;
|
||||
final merged = DateTime(
|
||||
picked.year,
|
||||
picked.month,
|
||||
picked.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
);
|
||||
setState(() {
|
||||
if (registration) {
|
||||
_registrationDeadline = merged;
|
||||
} else {
|
||||
_date = merged;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final id = widget.eventId ?? '';
|
||||
final event = Event(
|
||||
id: id,
|
||||
title: _titleCtrl.text.trim(),
|
||||
description: _descCtrl.text.trim(),
|
||||
date: _date,
|
||||
location: _locationCtrl.text.trim(),
|
||||
registrationDeadline: _registrationDeadline,
|
||||
teamsRegistered: int.tryParse(_teamsRegisteredCtrl.text.trim()) ?? 0,
|
||||
maxTeams: int.tryParse(_maxTeamsCtrl.text.trim()) ?? 0,
|
||||
category: _category,
|
||||
imageUrl: _imageUrlCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _imageUrlCtrl.text.trim(),
|
||||
isCancelled: _isCancelled,
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
if (widget.isEdit) {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).save(event);
|
||||
} else {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).create(event);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.isEdit ? 'Event updated' : 'Event created'),
|
||||
),
|
||||
);
|
||||
context.go('/admin/events');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isEdit && !_hydrated) {
|
||||
final eventsAsync = ref.watch(adminEventsStreamProvider);
|
||||
final events = eventsAsync.valueOrNull;
|
||||
if (events != null) {
|
||||
final match = events.firstWhere(
|
||||
(e) => e.id == widget.eventId,
|
||||
orElse: () => _placeholderEvent(),
|
||||
);
|
||||
if (match.id.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hydrateFrom(match));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final df = DateFormat.yMMMd().add_jm();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isEdit ? 'EDIT EVENT' : 'NEW EVENT'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/admin/events'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
TextFormField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<EventCategory>(
|
||||
segments: const <ButtonSegment<EventCategory>>[
|
||||
ButtonSegment<EventCategory>(
|
||||
value: EventCategory.tournament,
|
||||
label: Text('TOURNAMENT'),
|
||||
icon: Icon(Icons.emoji_events_outlined),
|
||||
),
|
||||
ButtonSegment<EventCategory>(
|
||||
value: EventCategory.pickup,
|
||||
label: Text('PICK-UP'),
|
||||
icon: Icon(Icons.sports_soccer),
|
||||
),
|
||||
],
|
||||
selected: <EventCategory>{_category},
|
||||
onSelectionChanged: (set) =>
|
||||
setState(() => _category = set.first),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Location'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DateField(
|
||||
label: 'Event date & time',
|
||||
value: df.format(_date),
|
||||
onTap: () => _pickDate(registration: false),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DateField(
|
||||
label: 'Registration deadline',
|
||||
value: df.format(_registrationDeadline),
|
||||
onTap: () => _pickDate(registration: true),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _teamsRegisteredCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Teams registered',
|
||||
),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _maxTeamsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Max teams'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _imageUrlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Image URL (optional)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Cancelled', style: theme.textTheme.bodyLarge),
|
||||
subtitle: Text(
|
||||
'Mark this event as cancelled',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _isCancelled,
|
||||
onChanged: (v) => setState(() => _isCancelled = v),
|
||||
),
|
||||
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.save_outlined),
|
||||
label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE EVENT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _validateInt(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n == null || n < 0) return 'Enter a non-negative number';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Event _placeholderEvent() => Event(
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
date: DateTime.now(),
|
||||
location: '',
|
||||
registrationDeadline: DateTime.now(),
|
||||
teamsRegistered: 0,
|
||||
maxTeams: 0,
|
||||
);
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
const _DateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
suffixIcon: const Icon(Icons.calendar_today, size: 18),
|
||||
),
|
||||
child: Text(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../events/domain/event.dart';
|
||||
import '../../application/admin_events_notifier.dart';
|
||||
|
||||
class AdminEventsScreen extends ConsumerWidget {
|
||||
const AdminEventsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final eventsAsync = ref.watch(adminEventsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: eventsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: '$err',
|
||||
onRetry: () => ref.invalidate(adminEventsStreamProvider),
|
||||
),
|
||||
data: (events) {
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.event_busy_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No events yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the + button to create your first event.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) => _EventRow(event: events[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/admin/events/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('NEW EVENT'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventRow extends ConsumerWidget {
|
||||
const _EventRow({required this.event});
|
||||
|
||||
final Event event;
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete event?'),
|
||||
content: Text(
|
||||
'"${event.title}" will be permanently removed. This cannot be undone.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref.read(adminEventsNotifierProvider.notifier).delete(event.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${event.title}"')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final dateLabel = DateFormat.yMMMd().add_jm().format(event.date);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (event.isCancelled)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'CANCELLED',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${event.teamsRegistered}/${event.maxTeams} teams',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.location,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/admin/events/${event.id}/edit'),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: <Widget>[
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Could not load events',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
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,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../teams/domain/team.dart';
|
||||
import '../../../teams/infrastructure/teams_repository.dart';
|
||||
import '../../application/admin_teams_notifier.dart';
|
||||
|
||||
/// Admin panel tab listing teams awaiting approval. Approve and reject
|
||||
/// actions are surfaced as a row of buttons per card.
|
||||
class AdminPendingScreen extends ConsumerWidget {
|
||||
const AdminPendingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final async = ref.watch(adminTeamsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text('Could not load teams: $e'),
|
||||
),
|
||||
),
|
||||
data: (teams) {
|
||||
final pending = teams.where((t) => t.isPending).toList();
|
||||
if (pending.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.pending_actions_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending teams',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'New manager-submitted teams will appear here for '
|
||||
'review.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
itemCount: pending.length,
|
||||
itemBuilder: (context, i) => _PendingTeamCard(team: pending[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingTeamCard extends ConsumerStatefulWidget {
|
||||
const _PendingTeamCard({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
@override
|
||||
ConsumerState<_PendingTeamCard> createState() => _PendingTeamCardState();
|
||||
}
|
||||
|
||||
class _PendingTeamCardState extends ConsumerState<_PendingTeamCard> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _setStatus(String status) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref
|
||||
.read(teamsRepositoryProvider)
|
||||
.updateTeamStatus(widget.team.id, status);
|
||||
if (!mounted) return;
|
||||
final label = status == TeamStatus.approved ? 'approved' : 'rejected';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${widget.team.name} $label')),
|
||||
);
|
||||
} 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 t = widget.team;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
child: Text(
|
||||
t.name.isEmpty ? '?' : t.name.characters.first,
|
||||
style: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.name.isEmpty ? 'Unnamed team' : t.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
t.managerEmail.isEmpty
|
||||
? 'No manager email'
|
||||
: t.managerEmail,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'PENDING',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.amber.shade300,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (t.description != null && t.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
t.description!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.group_outlined,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${t.players.length} '
|
||||
'${t.players.length == 1 ? "player" : "players"}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _setStatus(TeamStatus.rejected),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
label: const Text('REJECT'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _setStatus(TeamStatus.approved),
|
||||
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,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../suggestions/domain/suggestion.dart';
|
||||
import '../../application/admin_suggestions_notifier.dart';
|
||||
|
||||
class AdminSuggestionsScreen extends ConsumerWidget {
|
||||
const AdminSuggestionsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(adminSuggestionsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Could not load suggestions',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$err',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (suggestions) {
|
||||
if (suggestions.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No suggestions yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Community ideas will show up here as they come in.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_AdminSuggestionRow(suggestion: suggestions[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminSuggestionRow extends ConsumerWidget {
|
||||
const _AdminSuggestionRow({required this.suggestion});
|
||||
|
||||
final Suggestion suggestion;
|
||||
|
||||
String _author() {
|
||||
if (suggestion.isAnonymous) return 'Anonymous';
|
||||
final name = suggestion.displayName?.trim();
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
final userId = suggestion.userId?.trim();
|
||||
if (userId != null && userId.isNotEmpty) return 'User $userId';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
Future<void> _changeStatus(BuildContext context, WidgetRef ref) async {
|
||||
final picked = await showDialog<SuggestionStatus>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('Update status'),
|
||||
children: SuggestionStatus.values
|
||||
.map(
|
||||
(s) => SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(ctx).pop(s),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
s == suggestion.status
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(_statusLabel(s)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked == suggestion.status) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(adminSuggestionsNotifierProvider.notifier)
|
||||
.updateStatus(suggestion.id, picked);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Status set to ${_statusLabel(picked)}')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Update failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete suggestion?'),
|
||||
content: const Text(
|
||||
'This will permanently remove the suggestion. Use for spam or duplicates.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(adminSuggestionsNotifierProvider.notifier)
|
||||
.delete(suggestion.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Suggestion deleted')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final dateLabel =
|
||||
DateFormat.yMMMd().add_jm().format(suggestion.submittedAt);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.text,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(status: suggestion.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
suggestion.isAnonymous
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.person_outline,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_author(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.schedule, size: 14, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _changeStatus(context, ref),
|
||||
icon: const Icon(Icons.flag_outlined, size: 18),
|
||||
label: const Text('Status'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _statusLabel(SuggestionStatus s) {
|
||||
switch (s) {
|
||||
case SuggestionStatus.pending:
|
||||
return 'Pending';
|
||||
case SuggestionStatus.reviewed:
|
||||
return 'Reviewed';
|
||||
case SuggestionStatus.implemented:
|
||||
return 'Implemented';
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final SuggestionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
final (Color background, Color foreground, String label) = switch (status) {
|
||||
SuggestionStatus.pending => (
|
||||
colors.surfaceContainerHighest,
|
||||
colors.onSurfaceVariant,
|
||||
'Pending',
|
||||
),
|
||||
SuggestionStatus.reviewed => (
|
||||
Colors.amber.withValues(alpha: 0.18),
|
||||
Colors.amber.shade300,
|
||||
'Reviewed',
|
||||
),
|
||||
SuggestionStatus.implemented => (
|
||||
Colors.green.withValues(alpha: 0.18),
|
||||
Colors.green.shade300,
|
||||
'Implemented',
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../teams/domain/player.dart';
|
||||
import '../../../teams/domain/team.dart';
|
||||
import '../../application/admin_teams_notifier.dart';
|
||||
|
||||
class AdminTeamFormScreen extends ConsumerStatefulWidget {
|
||||
const AdminTeamFormScreen({super.key, this.teamId});
|
||||
|
||||
final String? teamId;
|
||||
|
||||
bool get isEdit => teamId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<AdminTeamFormScreen> createState() =>
|
||||
_AdminTeamFormScreenState();
|
||||
}
|
||||
|
||||
class _AdminTeamFormScreenState extends ConsumerState<AdminTeamFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
final _logoUrlCtrl = TextEditingController();
|
||||
final _primaryColorCtrl = TextEditingController();
|
||||
final _winsCtrl = TextEditingController(text: '0');
|
||||
final _lossesCtrl = TextEditingController(text: '0');
|
||||
final _drawsCtrl = TextEditingController(text: '0');
|
||||
|
||||
final List<Player> _roster = <Player>[];
|
||||
|
||||
/// Preserved across edits so saving doesn't reset a pending team to
|
||||
/// approved or vice versa. New teams default to [TeamStatus.approved].
|
||||
String _status = TeamStatus.approved;
|
||||
|
||||
bool _hydrated = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEdit) _hydrated = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_logoUrlCtrl.dispose();
|
||||
_primaryColorCtrl.dispose();
|
||||
_winsCtrl.dispose();
|
||||
_lossesCtrl.dispose();
|
||||
_drawsCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _hydrateFrom(Team team) {
|
||||
if (_hydrated) return;
|
||||
_nameCtrl.text = team.name;
|
||||
_descCtrl.text = team.description ?? '';
|
||||
_logoUrlCtrl.text = team.logoUrl ?? '';
|
||||
_primaryColorCtrl.text = team.primaryColor ?? '';
|
||||
_winsCtrl.text = team.wins.toString();
|
||||
_lossesCtrl.text = team.losses.toString();
|
||||
_drawsCtrl.text = team.draws.toString();
|
||||
_roster
|
||||
..clear()
|
||||
..addAll(team.players);
|
||||
_status = team.status;
|
||||
_hydrated = true;
|
||||
}
|
||||
|
||||
Future<void> _editPlayer({Player? existing}) async {
|
||||
final result = await showDialog<Player>(
|
||||
context: context,
|
||||
builder: (ctx) => _PlayerEditorDialog(player: existing),
|
||||
);
|
||||
if (result == null) return;
|
||||
setState(() {
|
||||
if (existing == null) {
|
||||
_roster.add(result);
|
||||
} else {
|
||||
final idx = _roster.indexWhere((p) => p.id == existing.id);
|
||||
if (idx >= 0) _roster[idx] = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _removePlayer(Player p) {
|
||||
setState(() => _roster.removeWhere((x) => x.id == p.id));
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final id = widget.teamId ?? '';
|
||||
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(),
|
||||
wins: int.tryParse(_winsCtrl.text.trim()) ?? 0,
|
||||
losses: int.tryParse(_lossesCtrl.text.trim()) ?? 0,
|
||||
draws: int.tryParse(_drawsCtrl.text.trim()) ?? 0,
|
||||
primaryColor: _primaryColorCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _primaryColorCtrl.text.trim(),
|
||||
players: List<Player>.unmodifiable(_roster),
|
||||
status: _status,
|
||||
);
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
if (widget.isEdit) {
|
||||
await ref.read(adminTeamsNotifierProvider.notifier).save(team);
|
||||
} else {
|
||||
await ref.read(adminTeamsNotifierProvider.notifier).create(team);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(widget.isEdit ? 'Team updated' : 'Team created')),
|
||||
);
|
||||
context.go('/admin/teams');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Save failed: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isEdit && !_hydrated) {
|
||||
final teamsAsync = ref.watch(adminTeamsStreamProvider);
|
||||
final teams = teamsAsync.valueOrNull;
|
||||
if (teams != null) {
|
||||
final match = teams.firstWhere(
|
||||
(t) => t.id == widget.teamId,
|
||||
orElse: () => const Team(id: '', name: ''),
|
||||
);
|
||||
if (match.id.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hydrateFrom(match));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isEdit ? 'EDIT TEAM' : 'NEW TEAM'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.go('/admin/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: _descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _logoUrlCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Logo URL (optional)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _primaryColorCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Primary color hex (e.g. #2E7D32)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _winsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Wins'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lossesCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Losses'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _drawsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Draws'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'ROSTER',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _editPlayer(),
|
||||
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 on the roster yet.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._roster.map(
|
||||
(p) => _PlayerRow(
|
||||
player: p,
|
||||
onEdit: () => _editPlayer(existing: p),
|
||||
onDelete: () => _removePlayer(p),
|
||||
),
|
||||
),
|
||||
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.save_outlined),
|
||||
label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE TEAM'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _validateInt(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n == null || n < 0) return 'Enter a non-negative number';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerRow extends StatelessWidget {
|
||||
const _PlayerRow({
|
||||
required this.player,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Player player;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(player.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (player.position != null && player.position!.isNotEmpty)
|
||||
player.position!,
|
||||
'G ${player.goalsScored}',
|
||||
'A ${player.assists}',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: onEdit,
|
||||
tooltip: 'Edit player',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Remove player',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerEditorDialog extends StatefulWidget {
|
||||
const _PlayerEditorDialog({this.player});
|
||||
|
||||
final Player? player;
|
||||
|
||||
@override
|
||||
State<_PlayerEditorDialog> createState() => _PlayerEditorDialogState();
|
||||
}
|
||||
|
||||
class _PlayerEditorDialogState extends State<_PlayerEditorDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _positionCtrl;
|
||||
late final TextEditingController _goalsCtrl;
|
||||
late final TextEditingController _assistsCtrl;
|
||||
late final TextEditingController _avatarCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final p = widget.player;
|
||||
_nameCtrl = TextEditingController(text: p?.name ?? '');
|
||||
_positionCtrl = TextEditingController(text: p?.position ?? '');
|
||||
_goalsCtrl = TextEditingController(text: (p?.goalsScored ?? 0).toString());
|
||||
_assistsCtrl = TextEditingController(text: (p?.assists ?? 0).toString());
|
||||
_avatarCtrl = TextEditingController(text: p?.avatarUrl ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_positionCtrl.dispose();
|
||||
_goalsCtrl.dispose();
|
||||
_assistsCtrl.dispose();
|
||||
_avatarCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final id = widget.player?.id ??
|
||||
'p_${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}';
|
||||
final result = Player(
|
||||
id: id,
|
||||
name: _nameCtrl.text.trim(),
|
||||
position: _positionCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _positionCtrl.text.trim(),
|
||||
avatarUrl: _avatarCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _avatarCtrl.text.trim(),
|
||||
goalsScored: int.tryParse(_goalsCtrl.text.trim()) ?? 0,
|
||||
assists: int.tryParse(_assistsCtrl.text.trim()) ?? 0,
|
||||
);
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.player == null ? 'Add player' : 'Edit player'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _positionCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Position (optional)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _goalsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Goals'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _assistsCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Assists'),
|
||||
validator: _validateInt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _avatarCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Avatar URL (optional)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String? _validateInt(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n == null || n < 0) return 'Enter a non-negative number';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../teams/domain/team.dart';
|
||||
import '../../application/admin_teams_notifier.dart';
|
||||
|
||||
class AdminTeamsScreen extends ConsumerWidget {
|
||||
const AdminTeamsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final teamsAsync = ref.watch(adminTeamsStreamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: teamsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Could not load teams', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$err',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (teams) {
|
||||
if (teams.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
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(
|
||||
'Tap the + button to create your first team.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
itemCount: teams.length,
|
||||
itemBuilder: (context, index) => _TeamRow(team: teams[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.go('/admin/teams/new'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('NEW TEAM'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamRow extends ConsumerWidget {
|
||||
const _TeamRow({required this.team});
|
||||
|
||||
final Team team;
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete team?'),
|
||||
content: Text(
|
||||
'"${team.name}" and its roster will be permanently removed.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
await ref.read(adminTeamsNotifierProvider.notifier).delete(team.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${team.name}"')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color? _parseColor(String? hex) {
|
||||
if (hex == null) return null;
|
||||
final cleaned = hex.replaceAll('#', '').trim();
|
||||
if (cleaned.length != 6) return null;
|
||||
final value = int.tryParse(cleaned, radix: 16);
|
||||
if (value == null) return null;
|
||||
return Color(0xFF000000 | value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final accent = _parseColor(team.primaryColor) ?? theme.colorScheme.primary;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 12,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(team.name, style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
'${team.record} · ${team.players.length} player${team.players.length == 1 ? '' : 's'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (team.description != null && team.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
team.description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/admin/teams/${team.id}/edit'),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/app_user.dart';
|
||||
import '../infrastructure/auth_repository.dart';
|
||||
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
@override
|
||||
Future<AppUser?> build() async {
|
||||
final repo = ref.watch(authRepositoryProvider);
|
||||
|
||||
final completer = Completer<AppUser?>();
|
||||
final sub = repo.authStateChanges().listen(
|
||||
(user) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(user);
|
||||
} else {
|
||||
state = AsyncData(user);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stack);
|
||||
} else {
|
||||
state = AsyncError(error, stack);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(sub.cancel);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> signIn({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => repo.signInWithEmail(email: email, password: password),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => repo.registerWithEmail(
|
||||
email: email,
|
||||
password: password,
|
||||
displayName: displayName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await repo.signOut();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps an [ApiException] or generic error to a friendly message.
|
||||
String authErrorMessage(Object error) {
|
||||
if (error is ApiException) {
|
||||
final msg = error.message.toLowerCase();
|
||||
if (msg.contains('email already')) return 'An account already exists for that email.';
|
||||
if (msg.contains('invalid email')) return 'That email address looks invalid.';
|
||||
if (msg.contains('password')) return 'Password must be at least 6 characters.';
|
||||
if (msg.contains('invalid email or password')) return 'Incorrect email or password.';
|
||||
return error.message;
|
||||
}
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// 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
|
||||
|
||||
part of 'auth_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authNotifierHash() => r'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
|
||||
|
||||
/// See also [AuthNotifier].
|
||||
@ProviderFor(AuthNotifier)
|
||||
final authNotifierProvider =
|
||||
AsyncNotifierProvider<AuthNotifier, AppUser?>.internal(
|
||||
AuthNotifier.new,
|
||||
name: r'authNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AuthNotifier = AsyncNotifier<AppUser?>;
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Immutable domain model representing an authenticated Winded user.
|
||||
///
|
||||
/// This is a pure-Dart model intentionally decoupled from Firebase types so
|
||||
/// the rest of the app can depend on it without pulling in firebase_auth.
|
||||
class AppUser {
|
||||
const AppUser({
|
||||
required this.uid,
|
||||
required this.email,
|
||||
this.displayName,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
final String uid;
|
||||
final String email;
|
||||
final String? displayName;
|
||||
final String? photoUrl;
|
||||
|
||||
AppUser copyWith({
|
||||
String? uid,
|
||||
String? email,
|
||||
String? displayName,
|
||||
String? photoUrl,
|
||||
}) {
|
||||
return AppUser(
|
||||
uid: uid ?? this.uid,
|
||||
email: email ?? this.email,
|
||||
displayName: displayName ?? this.displayName,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AppUser &&
|
||||
other.uid == uid &&
|
||||
other.email == email &&
|
||||
other.displayName == displayName &&
|
||||
other.photoUrl == photoUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(uid, email, displayName, photoUrl);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppUser(uid: $uid, email: $email, displayName: $displayName)';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/app_user.dart';
|
||||
|
||||
part 'auth_repository.g.dart';
|
||||
|
||||
/// Manages auth state backed by the PHP/MySQL API.
|
||||
///
|
||||
/// Because the backend has no push mechanism, auth state is held in memory and
|
||||
/// exposed via a [StreamController]. Sign-in and registration update the stream
|
||||
/// immediately; the token is persisted in [FlutterSecureStorage] via [ApiClient].
|
||||
class AuthRepository {
|
||||
AuthRepository(this._api) {
|
||||
_init();
|
||||
}
|
||||
|
||||
final ApiClient _api;
|
||||
final _controller = StreamController<AppUser?>.broadcast();
|
||||
|
||||
Stream<AppUser?> authStateChanges() => _controller.stream;
|
||||
AppUser? get currentUser => _currentUser;
|
||||
AppUser? _currentUser;
|
||||
|
||||
Future<void> _init() async {
|
||||
final token = await _api.token;
|
||||
if (token == null) {
|
||||
_emit(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final data = await _api.get('/auth/me.php');
|
||||
final user = _mapUser(data);
|
||||
_emit(user);
|
||||
} catch (_) {
|
||||
await _api.clearToken();
|
||||
_emit(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppUser?> signInWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
final data = await _api.post(
|
||||
'/auth/login.php',
|
||||
{'email': email.trim(), 'password': password},
|
||||
auth: false,
|
||||
);
|
||||
await _api.saveToken(data['token'] as String);
|
||||
final user = _mapUser(data['user'] as Map<String, dynamic>);
|
||||
_emit(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<AppUser?> registerWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final data = await _api.post(
|
||||
'/auth/register.php',
|
||||
{
|
||||
'email': email.trim(),
|
||||
'password': password,
|
||||
'display_name': displayName?.trim() ?? '',
|
||||
},
|
||||
auth: false,
|
||||
);
|
||||
await _api.saveToken(data['token'] as String);
|
||||
final user = _mapUser(data['user'] as Map<String, dynamic>);
|
||||
_emit(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _api.clearToken();
|
||||
_emit(null);
|
||||
}
|
||||
|
||||
void _emit(AppUser? user) {
|
||||
_currentUser = user;
|
||||
_controller.add(user);
|
||||
}
|
||||
|
||||
AppUser? _mapUser(Map<String, dynamic> data) {
|
||||
final id = data['id'] as String?;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
return AppUser(
|
||||
uid: id,
|
||||
email: (data['email'] as String?) ?? '',
|
||||
displayName: data['display_name'] as String?,
|
||||
photoUrl: data['photo_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AuthRepository authRepository(AuthRepositoryRef ref) {
|
||||
final repo = AuthRepository(ref.watch(apiClientProvider));
|
||||
ref.onDispose(repo.dispose);
|
||||
return repo;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<AppUser?> authStateChanges(AuthStateChangesRef ref) {
|
||||
return ref.watch(authRepositoryProvider).authStateChanges();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// 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, deprecated_member_use
|
||||
|
||||
part of 'auth_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authRepositoryHash() => r'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
||||
|
||||
/// See also [authRepository].
|
||||
@ProviderFor(authRepository)
|
||||
final authRepositoryProvider = Provider<AuthRepository>.internal(
|
||||
authRepository,
|
||||
name: r'authRepositoryProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AuthRepositoryRef = ProviderRef<AuthRepository>;
|
||||
|
||||
String _$authStateChangesHash() => r'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3';
|
||||
|
||||
/// See also [authStateChanges].
|
||||
@ProviderFor(authStateChanges)
|
||||
final authStateChangesProvider = StreamProvider<AppUser?>.internal(
|
||||
authStateChanges,
|
||||
name: r'authStateChangesProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authStateChangesHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AuthStateChangesRef = StreamProviderRef<AppUser?>;
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../application/auth_notifier.dart';
|
||||
import 'widgets/winded_brand_header.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
static const _purple = Color(0xFF8B30C8);
|
||||
static const _purpleLight = Color(0xFFBF77F6);
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final form = _formKey.currentState;
|
||||
if (form == null || !form.validate()) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
await ref.read(authNotifierProvider.notifier).signIn(
|
||||
email: _emailCtrl.text,
|
||||
password: _passwordCtrl.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final state = ref.read(authNotifierProvider);
|
||||
if (state.hasError) {
|
||||
final message = authErrorMessage(state.error!);
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
// Successful sign-in triggers router redirect via auth stream — no
|
||||
// manual navigation needed here.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final authState = ref.watch(authNotifierProvider);
|
||||
final isLoading = authState.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const WindedBrandHeader(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'SIGN IN TO YOUR PITCH',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF3A3A3A)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 3,
|
||||
decoration: const BoxDecoration(
|
||||
color: _purple,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _emailCtrl,
|
||||
enabled: !isLoading,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: TextInputAction.next,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'EMAIL',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: _validateEmail,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
enabled: !isLoading,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PASSWORD',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => setState(() {
|
||||
_obscurePassword =
|
||||
!_obscurePassword;
|
||||
}),
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
return 'Enter your password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'SIGN IN',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'NEW HERE? ',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => context.go('/register'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _purpleLight,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Text('CREATE AN ACCOUNT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) return 'Enter your email';
|
||||
final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$');
|
||||
if (!emailRegex.hasMatch(trimmed)) return 'Enter a valid email address';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../profile/domain/user_profile.dart';
|
||||
import '../../profile/infrastructure/profile_repository.dart';
|
||||
import '../application/auth_notifier.dart';
|
||||
import 'widgets/winded_brand_header.dart';
|
||||
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
static const _purple = Color(0xFF8B30C8);
|
||||
static const _purpleLight = Color(0xFFBF77F6);
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
final _confirmCtrl = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirm = true;
|
||||
UserRole _selectedRole = UserRole.player;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
_confirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final form = _formKey.currentState;
|
||||
if (form == null || !form.validate()) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final displayName = _nameCtrl.text.trim();
|
||||
final email = _emailCtrl.text.trim();
|
||||
|
||||
await ref.read(authNotifierProvider.notifier).register(
|
||||
email: email,
|
||||
password: _passwordCtrl.text,
|
||||
displayName: displayName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final state = ref.read(authNotifierProvider);
|
||||
if (state.hasError) {
|
||||
final message = authErrorMessage(state.error!);
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth succeeded — write the matching Firestore profile so role-based
|
||||
// gates work immediately. Failures here are surfaced but don't block the
|
||||
// sign-in flow because the user is already authenticated.
|
||||
final user = state.valueOrNull;
|
||||
if (user != null) {
|
||||
try {
|
||||
await ref.read(profileRepositoryProvider).createProfile(
|
||||
UserProfile(
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
displayName: displayName.isEmpty
|
||||
? (user.displayName ?? '')
|
||||
: displayName,
|
||||
role: _selectedRole,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text('Could not create profile: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// On success, Firebase auth stream emits the new user, AuthNotifier
|
||||
// updates, and the router redirect sends us to /events.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final authState = ref.watch(authNotifierProvider);
|
||||
final isLoading = authState.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const WindedBrandHeader(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'JOIN THE LEAGUE',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF3A3A3A)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 3,
|
||||
decoration: const BoxDecoration(
|
||||
color: _purple,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
enabled: !isLoading,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
autofillHints: const [AutofillHints.name],
|
||||
textInputAction: TextInputAction.next,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'DISPLAY NAME',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (v) {
|
||||
final trimmed = v?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return 'Enter your name';
|
||||
}
|
||||
if (trimmed.length < 2) {
|
||||
return 'Name must be at least 2 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailCtrl,
|
||||
enabled: !isLoading,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: TextInputAction.next,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'EMAIL',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) {
|
||||
final trimmed = v?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return 'Enter your email';
|
||||
}
|
||||
final emailRegex = RegExp(
|
||||
r'^[^\s@]+@[^\s@]+\.[^\s@]+$',
|
||||
);
|
||||
if (!emailRegex.hasMatch(trimmed)) {
|
||||
return 'Enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
enabled: !isLoading,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
textInputAction: TextInputAction.next,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PASSWORD',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => setState(() {
|
||||
_obscurePassword =
|
||||
!_obscurePassword;
|
||||
}),
|
||||
),
|
||||
helperText: 'At least 6 characters',
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
return 'Enter a password';
|
||||
}
|
||||
if (v.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmCtrl,
|
||||
enabled: !isLoading,
|
||||
obscureText: _obscureConfirm,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'CONFIRM PASSWORD',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirm
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => setState(() {
|
||||
_obscureConfirm = !_obscureConfirm;
|
||||
}),
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) {
|
||||
return 'Confirm your password';
|
||||
}
|
||||
if (v != _passwordCtrl.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'I AM A',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 1.8,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<UserRole>(
|
||||
segments: const <ButtonSegment<UserRole>>[
|
||||
ButtonSegment<UserRole>(
|
||||
value: UserRole.player,
|
||||
label: Text('PLAYER'),
|
||||
icon: Icon(Icons.sports_soccer),
|
||||
),
|
||||
ButtonSegment<UserRole>(
|
||||
value: UserRole.manager,
|
||||
label: Text('MANAGER'),
|
||||
icon: Icon(Icons.shield_outlined),
|
||||
),
|
||||
],
|
||||
selected: <UserRole>{_selectedRole},
|
||||
onSelectionChanged: isLoading
|
||||
? null
|
||||
: (set) => setState(
|
||||
() => _selectedRole = set.first,
|
||||
),
|
||||
showSelectedIcon: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_selectedRole == UserRole.manager
|
||||
? 'Managers create and run a team. New teams '
|
||||
'require admin approval.'
|
||||
: 'Players have a personal profile and can '
|
||||
'request to join a team.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'CREATE ACCOUNT',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'ALREADY HAVE AN ACCOUNT? ',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => context.go('/login'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _purpleLight,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Text('SIGN IN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Brand header for the auth screens. Renders the Shadow Oak Pick Up
|
||||
/// circular badge logo, followed by the league wordmark in heavy
|
||||
/// uppercase type with wide letter spacing.
|
||||
class WindedBrandHeader extends StatelessWidget {
|
||||
const WindedBrandHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/shadow_oak_logo.jpg',
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'SHADOW OAK',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 4.0,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'PICK UP',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: const Color(0xFFBF77F6),
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 6.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../domain/bracket.dart';
|
||||
import '../infrastructure/brackets_repository.dart';
|
||||
|
||||
part 'brackets_notifier.g.dart';
|
||||
|
||||
/// Currently-selected bracket id used when navigating to the detail screen.
|
||||
@riverpod
|
||||
class SelectedBracketId extends _$SelectedBracketId {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void select(String? id) => state = id;
|
||||
}
|
||||
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
@riverpod
|
||||
Bracket? bracketById(BracketByIdRef ref, String id) {
|
||||
final brackets = ref.watch(bracketsStreamProvider).valueOrNull;
|
||||
if (brackets == null) return null;
|
||||
for (final bracket in brackets) {
|
||||
if (bracket.id == id) return bracket;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'brackets_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$bracketByIdHash() => r'c49c89b5fe87117266a8ca6c2c25009b0b290f60';
|
||||
|
||||
/// 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 [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
@ProviderFor(bracketById)
|
||||
const bracketByIdProvider = BracketByIdFamily();
|
||||
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
class BracketByIdFamily extends Family<Bracket?> {
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
const BracketByIdFamily();
|
||||
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
BracketByIdProvider call(String id) {
|
||||
return BracketByIdProvider(id);
|
||||
}
|
||||
|
||||
@override
|
||||
BracketByIdProvider getProviderOverride(
|
||||
covariant BracketByIdProvider 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'bracketByIdProvider';
|
||||
}
|
||||
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
class BracketByIdProvider extends AutoDisposeProvider<Bracket?> {
|
||||
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
|
||||
/// while loading or if no bracket matches.
|
||||
///
|
||||
/// Copied from [bracketById].
|
||||
BracketByIdProvider(String id)
|
||||
: this._internal(
|
||||
(ref) => bracketById(ref as BracketByIdRef, id),
|
||||
from: bracketByIdProvider,
|
||||
name: r'bracketByIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$bracketByIdHash,
|
||||
dependencies: BracketByIdFamily._dependencies,
|
||||
allTransitiveDependencies: BracketByIdFamily._allTransitiveDependencies,
|
||||
id: id,
|
||||
);
|
||||
|
||||
BracketByIdProvider._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(Bracket? Function(BracketByIdRef provider) create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: BracketByIdProvider._internal(
|
||||
(ref) => create(ref as BracketByIdRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
id: id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<Bracket?> createElement() {
|
||||
return _BracketByIdProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BracketByIdProvider && 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 BracketByIdRef on AutoDisposeProviderRef<Bracket?> {
|
||||
/// The parameter `id` of this provider.
|
||||
String get id;
|
||||
}
|
||||
|
||||
class _BracketByIdProviderElement extends AutoDisposeProviderElement<Bracket?>
|
||||
with BracketByIdRef {
|
||||
_BracketByIdProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get id => (origin as BracketByIdProvider).id;
|
||||
}
|
||||
|
||||
String _$selectedBracketIdHash() => r'1562a0b74ce4868ad5e49de98e5287551b7a423b';
|
||||
|
||||
/// Currently-selected bracket id used when navigating to the detail screen.
|
||||
///
|
||||
/// Copied from [SelectedBracketId].
|
||||
@ProviderFor(SelectedBracketId)
|
||||
final selectedBracketIdProvider =
|
||||
AutoDisposeNotifierProvider<SelectedBracketId, String?>.internal(
|
||||
SelectedBracketId.new,
|
||||
name: r'selectedBracketIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$selectedBracketIdHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SelectedBracketId = AutoDisposeNotifier<String?>;
|
||||
// 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,322 @@
|
||||
/// Lifecycle state of a single bracket match.
|
||||
enum MatchStatus { scheduled, inProgress, completed }
|
||||
|
||||
/// Lightweight team reference stored inline on a bracket match. The full team
|
||||
/// record (roster, record, etc.) lives in the teams feature; brackets only
|
||||
/// need an id, a display name, and an optional logo.
|
||||
class BracketTeam {
|
||||
const BracketTeam({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.logoUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? logoUrl;
|
||||
|
||||
BracketTeam copyWith({String? id, String? name, String? logoUrl}) {
|
||||
return BracketTeam(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
logoUrl: logoUrl ?? this.logoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
factory BracketTeam.fromMap(Map<String, dynamic> data) {
|
||||
return BracketTeam(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
name: (data['name'] as String?) ?? '',
|
||||
logoUrl: data['logo_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return <String, Object?>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'logo_url': logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is BracketTeam &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.logoUrl == logoUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, logoUrl);
|
||||
}
|
||||
|
||||
/// A single match within a bracket. Either team may be null while previous
|
||||
/// rounds are still being decided (a `null` team renders as "TBD").
|
||||
class BracketMatch {
|
||||
const BracketMatch({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.teamA,
|
||||
this.teamB,
|
||||
this.scoreA,
|
||||
this.scoreB,
|
||||
this.scheduledAt,
|
||||
this.winnerId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final BracketTeam? teamA;
|
||||
final BracketTeam? teamB;
|
||||
final int? scoreA;
|
||||
final int? scoreB;
|
||||
final MatchStatus status;
|
||||
final DateTime? scheduledAt;
|
||||
final String? winnerId;
|
||||
|
||||
bool get isTeamAWinner =>
|
||||
winnerId != null && teamA != null && winnerId == teamA!.id;
|
||||
bool get isTeamBWinner =>
|
||||
winnerId != null && teamB != null && winnerId == teamB!.id;
|
||||
|
||||
BracketMatch copyWith({
|
||||
String? id,
|
||||
BracketTeam? teamA,
|
||||
BracketTeam? teamB,
|
||||
int? scoreA,
|
||||
int? scoreB,
|
||||
MatchStatus? status,
|
||||
DateTime? scheduledAt,
|
||||
String? winnerId,
|
||||
}) {
|
||||
return BracketMatch(
|
||||
id: id ?? this.id,
|
||||
teamA: teamA ?? this.teamA,
|
||||
teamB: teamB ?? this.teamB,
|
||||
scoreA: scoreA ?? this.scoreA,
|
||||
scoreB: scoreB ?? this.scoreB,
|
||||
status: status ?? this.status,
|
||||
scheduledAt: scheduledAt ?? this.scheduledAt,
|
||||
winnerId: winnerId ?? this.winnerId,
|
||||
);
|
||||
}
|
||||
|
||||
factory BracketMatch.fromMap(Map<String, dynamic> data) {
|
||||
return BracketMatch(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
teamA: data['team_a'] is Map<String, dynamic>
|
||||
? BracketTeam.fromMap(data['team_a'] as Map<String, dynamic>)
|
||||
: null,
|
||||
teamB: data['team_b'] is Map<String, dynamic>
|
||||
? BracketTeam.fromMap(data['team_b'] as Map<String, dynamic>)
|
||||
: null,
|
||||
scoreA: (data['score_a'] as num?)?.toInt(),
|
||||
scoreB: (data['score_b'] as num?)?.toInt(),
|
||||
status: _readStatus(data['status']),
|
||||
scheduledAt: _readTimestamp(data['scheduled_at']),
|
||||
winnerId: data['winner_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return <String, Object?>{
|
||||
'id': id,
|
||||
'team_a': teamA?.toMap(),
|
||||
'team_b': teamB?.toMap(),
|
||||
'score_a': scoreA,
|
||||
'score_b': scoreB,
|
||||
'status': status.name,
|
||||
'scheduled_at': scheduledAt?.toIso8601String(),
|
||||
'winner_id': winnerId,
|
||||
};
|
||||
}
|
||||
|
||||
static MatchStatus _readStatus(Object? value) {
|
||||
if (value is String) {
|
||||
for (final s in MatchStatus.values) {
|
||||
if (s.name == value) return s;
|
||||
}
|
||||
}
|
||||
return MatchStatus.scheduled;
|
||||
}
|
||||
|
||||
static DateTime? _readTimestamp(Object? value) {
|
||||
if (value is String && value.isNotEmpty) return DateTime.tryParse(value);
|
||||
if (value is DateTime) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is BracketMatch &&
|
||||
other.id == id &&
|
||||
other.teamA == teamA &&
|
||||
other.teamB == teamB &&
|
||||
other.scoreA == scoreA &&
|
||||
other.scoreB == scoreB &&
|
||||
other.status == status &&
|
||||
other.scheduledAt == scheduledAt &&
|
||||
other.winnerId == winnerId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
teamA,
|
||||
teamB,
|
||||
scoreA,
|
||||
scoreB,
|
||||
status,
|
||||
scheduledAt,
|
||||
winnerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// A round (column) in a bracket — quarterfinals, semifinals, final, etc.
|
||||
class BracketRound {
|
||||
const BracketRound({
|
||||
required this.roundNumber,
|
||||
required this.label,
|
||||
required this.matches,
|
||||
});
|
||||
|
||||
final int roundNumber;
|
||||
final String label;
|
||||
final List<BracketMatch> matches;
|
||||
|
||||
BracketRound copyWith({
|
||||
int? roundNumber,
|
||||
String? label,
|
||||
List<BracketMatch>? matches,
|
||||
}) {
|
||||
return BracketRound(
|
||||
roundNumber: roundNumber ?? this.roundNumber,
|
||||
label: label ?? this.label,
|
||||
matches: matches ?? this.matches,
|
||||
);
|
||||
}
|
||||
|
||||
factory BracketRound.fromMap(Map<String, dynamic> data) {
|
||||
final rawMatches = (data['matches'] as List?) ?? const [];
|
||||
return BracketRound(
|
||||
roundNumber: (data['round_number'] as num?)?.toInt() ?? 0,
|
||||
label: (data['label'] as String?) ?? '',
|
||||
matches: rawMatches
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(BracketMatch.fromMap)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
return <String, Object?>{
|
||||
'round_number': roundNumber,
|
||||
'label': label,
|
||||
'matches': matches.map((m) => m.toMap()).toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! BracketRound) return false;
|
||||
if (other.roundNumber != roundNumber) return false;
|
||||
if (other.label != label) return false;
|
||||
if (other.matches.length != matches.length) return false;
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
if (other.matches[i] != matches[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(roundNumber, label, Object.hashAll(matches));
|
||||
}
|
||||
|
||||
/// Top-level bracket. A single event may have multiple brackets (e.g. main
|
||||
/// draw + consolation), so brackets carry an [eventId].
|
||||
class Bracket {
|
||||
const Bracket({
|
||||
required this.id,
|
||||
required this.eventId,
|
||||
required this.name,
|
||||
required this.rounds,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String eventId;
|
||||
final String name;
|
||||
final List<BracketRound> rounds;
|
||||
final DateTime createdAt;
|
||||
|
||||
Bracket copyWith({
|
||||
String? id,
|
||||
String? eventId,
|
||||
String? name,
|
||||
List<BracketRound>? rounds,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return Bracket(
|
||||
id: id ?? this.id,
|
||||
eventId: eventId ?? this.eventId,
|
||||
name: name ?? this.name,
|
||||
rounds: rounds ?? this.rounds,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory Bracket.fromJson(Map<String, dynamic> data) {
|
||||
final rawRounds = (data['rounds'] as List?) ?? const [];
|
||||
return Bracket(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
eventId: (data['event_id'] as String?) ?? '',
|
||||
name: (data['name'] as String?) ?? '',
|
||||
rounds: rawRounds
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(BracketRound.fromMap)
|
||||
.toList(growable: false),
|
||||
createdAt: _readDate(data['created_at']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'event_id': eventId,
|
||||
'name': name,
|
||||
'rounds': rounds.map((r) => r.toMap()).toList(growable: false),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static DateTime? _readDate(Object? value) {
|
||||
if (value is String && value.isNotEmpty) return DateTime.tryParse(value);
|
||||
if (value is DateTime) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! Bracket) return false;
|
||||
if (other.id != id) return false;
|
||||
if (other.eventId != eventId) return false;
|
||||
if (other.name != name) return false;
|
||||
if (other.createdAt != createdAt) return false;
|
||||
if (other.rounds.length != rounds.length) return false;
|
||||
for (var i = 0; i < rounds.length; i++) {
|
||||
if (other.rounds[i] != rounds[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, eventId, name, createdAt, Object.hashAll(rounds));
|
||||
|
||||
@override
|
||||
String toString() => 'Bracket(id: $id, name: $name, rounds: ${rounds.length})';
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/bracket.dart';
|
||||
|
||||
part 'brackets_repository.g.dart';
|
||||
|
||||
class BracketsRepository {
|
||||
BracketsRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<Bracket>> fetchBrackets() async {
|
||||
final data = await _api.get('/brackets/index.php');
|
||||
final list = (data['brackets'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Bracket.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<Bracket?> getBracket(String id) async {
|
||||
try {
|
||||
final data = await _api.get('/brackets/detail.php', params: {'id': id});
|
||||
return Bracket.fromJson(data);
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createBracket(Bracket bracket) async {
|
||||
final data = await _api.post('/brackets/index.php', bracket.toJson());
|
||||
return data['id'] as String;
|
||||
}
|
||||
|
||||
Future<Bracket> updateBracket(Bracket bracket) async {
|
||||
final data = await _api.put(
|
||||
'/brackets/detail.php',
|
||||
bracket.toJson(),
|
||||
params: {'id': bracket.id},
|
||||
);
|
||||
return Bracket.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deleteBracket(String id) async {
|
||||
await _api.delete('/brackets/detail.php', params: {'id': id});
|
||||
}
|
||||
|
||||
Future<void> updateMatch(
|
||||
String bracketId,
|
||||
String roundLabel,
|
||||
BracketMatch match,
|
||||
) async {
|
||||
final bracket = await getBracket(bracketId);
|
||||
if (bracket == null) return;
|
||||
final rounds = bracket.rounds.map((round) {
|
||||
if (round.label != roundLabel) return round;
|
||||
final updatedMatches = round.matches
|
||||
.map((m) => m.id == match.id ? match : m)
|
||||
.toList(growable: false);
|
||||
return round.copyWith(matches: updatedMatches);
|
||||
}).toList(growable: false);
|
||||
await updateBracket(bracket.copyWith(rounds: rounds));
|
||||
}
|
||||
|
||||
Stream<List<Bracket>> watchBrackets() async* {
|
||||
yield await fetchBrackets();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchBrackets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
BracketsRepository bracketsRepository(BracketsRepositoryRef ref) {
|
||||
return BracketsRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Stream<List<Bracket>> bracketsStream(BracketsStreamRef ref) {
|
||||
return ref.watch(bracketsRepositoryProvider).watchBrackets();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'brackets_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$bracketsRepositoryHash() =>
|
||||
r'942ebdb136bee1840c05c7d263e6a4e530cc2d4d';
|
||||
|
||||
/// See also [bracketsRepository].
|
||||
@ProviderFor(bracketsRepository)
|
||||
final bracketsRepositoryProvider = Provider<BracketsRepository>.internal(
|
||||
bracketsRepository,
|
||||
name: r'bracketsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$bracketsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BracketsRepositoryRef = ProviderRef<BracketsRepository>;
|
||||
String _$bracketsStreamHash() => r'72d5d17ad76cbfcf900c81d6bcf44f6678e52dfa';
|
||||
|
||||
/// Stream of brackets surfaced to the UI. Currently emits the mock list as a
|
||||
/// single tick — swap to `ref.watch(bracketsRepositoryProvider).watchBrackets()`
|
||||
/// once Firestore is seeded.
|
||||
///
|
||||
/// Copied from [bracketsStream].
|
||||
@ProviderFor(bracketsStream)
|
||||
final bracketsStreamProvider =
|
||||
AutoDisposeStreamProvider<List<Bracket>>.internal(
|
||||
bracketsStream,
|
||||
name: r'bracketsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$bracketsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BracketsStreamRef = AutoDisposeStreamProviderRef<List<Bracket>>;
|
||||
// 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,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../events/application/events_notifier.dart';
|
||||
import '../application/brackets_notifier.dart';
|
||||
import 'widgets/bracket_tree_widget.dart';
|
||||
|
||||
/// Full-screen view of a single bracket. Hosts the [BracketTreeWidget] in the
|
||||
/// body and shows the parent event's title in the AppBar subtitle when
|
||||
/// available.
|
||||
class BracketDetailScreen extends ConsumerWidget {
|
||||
const BracketDetailScreen({super.key, required this.bracketId});
|
||||
|
||||
final String bracketId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bracket = ref.watch(bracketByIdProvider(bracketId));
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
if (bracket == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bracket'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/brackets'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('Bracket not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
final event = ref.watch(eventByIdProvider(bracket.eventId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/brackets'),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
bracket.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (event != null)
|
||||
Text(
|
||||
event.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
tooltip: 'Share bracket',
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sharing brackets is coming soon.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: BracketTreeWidget(bracket: bracket),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
size: 18,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Scroll horizontally to see all rounds',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../domain/bracket.dart';
|
||||
import '../infrastructure/brackets_repository.dart';
|
||||
import 'widgets/bracket_tree_widget.dart';
|
||||
|
||||
/// Top-level Brackets tab.
|
||||
///
|
||||
/// Routing behavior:
|
||||
/// * No brackets → empty state.
|
||||
/// * Exactly one bracket → render its tree inline (the common case for the
|
||||
/// MVP, where each event has a single main draw).
|
||||
/// * Multiple brackets → list view, tap to drill into `/brackets/:id`.
|
||||
class BracketsScreen extends ConsumerWidget {
|
||||
const BracketsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bracketsAsync = ref.watch(bracketsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Brackets')),
|
||||
body: bracketsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(bracketsStreamProvider),
|
||||
),
|
||||
data: (brackets) {
|
||||
if (brackets.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
if (brackets.length == 1) {
|
||||
return _SingleBracketView(bracket: brackets.first);
|
||||
}
|
||||
return _BracketsList(brackets: brackets);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBracketView extends StatelessWidget {
|
||||
const _SingleBracketView({required this.bracket});
|
||||
|
||||
final Bracket bracket;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
bracket.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(child: BracketTreeWidget(bracket: bracket)),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
size: 18,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Scroll horizontally to see all rounds',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BracketsList extends StatelessWidget {
|
||||
const _BracketsList({required this.brackets});
|
||||
|
||||
final List<Bracket> brackets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final dateFormat = DateFormat('MMM d, y');
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: brackets.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final bracket = brackets[index];
|
||||
final totalMatches =
|
||||
bracket.rounds.fold<int>(0, (sum, r) => sum + r.matches.length);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/brackets/${bracket.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
child: const Icon(Icons.emoji_events),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
bracket.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${bracket.rounds.length} rounds · '
|
||||
'$totalMatches matches · '
|
||||
'Created ${dateFormat.format(bracket.createdAt)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.emoji_events_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No brackets yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tournament brackets will appear here once an event reaches '
|
||||
'its draw stage.',
|
||||
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 brackets',
|
||||
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,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/bracket.dart';
|
||||
import 'round_column.dart';
|
||||
|
||||
/// Renders a single-elimination bracket as a horizontal scrolling tree.
|
||||
///
|
||||
/// Geometry rules:
|
||||
/// * Each round is a [RoundColumn] of width [_columnWidth] (220px card area
|
||||
/// + 20px right gap = 240px).
|
||||
/// * Round 1 matches are evenly distributed across the available height.
|
||||
/// * Each subsequent round's match N is centered between matches 2N and
|
||||
/// 2N+1 of the previous round.
|
||||
/// * Connector lines are drawn behind the cards by [_ConnectorsPainter]:
|
||||
/// a horizontal stub leaves each match's right edge, then a vertical
|
||||
/// segment joins to the horizontal stub entering the next round's match.
|
||||
class BracketTreeWidget extends StatelessWidget {
|
||||
const BracketTreeWidget({super.key, required this.bracket});
|
||||
|
||||
final Bracket bracket;
|
||||
|
||||
// Layout constants.
|
||||
static const double _cardWidth = 200;
|
||||
static const double _cardHeight = 80;
|
||||
static const double _columnGap = 40;
|
||||
static const double _columnWidth = _cardWidth + _columnGap; // 240
|
||||
static const double _matchSlotHeight = 120; // card + status line + spacing
|
||||
static const double _verticalPadding = 24;
|
||||
static const double _labelHeight = RoundColumn.labelHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rounds = bracket.rounds;
|
||||
if (rounds.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'This bracket has no rounds yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final firstRoundMatches = rounds.first.matches.length.clamp(1, 1024);
|
||||
|
||||
// Total drawable height inside the column body (below the round label).
|
||||
final bodyHeight = _matchSlotHeight * firstRoundMatches;
|
||||
final columnHeight = bodyHeight + _labelHeight + _verticalPadding * 2;
|
||||
final totalWidth = rounds.length * _columnWidth;
|
||||
|
||||
// Compute card centers per round, in local column-body coordinates
|
||||
// (i.e. y measured from the top of the Stack that holds the cards).
|
||||
final centersByRound = <List<double>>[];
|
||||
for (var r = 0; r < rounds.length; r++) {
|
||||
final matchCount = rounds[r].matches.length;
|
||||
if (r == 0) {
|
||||
// Evenly distribute round 1.
|
||||
final slot = bodyHeight / matchCount;
|
||||
centersByRound.add([
|
||||
for (var i = 0; i < matchCount; i++)
|
||||
_verticalPadding + slot * (i + 0.5),
|
||||
]);
|
||||
} else {
|
||||
// Each match centered between its two feeders from previous round.
|
||||
final prev = centersByRound[r - 1];
|
||||
final centers = <double>[];
|
||||
for (var i = 0; i < matchCount; i++) {
|
||||
final a = i * 2;
|
||||
final b = a + 1;
|
||||
if (b < prev.length) {
|
||||
centers.add((prev[a] + prev[b]) / 2);
|
||||
} else if (a < prev.length) {
|
||||
centers.add(prev[a]);
|
||||
} else {
|
||||
centers.add(_verticalPadding + bodyHeight / 2);
|
||||
}
|
||||
}
|
||||
centersByRound.add(centers);
|
||||
}
|
||||
}
|
||||
|
||||
final connectorColor = Theme.of(context).colorScheme.outlineVariant;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: totalWidth,
|
||||
height: columnHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Connector lines drawn first so they sit behind the cards.
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _ConnectorsPainter(
|
||||
rounds: rounds,
|
||||
centersByRound: centersByRound,
|
||||
columnWidth: _columnWidth,
|
||||
cardWidth: _cardWidth,
|
||||
labelHeight: _labelHeight,
|
||||
color: connectorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var r = 0; r < rounds.length; r++)
|
||||
RoundColumn(
|
||||
round: rounds[r],
|
||||
cardCenters: centersByRound[r],
|
||||
columnWidth: _columnWidth,
|
||||
cardWidth: _cardWidth,
|
||||
cardHeight: _cardHeight,
|
||||
columnHeight: columnHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectorsPainter extends CustomPainter {
|
||||
_ConnectorsPainter({
|
||||
required this.rounds,
|
||||
required this.centersByRound,
|
||||
required this.columnWidth,
|
||||
required this.cardWidth,
|
||||
required this.labelHeight,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final List<BracketRound> rounds;
|
||||
final List<List<double>> centersByRound;
|
||||
final double columnWidth;
|
||||
final double cardWidth;
|
||||
final double labelHeight;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// For each pair of adjacent rounds, draw connectors from every match in
|
||||
// the earlier round into its corresponding match in the later round.
|
||||
for (var r = 0; r < rounds.length - 1; r++) {
|
||||
final left = centersByRound[r];
|
||||
final right = centersByRound[r + 1];
|
||||
|
||||
// Card horizontal bounds for this column.
|
||||
final colLeftX = r * columnWidth;
|
||||
final cardRightX = colLeftX + (columnWidth + cardWidth) / 2;
|
||||
|
||||
final nextColLeftX = (r + 1) * columnWidth;
|
||||
final nextCardLeftX = nextColLeftX + (columnWidth - cardWidth) / 2;
|
||||
|
||||
final midX = (cardRightX + nextCardLeftX) / 2;
|
||||
|
||||
for (var i = 0; i < left.length; i++) {
|
||||
// Pair index in next round.
|
||||
final next = i ~/ 2;
|
||||
if (next >= right.length) continue;
|
||||
|
||||
final fromY = left[i] + labelHeight;
|
||||
final toY = right[next] + labelHeight;
|
||||
|
||||
// Right stub from card.
|
||||
canvas.drawLine(Offset(cardRightX, fromY), Offset(midX, fromY), paint);
|
||||
// Vertical segment connecting siblings.
|
||||
canvas.drawLine(Offset(midX, fromY), Offset(midX, toY), paint);
|
||||
// Left stub into next round's card.
|
||||
canvas.drawLine(
|
||||
Offset(midX, toY),
|
||||
Offset(nextCardLeftX, toY),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ConnectorsPainter old) {
|
||||
return old.rounds != rounds ||
|
||||
old.centersByRound != centersByRound ||
|
||||
old.color != color ||
|
||||
old.columnWidth != columnWidth ||
|
||||
old.cardWidth != cardWidth ||
|
||||
old.labelHeight != labelHeight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/bracket.dart';
|
||||
|
||||
/// Compact card representing a single [BracketMatch] inside the bracket tree.
|
||||
///
|
||||
/// Fixed 200x80 footprint so the bracket tree can lay matches out with
|
||||
/// predictable geometry. If the match is scheduled, an additional date line
|
||||
/// is rendered directly beneath the card.
|
||||
class MatchCard extends StatelessWidget {
|
||||
const MatchCard({
|
||||
super.key,
|
||||
required this.match,
|
||||
this.width = 200,
|
||||
this.height = 80,
|
||||
});
|
||||
|
||||
final BracketMatch match;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamRow(
|
||||
team: match.teamA,
|
||||
score: match.scoreA,
|
||||
isWinner: match.isTeamAWinner,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _TeamRow(
|
||||
team: match.teamB,
|
||||
score: match.scoreB,
|
||||
isWinner: match.isTeamBWinner,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_StatusLine(match: match, width: width),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamRow extends StatelessWidget {
|
||||
const _TeamRow({
|
||||
required this.team,
|
||||
required this.score,
|
||||
required this.isWinner,
|
||||
});
|
||||
|
||||
final BracketTeam? team;
|
||||
final int? score;
|
||||
final bool isWinner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final name = team?.name ?? 'TBD';
|
||||
final nameStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isWinner ? FontWeight.w700 : FontWeight.w500,
|
||||
color: team == null
|
||||
? scheme.onSurfaceVariant
|
||||
: (isWinner ? scheme.onPrimaryContainer : scheme.onSurface),
|
||||
fontStyle: team == null ? FontStyle.italic : FontStyle.normal,
|
||||
);
|
||||
final scoreStyle = theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: isWinner ? FontWeight.w800 : FontWeight.w600,
|
||||
color: isWinner ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: isWinner ? scheme.primaryContainer : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: nameStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
score?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: scoreStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.match, required this.width});
|
||||
|
||||
final BracketMatch match;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final (Color dot, String label) = switch (match.status) {
|
||||
MatchStatus.completed => (Colors.green.shade600, 'Final'),
|
||||
MatchStatus.inProgress => (Colors.amber.shade700, 'Live'),
|
||||
MatchStatus.scheduled => (scheme.outline, _scheduledLabel(match)),
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _scheduledLabel(BracketMatch match) {
|
||||
final scheduled = match.scheduledAt;
|
||||
if (scheduled == null) return 'Scheduled';
|
||||
return DateFormat('MMM d · h:mm a').format(scheduled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/bracket.dart';
|
||||
import 'match_card.dart';
|
||||
|
||||
/// A single column in the bracket tree: a round label at the top, then a
|
||||
/// vertical stack of [MatchCard]s positioned according to the bracket
|
||||
/// geometry computed by [BracketTreeWidget].
|
||||
///
|
||||
/// The widget itself does not compute spacing — its parent supplies a
|
||||
/// per-card vertical offset so all rounds align even when match counts
|
||||
/// differ between columns.
|
||||
class RoundColumn extends StatelessWidget {
|
||||
const RoundColumn({
|
||||
super.key,
|
||||
required this.round,
|
||||
required this.cardCenters,
|
||||
required this.columnWidth,
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
required this.columnHeight,
|
||||
});
|
||||
|
||||
final BracketRound round;
|
||||
|
||||
/// Vertical center y-coordinate (in this column's local space) for each
|
||||
/// match card. Same length as `round.matches`.
|
||||
final List<double> cardCenters;
|
||||
|
||||
final double columnWidth;
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
final double columnHeight;
|
||||
|
||||
static const double labelHeight = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: columnWidth,
|
||||
height: columnHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: labelHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
round.label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = 0; i < round.matches.length; i++)
|
||||
Positioned(
|
||||
left: (columnWidth - cardWidth) / 2,
|
||||
top: cardCenters[i] - cardHeight / 2,
|
||||
width: cardWidth,
|
||||
child: MatchCard(
|
||||
match: round.matches[i],
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../domain/event.dart';
|
||||
import '../infrastructure/events_repository.dart';
|
||||
|
||||
part 'events_notifier.g.dart';
|
||||
|
||||
/// Holds the currently-selected event id used when navigating to the detail
|
||||
/// screen. Null when no event is selected.
|
||||
@riverpod
|
||||
class SelectedEventId extends _$SelectedEventId {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void select(String? id) => state = id;
|
||||
}
|
||||
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
@riverpod
|
||||
Event? eventById(EventByIdRef ref, String id) {
|
||||
final events = ref.watch(eventsStreamProvider).valueOrNull;
|
||||
if (events == null) return null;
|
||||
for (final event in events) {
|
||||
if (event.id == id) return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'events_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$eventByIdHash() => r'8717d386b9cf44631b1bc606aedab99c63636b33';
|
||||
|
||||
/// 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 [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
@ProviderFor(eventById)
|
||||
const eventByIdProvider = EventByIdFamily();
|
||||
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
class EventByIdFamily extends Family<Event?> {
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
const EventByIdFamily();
|
||||
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
EventByIdProvider call(String id) {
|
||||
return EventByIdProvider(id);
|
||||
}
|
||||
|
||||
@override
|
||||
EventByIdProvider getProviderOverride(covariant EventByIdProvider 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'eventByIdProvider';
|
||||
}
|
||||
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
class EventByIdProvider extends AutoDisposeProvider<Event?> {
|
||||
/// Resolves a single [Event] by id out of the events stream. Returns null
|
||||
/// while loading or if no event matches.
|
||||
///
|
||||
/// Copied from [eventById].
|
||||
EventByIdProvider(String id)
|
||||
: this._internal(
|
||||
(ref) => eventById(ref as EventByIdRef, id),
|
||||
from: eventByIdProvider,
|
||||
name: r'eventByIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$eventByIdHash,
|
||||
dependencies: EventByIdFamily._dependencies,
|
||||
allTransitiveDependencies: EventByIdFamily._allTransitiveDependencies,
|
||||
id: id,
|
||||
);
|
||||
|
||||
EventByIdProvider._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(Event? Function(EventByIdRef provider) create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: EventByIdProvider._internal(
|
||||
(ref) => create(ref as EventByIdRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
id: id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<Event?> createElement() {
|
||||
return _EventByIdProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is EventByIdProvider && 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 EventByIdRef on AutoDisposeProviderRef<Event?> {
|
||||
/// The parameter `id` of this provider.
|
||||
String get id;
|
||||
}
|
||||
|
||||
class _EventByIdProviderElement extends AutoDisposeProviderElement<Event?>
|
||||
with EventByIdRef {
|
||||
_EventByIdProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get id => (origin as EventByIdProvider).id;
|
||||
}
|
||||
|
||||
String _$selectedEventIdHash() => r'6d48c24938e4ca7c60317e72cfee3bd87823b2cb';
|
||||
|
||||
/// Holds the currently-selected event id used when navigating to the detail
|
||||
/// screen. Null when no event is selected.
|
||||
///
|
||||
/// Copied from [SelectedEventId].
|
||||
@ProviderFor(SelectedEventId)
|
||||
final selectedEventIdProvider =
|
||||
AutoDisposeNotifierProvider<SelectedEventId, String?>.internal(
|
||||
SelectedEventId.new,
|
||||
name: r'selectedEventIdProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$selectedEventIdHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SelectedEventId = AutoDisposeNotifier<String?>;
|
||||
// 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,129 @@
|
||||
enum EventCategory { tournament, pickup }
|
||||
|
||||
class Event {
|
||||
const Event({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.date,
|
||||
required this.location,
|
||||
required this.registrationDeadline,
|
||||
required this.teamsRegistered,
|
||||
required this.maxTeams,
|
||||
this.category = EventCategory.pickup,
|
||||
this.imageUrl,
|
||||
this.isCancelled = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime date;
|
||||
final String location;
|
||||
final DateTime registrationDeadline;
|
||||
final int teamsRegistered;
|
||||
final int maxTeams;
|
||||
final EventCategory category;
|
||||
final String? imageUrl;
|
||||
final bool isCancelled;
|
||||
|
||||
Event copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DateTime? date,
|
||||
String? location,
|
||||
DateTime? registrationDeadline,
|
||||
int? teamsRegistered,
|
||||
int? maxTeams,
|
||||
EventCategory? category,
|
||||
String? imageUrl,
|
||||
bool? isCancelled,
|
||||
}) {
|
||||
return Event(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
date: date ?? this.date,
|
||||
location: location ?? this.location,
|
||||
registrationDeadline: registrationDeadline ?? this.registrationDeadline,
|
||||
teamsRegistered: teamsRegistered ?? this.teamsRegistered,
|
||||
maxTeams: maxTeams ?? this.maxTeams,
|
||||
category: category ?? this.category,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
isCancelled: isCancelled ?? this.isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
factory Event.fromJson(Map<String, dynamic> data) {
|
||||
return Event(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
title: (data['title'] as String?) ?? '',
|
||||
description: (data['description'] as String?) ?? '',
|
||||
date: _parseDate(data['event_date']) ?? DateTime.now(),
|
||||
location: (data['location'] as String?) ?? '',
|
||||
registrationDeadline:
|
||||
_parseDate(data['registration_deadline']) ?? DateTime.now(),
|
||||
teamsRegistered: (data['teams_registered'] as num?)?.toInt() ?? 0,
|
||||
maxTeams: (data['max_teams'] as num?)?.toInt() ?? 0,
|
||||
category: (data['category'] as String?) == 'tournament'
|
||||
? EventCategory.tournament
|
||||
: EventCategory.pickup,
|
||||
imageUrl: data['image_url'] as String?,
|
||||
isCancelled: _parseBool(data['is_cancelled']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'title': title,
|
||||
'description': description,
|
||||
'event_date': date.toIso8601String(),
|
||||
'location': location,
|
||||
'registration_deadline': registrationDeadline.toIso8601String(),
|
||||
'max_teams': maxTeams,
|
||||
'category': category.name,
|
||||
'image_url': imageUrl,
|
||||
'is_cancelled': isCancelled ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(Object? v) {
|
||||
if (v is String && v.isNotEmpty) return DateTime.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _parseBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is int) return v != 0;
|
||||
if (v is String) return v == '1' || v.toLowerCase() == 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Event &&
|
||||
other.id == id &&
|
||||
other.title == title &&
|
||||
other.description == description &&
|
||||
other.date == date &&
|
||||
other.location == location &&
|
||||
other.registrationDeadline == registrationDeadline &&
|
||||
other.teamsRegistered == teamsRegistered &&
|
||||
other.maxTeams == maxTeams &&
|
||||
other.category == category &&
|
||||
other.imageUrl == imageUrl &&
|
||||
other.isCancelled == isCancelled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, title, description, date, location,
|
||||
registrationDeadline, teamsRegistered, maxTeams,
|
||||
category, imageUrl, isCancelled,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Event(id: $id, title: $title, date: $date)';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/event.dart';
|
||||
|
||||
part 'events_repository.g.dart';
|
||||
|
||||
class EventsRepository {
|
||||
EventsRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<Event>> fetchEvents() async {
|
||||
final data = await _api.get('/events/index.php');
|
||||
final list = (data['events'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Event.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<Event?> getEvent(String id) async {
|
||||
try {
|
||||
final data = await _api.get('/events/detail.php', params: {'id': id});
|
||||
return Event.fromJson(data);
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createEvent(Event event) async {
|
||||
final data = await _api.post('/events/index.php', event.toJson());
|
||||
return data['id'] as String;
|
||||
}
|
||||
|
||||
Future<void> updateEvent(Event event) async {
|
||||
await _api.put('/events/detail.php', event.toJson(), params: {'id': event.id});
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(String id) async {
|
||||
await _api.delete('/events/detail.php', params: {'id': id});
|
||||
}
|
||||
|
||||
Future<bool> isRegistered(String eventId) async {
|
||||
final data = await _api.get(
|
||||
'/events/register.php',
|
||||
params: {'event_id': eventId},
|
||||
);
|
||||
return (data['registered'] as bool?) ?? false;
|
||||
}
|
||||
|
||||
Future<void> register(String eventId) async {
|
||||
await _api.post('/events/register.php', {'event_id': eventId});
|
||||
}
|
||||
|
||||
Future<void> unregister(String eventId) async {
|
||||
await _api.delete('/events/register.php', params: {'event_id': eventId});
|
||||
}
|
||||
|
||||
Stream<List<Event>> watchEvents() async* {
|
||||
yield await fetchEvents();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
EventsRepository eventsRepository(EventsRepositoryRef ref) {
|
||||
return EventsRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Stream<List<Event>> eventsStream(EventsStreamRef ref) {
|
||||
return ref.watch(eventsRepositoryProvider).watchEvents();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'events_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$eventsRepositoryHash() => r'753d76dd8556bce50755088a8ea0a611bab61d34';
|
||||
|
||||
/// See also [eventsRepository].
|
||||
@ProviderFor(eventsRepository)
|
||||
final eventsRepositoryProvider = Provider<EventsRepository>.internal(
|
||||
eventsRepository,
|
||||
name: r'eventsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$eventsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef EventsRepositoryRef = ProviderRef<EventsRepository>;
|
||||
String _$eventsStreamHash() => r'50b8c367793996c2c0fa894fd2694eefbdf4135b';
|
||||
|
||||
/// Stream of events surfaced to the UI.
|
||||
///
|
||||
/// Currently emits [EventsRepository.mockEvents] as a single tick so the
|
||||
/// screens render real-looking content without needing Firestore to be
|
||||
/// seeded. Swap this to `ref.watch(eventsRepositoryProvider).watchEvents()`
|
||||
/// once the collection has data.
|
||||
///
|
||||
/// Copied from [eventsStream].
|
||||
@ProviderFor(eventsStream)
|
||||
final eventsStreamProvider = AutoDisposeStreamProvider<List<Event>>.internal(
|
||||
eventsStream,
|
||||
name: r'eventsStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$eventsStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef EventsStreamRef = AutoDisposeStreamProviderRef<List<Event>>;
|
||||
// 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,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../application/events_notifier.dart';
|
||||
import '../domain/event.dart';
|
||||
import '../infrastructure/events_repository.dart';
|
||||
import 'widgets/countdown_timer.dart';
|
||||
import 'widgets/registration_button.dart';
|
||||
|
||||
class EventDetailScreen extends ConsumerWidget {
|
||||
const EventDetailScreen({super.key, required this.eventId});
|
||||
|
||||
final String eventId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final eventsAsync = ref.watch(eventsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/events');
|
||||
}
|
||||
},
|
||||
),
|
||||
title: const Text('Event details'),
|
||||
),
|
||||
body: eventsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _NotFound(
|
||||
message: 'Could not load event: $error',
|
||||
),
|
||||
data: (_) {
|
||||
final event = ref.watch(eventByIdProvider(eventId));
|
||||
if (event == null) {
|
||||
return const _NotFound(message: 'Event not found.');
|
||||
}
|
||||
return _EventDetailBody(event: event);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventDetailBody extends StatelessWidget {
|
||||
const _EventDetailBody({required this.event});
|
||||
|
||||
final Event event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final dateLabel =
|
||||
DateFormat('EEEE, MMMM d, y · h:mm a').format(event.date);
|
||||
final deadlineLabel =
|
||||
DateFormat('EEE, MMM d · h:mm a').format(event.registrationDeadline);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 720;
|
||||
final horizontalPadding = isWide ? 32.0 : 16.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(
|
||||
event: event,
|
||||
dateLabel: dateLabel,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CountdownTimer(
|
||||
target: event.date,
|
||||
compact: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'About this event',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
event.description,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
height: 1.45,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_RegistrationSection(
|
||||
event: event,
|
||||
deadlineLabel: deadlineLabel,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.event, required this.dateLabel});
|
||||
|
||||
final Event event;
|
||||
final String dateLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (event.isCancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.error,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Cancelled',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onError,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
event.title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 18,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.place_outlined,
|
||||
size: 18,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.location,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RegistrationSection extends StatelessWidget {
|
||||
const _RegistrationSection({
|
||||
required this.event,
|
||||
required this.deadlineLabel,
|
||||
});
|
||||
|
||||
final Event event;
|
||||
final String deadlineLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final ratio = event.maxTeams == 0
|
||||
? 0.0
|
||||
: (event.teamsRegistered / event.maxTeams).clamp(0.0, 1.0).toDouble();
|
||||
final deadlinePassed =
|
||||
DateTime.now().isAfter(event.registrationDeadline);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Registration',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.groups_outlined, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${event.teamsRegistered} / ${event.maxTeams} teams',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 8,
|
||||
backgroundColor: scheme.surfaceContainer,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(scheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer_off_outlined,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
deadlinePassed
|
||||
? 'Registration closed $deadlineLabel'
|
||||
: 'Registration closes $deadlineLabel',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RegistrationButton(
|
||||
fullWidth: true,
|
||||
enabled: !deadlinePassed && !event.isCancelled,
|
||||
),
|
||||
if (event.maxTeams > 0 &&
|
||||
event.teamsRegistered >= event.maxTeams) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Preferred headcount reached — extras are still welcome to drop in.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotFound extends StatelessWidget {
|
||||
const _NotFound({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: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(message, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/events'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Back to events'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../domain/event.dart';
|
||||
import '../infrastructure/events_repository.dart';
|
||||
import 'widgets/event_card.dart';
|
||||
|
||||
class EventsScreen extends ConsumerWidget {
|
||||
const EventsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final eventsAsync = ref.watch(eventsStreamProvider);
|
||||
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Events'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search & filter',
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: <Tab>[
|
||||
Tab(text: 'ALL'),
|
||||
Tab(text: 'TOURNAMENTS'),
|
||||
Tab(text: 'PICK-UP'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: eventsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(eventsStreamProvider),
|
||||
),
|
||||
data: (events) {
|
||||
return TabBarView(
|
||||
children: <Widget>[
|
||||
_EventsList(
|
||||
events: events,
|
||||
onRefresh: () => ref.invalidate(eventsStreamProvider),
|
||||
),
|
||||
_EventsList(
|
||||
events: events
|
||||
.where((e) => e.category == EventCategory.tournament)
|
||||
.toList(growable: false),
|
||||
onRefresh: () => ref.invalidate(eventsStreamProvider),
|
||||
),
|
||||
_EventsList(
|
||||
events: events
|
||||
.where((e) => e.category == EventCategory.pickup)
|
||||
.toList(growable: false),
|
||||
onRefresh: () => ref.invalidate(eventsStreamProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventsList extends StatelessWidget {
|
||||
const _EventsList({required this.events, required this.onRefresh});
|
||||
|
||||
final List<Event> events;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (events.isEmpty) return const _EmptyState();
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => onRefresh(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) => EventCard(event: events[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.sports_soccer,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('No events scheduled', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Check back soon — new pick-up games and tournaments are posted regularly.',
|
||||
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 events', 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,147 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Live-updating countdown to a target [DateTime].
|
||||
///
|
||||
/// Rebuilds once per second and renders one of:
|
||||
/// * "in 3d 4h" — when more than a day out
|
||||
/// * "in 4h 12m" — when same-day
|
||||
/// * "in 12m 30s" — within the hour
|
||||
/// * "Starting now!" — within the final minute window
|
||||
/// * "Ended" — once the target has passed by more than the [grace] window
|
||||
///
|
||||
/// Pass [compact] true to render only the duration text (used in cards);
|
||||
/// false renders a labelled card-friendly block (used on the detail screen).
|
||||
class CountdownTimer extends StatefulWidget {
|
||||
const CountdownTimer({
|
||||
super.key,
|
||||
required this.target,
|
||||
this.compact = true,
|
||||
this.grace = const Duration(minutes: 60),
|
||||
});
|
||||
|
||||
final DateTime target;
|
||||
final bool compact;
|
||||
|
||||
/// How long after [target] we still show "Starting now!" before flipping
|
||||
/// to "Ended". Defaults to an hour so an in-progress match stays visible.
|
||||
final Duration grace;
|
||||
|
||||
@override
|
||||
State<CountdownTimer> createState() => _CountdownTimerState();
|
||||
}
|
||||
|
||||
class _CountdownTimerState extends State<CountdownTimer> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final now = DateTime.now();
|
||||
final diff = widget.target.difference(now);
|
||||
|
||||
final label = _formatLabel(diff);
|
||||
final isLive = diff.isNegative && diff.abs() < widget.grace;
|
||||
final isEnded = diff.isNegative && diff.abs() >= widget.grace;
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
if (isEnded) {
|
||||
bg = scheme.surfaceContainerHighest;
|
||||
fg = scheme.onSurfaceVariant;
|
||||
} else if (isLive) {
|
||||
bg = scheme.tertiaryContainer;
|
||||
fg = scheme.onTertiaryContainer;
|
||||
} else {
|
||||
bg = scheme.primaryContainer;
|
||||
fg = scheme.onPrimaryContainer;
|
||||
}
|
||||
|
||||
if (widget.compact) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isEnded
|
||||
? Icons.event_busy
|
||||
: isLive
|
||||
? Icons.sports_soccer
|
||||
: Icons.timer_outlined,
|
||||
color: fg,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLabel(Duration diff) {
|
||||
if (diff.isNegative) {
|
||||
if (diff.abs() < widget.grace) return 'Starting now!';
|
||||
return 'Ended';
|
||||
}
|
||||
if (diff.inSeconds <= 60) return 'Starting now!';
|
||||
|
||||
if (diff.inDays >= 1) {
|
||||
final days = diff.inDays;
|
||||
final hours = diff.inHours - days * 24;
|
||||
if (hours == 0) {
|
||||
return 'in ${days}d';
|
||||
}
|
||||
return 'in ${days}d ${hours}h';
|
||||
}
|
||||
if (diff.inHours >= 1) {
|
||||
final hours = diff.inHours;
|
||||
final minutes = diff.inMinutes - hours * 60;
|
||||
return 'in ${hours}h ${minutes}m';
|
||||
}
|
||||
final minutes = diff.inMinutes;
|
||||
final seconds = diff.inSeconds - minutes * 60;
|
||||
return 'in ${minutes}m ${seconds}s';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/event.dart';
|
||||
import 'countdown_timer.dart';
|
||||
|
||||
/// Material 3 card representing a single [Event] in the events list.
|
||||
///
|
||||
/// Tap navigates to `/events/:id`. Visual emphasis is given to the title,
|
||||
/// the countdown chip, and the registration headcount.
|
||||
class EventCard extends StatelessWidget {
|
||||
const EventCard({super.key, required this.event});
|
||||
|
||||
final Event event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final dateLabel = DateFormat('EEE, MMM d · h:mm a').format(event.date);
|
||||
final isFull =
|
||||
event.teamsRegistered >= event.maxTeams && event.maxTeams > 0;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/events/${event.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CountdownTimer(target: event.date),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_CategoryChip(category: event.category),
|
||||
if (event.isCancelled) ...<Widget>[
|
||||
const SizedBox(width: 8),
|
||||
_CancelledChip(scheme: scheme),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_IconRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
color: scheme.onSurfaceVariant,
|
||||
child: Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_IconRow(
|
||||
icon: Icons.place_outlined,
|
||||
color: scheme.onSurfaceVariant,
|
||||
child: Text(
|
||||
event.location,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.groups_outlined, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${event.teamsRegistered} / ${event.maxTeams} teams',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isFull)
|
||||
Text(
|
||||
'Preferred count reached',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconRow extends StatelessWidget {
|
||||
const _IconRow({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelledChip extends StatelessWidget {
|
||||
const _CancelledChip({required this.scheme});
|
||||
|
||||
final ColorScheme scheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Cancelled',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
const _CategoryChip({required this.category});
|
||||
|
||||
final EventCategory category;
|
||||
|
||||
static const Color _tournamentColor = Color(0xFF8B30C8);
|
||||
static const Color _pickupColor = Color(0xFF26A69A);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isTournament = category == EventCategory.tournament;
|
||||
final color = isTournament ? _tournamentColor : _pickupColor;
|
||||
final label = isTournament ? 'TOURNAMENT' : 'PICK-UP';
|
||||
final icon = isTournament
|
||||
? Icons.emoji_events_outlined
|
||||
: Icons.sports_soccer;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.55)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 13, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Toggle button representing the current user's registration state for an
|
||||
/// event. Local-state only for now — a future revision will wire this to
|
||||
/// Firestore via the events repository.
|
||||
class RegistrationButton extends StatefulWidget {
|
||||
const RegistrationButton({
|
||||
super.key,
|
||||
this.initiallyRegistered = false,
|
||||
this.enabled = true,
|
||||
this.fullWidth = false,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final bool initiallyRegistered;
|
||||
final bool enabled;
|
||||
final bool fullWidth;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
State<RegistrationButton> createState() => _RegistrationButtonState();
|
||||
}
|
||||
|
||||
class _RegistrationButtonState extends State<RegistrationButton> {
|
||||
late bool _registered;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registered = widget.initiallyRegistered;
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _registered = !_registered);
|
||||
widget.onChanged?.call(_registered);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
final child = _registered
|
||||
? OutlinedButton.icon(
|
||||
onPressed: widget.enabled ? _toggle : null,
|
||||
icon: Icon(Icons.check_circle, color: scheme.primary),
|
||||
label: const Text('Registered'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: scheme.primary,
|
||||
side: BorderSide(color: scheme.primary),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
)
|
||||
: FilledButton.icon(
|
||||
onPressed: widget.enabled ? _toggle : null,
|
||||
icon: const Icon(Icons.how_to_reg),
|
||||
label: const Text('Register'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.fullWidth) {
|
||||
return SizedBox(width: double.infinity, child: child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Immutable domain model for a highlight video entry on the Media screen.
|
||||
///
|
||||
/// [thumbnailUrl] is nullable so the UI can render a placeholder when no
|
||||
/// thumbnail is available — common while highlights are still being uploaded.
|
||||
class Highlight {
|
||||
const Highlight({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.youtubeUrl,
|
||||
required this.publishedAt,
|
||||
this.thumbnailUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String youtubeUrl;
|
||||
final String? thumbnailUrl;
|
||||
final DateTime publishedAt;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! Highlight) return false;
|
||||
return other.id == id &&
|
||||
other.title == title &&
|
||||
other.description == description &&
|
||||
other.youtubeUrl == youtubeUrl &&
|
||||
other.thumbnailUrl == thumbnailUrl &&
|
||||
other.publishedAt == publishedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
youtubeUrl,
|
||||
thumbnailUrl,
|
||||
publishedAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Highlight(id: $id, title: $title)';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// Social platforms surfaced on the Media screen. The enum values are stable
|
||||
/// identifiers used for icon mapping and snackbar copy.
|
||||
enum SocialPlatform { instagram, youtube, twitter, tiktok }
|
||||
|
||||
/// Immutable domain model for a single social media link card on the Media
|
||||
/// screen. Pairs a [platform] with the community's [handle], a deep [url],
|
||||
/// and a friendly [displayName].
|
||||
class MediaLink {
|
||||
const MediaLink({
|
||||
required this.platform,
|
||||
required this.handle,
|
||||
required this.url,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
final SocialPlatform platform;
|
||||
final String handle;
|
||||
final String url;
|
||||
final String displayName;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! MediaLink) return false;
|
||||
return other.platform == platform &&
|
||||
other.handle == handle &&
|
||||
other.url == url &&
|
||||
other.displayName == displayName;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(platform, handle, url, displayName);
|
||||
|
||||
@override
|
||||
String toString() => 'MediaLink($platform, $handle)';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import '../domain/highlight.dart';
|
||||
import '../domain/media_link.dart';
|
||||
|
||||
/// Repository for the Media screen content.
|
||||
///
|
||||
/// All content is static for the MVP — social handles and highlight metadata
|
||||
/// rarely change and don't justify a Firestore round-trip. Future Phase 2
|
||||
/// work can swap these getters for a Firestore-backed source if needed
|
||||
/// (e.g. an admin-editable `media_links` collection).
|
||||
class MediaRepository {
|
||||
const MediaRepository();
|
||||
|
||||
/// Social media accounts featured at the top of the Media screen.
|
||||
static const List<MediaLink> socialLinks = <MediaLink>[
|
||||
MediaLink(
|
||||
platform: SocialPlatform.instagram,
|
||||
handle: '@windedfc_official',
|
||||
url: 'https://instagram.com/windedfc_official',
|
||||
displayName: 'Instagram',
|
||||
),
|
||||
MediaLink(
|
||||
platform: SocialPlatform.youtube,
|
||||
handle: 'Winded FC',
|
||||
url: 'https://youtube.com/@windedfc',
|
||||
displayName: 'YouTube',
|
||||
),
|
||||
MediaLink(
|
||||
platform: SocialPlatform.twitter,
|
||||
handle: '@windedfc',
|
||||
url: 'https://twitter.com/windedfc',
|
||||
displayName: 'Twitter / X',
|
||||
),
|
||||
MediaLink(
|
||||
platform: SocialPlatform.tiktok,
|
||||
handle: '@windedfc',
|
||||
url: 'https://tiktok.com/@windedfc',
|
||||
displayName: 'TikTok',
|
||||
),
|
||||
];
|
||||
|
||||
/// Highlight reels surfaced in the Media screen feed. Ordered newest first
|
||||
/// to match how the UI presents them.
|
||||
static final List<Highlight> highlights = <Highlight>[
|
||||
Highlight(
|
||||
id: 'highlight_summer_kickoff_final',
|
||||
title: 'Summer Kickoff 7v7 – Final Highlights',
|
||||
description:
|
||||
'Green Eagles vs. Red Lions went the distance — extra time, a '
|
||||
'goal-line clearance, and a winner from 30 yards. Catch every '
|
||||
'turning point from the championship match.',
|
||||
youtubeUrl: 'https://youtube.com/watch?v=winded_summer_final',
|
||||
publishedAt: DateTime(2026, 5, 10),
|
||||
),
|
||||
Highlight(
|
||||
id: 'highlight_best_goals_may_2026',
|
||||
title: 'Best Goals of the Month – May 2026',
|
||||
description:
|
||||
'Ten goals, one tape. Volleys, scorpion kicks, and a half-pitch '
|
||||
'lob — our community voted, and these are the May standouts.',
|
||||
youtubeUrl: 'https://youtube.com/watch?v=winded_may_goals',
|
||||
publishedAt: DateTime(2026, 5, 6),
|
||||
),
|
||||
Highlight(
|
||||
id: 'highlight_wednesday_pickup',
|
||||
title: 'Wednesday Night Pick-Up – Top Moments',
|
||||
description:
|
||||
'No standings, no pressure — just the best plays from this week\'s '
|
||||
'open run at Riverside Park. Bring cleats and friends next time.',
|
||||
youtubeUrl: 'https://youtube.com/watch?v=winded_wed_pickup',
|
||||
publishedAt: DateTime(2026, 4, 30),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../infrastructure/media_repository.dart';
|
||||
import 'widgets/highlight_card.dart';
|
||||
import 'widgets/social_link_card.dart';
|
||||
|
||||
/// Top-level Media screen. Promotes community social presence above the fold
|
||||
/// and a feed of highlight reels below. Data is read directly from
|
||||
/// [MediaRepository] static getters — no Riverpod state since the content is
|
||||
/// hardcoded for the MVP.
|
||||
class MediaScreen extends StatelessWidget {
|
||||
const MediaScreen({super.key});
|
||||
|
||||
static const double _maxContentWidth = 760;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final socialLinks = MediaRepository.socialLinks;
|
||||
final highlights = MediaRepository.highlights;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Media')),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_SectionHeader(
|
||||
title: 'Follow Us',
|
||||
subtitle: 'Stay connected on your favorite platform',
|
||||
textTheme: theme.textTheme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: socialLinks.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return SocialLinkCard(link: socialLinks[index]);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
_SectionHeader(
|
||||
title: 'Highlights',
|
||||
subtitle: 'Recent reels and top moments',
|
||||
textTheme: theme.textTheme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: highlights.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return HighlightCard(highlight: highlights[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/highlight.dart';
|
||||
|
||||
/// Card for one highlight reel. Renders:
|
||||
/// * a 160px thumbnail area (placeholder until [Highlight.thumbnailUrl] is
|
||||
/// populated — Phase 2 will swap to `Image.network`)
|
||||
/// * the title, description (clipped to 2 lines), and published date
|
||||
/// * a "Watch on YouTube" outlined button that surfaces a snackbar
|
||||
/// placeholder; real launching ships with the `url_launcher` package.
|
||||
class HighlightCard extends StatelessWidget {
|
||||
const HighlightCard({super.key, required this.highlight});
|
||||
|
||||
final Highlight highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final formattedDate = DateFormat.yMMMMd().format(highlight.publishedAt);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
_Thumbnail(thumbnailUrl: highlight.thumbnailUrl),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
highlight.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
highlight.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _handleWatch(context),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Watch on YouTube'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleWatch(BuildContext context) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Opening YouTube...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumbnail extends StatelessWidget {
|
||||
const _Thumbnail({required this.thumbnailUrl});
|
||||
|
||||
final String? thumbnailUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (thumbnailUrl == null) {
|
||||
return Container(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
color: scheme.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 48,
|
||||
color: scheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
child: Image.network(
|
||||
thumbnailUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 36,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/media_link.dart';
|
||||
|
||||
/// Brand colors for each social platform. Hardcoded on purpose — these are
|
||||
/// the official platform brand colors, not theme tokens, so they remain
|
||||
/// recognisable regardless of the app's color scheme.
|
||||
const Map<SocialPlatform, Color> _platformAccent = <SocialPlatform, Color>{
|
||||
SocialPlatform.instagram: Color(0xFFE1306C),
|
||||
SocialPlatform.youtube: Color(0xFFFF0000),
|
||||
SocialPlatform.twitter: Color(0xFF1DA1F2),
|
||||
SocialPlatform.tiktok: Color(0xFF69C9D0),
|
||||
};
|
||||
|
||||
const Map<SocialPlatform, IconData> _platformIcon = <SocialPlatform, IconData>{
|
||||
SocialPlatform.instagram: Icons.photo_camera,
|
||||
SocialPlatform.youtube: Icons.play_circle_filled,
|
||||
SocialPlatform.twitter: Icons.tag,
|
||||
SocialPlatform.tiktok: Icons.music_note,
|
||||
};
|
||||
|
||||
/// Wide tappable card for a single social platform. Shows a brand-colored
|
||||
/// icon leading, the platform name + handle as the title/subtitle, and a
|
||||
/// trailing chevron. Tapping surfaces an "Opening …" snackbar — actual URL
|
||||
/// launching will be wired up once `url_launcher` is added to pubspec.
|
||||
class SocialLinkCard extends StatelessWidget {
|
||||
const SocialLinkCard({super.key, required this.link});
|
||||
|
||||
final MediaLink link;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final accent = _platformAccent[link.platform] ?? scheme.primary;
|
||||
final icon = _platformIcon[link.platform] ?? Icons.public;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
onTap: () => _handleTap(context),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: accent, size: 24),
|
||||
),
|
||||
title: Text(
|
||||
link.displayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
link.handle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Opening ${link.displayName}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../teams/domain/player.dart';
|
||||
import '../../teams/domain/team.dart';
|
||||
import '../../teams/infrastructure/teams_repository.dart';
|
||||
|
||||
part 'stats_notifier.g.dart';
|
||||
|
||||
/// A player paired with the team they belong to. Records are emitted by the
|
||||
/// stats providers so leaderboard rows can show "Player — Team" without doing
|
||||
/// a second lookup.
|
||||
typedef PlayerWithTeam = ({Player player, Team team});
|
||||
|
||||
/// Top scorers across every team, sorted by goals scored (descending). Ties
|
||||
/// are broken by assists, then by player name so the order is deterministic.
|
||||
@riverpod
|
||||
Future<List<PlayerWithTeam>> topScorers(TopScorersRef ref) async {
|
||||
final teams = await ref.watch(teamsStreamProvider.future);
|
||||
final entries = <PlayerWithTeam>[
|
||||
for (final team in teams)
|
||||
for (final player in team.players) (player: player, team: team),
|
||||
];
|
||||
entries.sort((a, b) {
|
||||
final byGoals = b.player.goalsScored.compareTo(a.player.goalsScored);
|
||||
if (byGoals != 0) return byGoals;
|
||||
final byAssists = b.player.assists.compareTo(a.player.assists);
|
||||
if (byAssists != 0) return byAssists;
|
||||
return a.player.name.compareTo(b.player.name);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// Top assisters across every team, sorted by assists (descending). Ties are
|
||||
/// broken by goals, then by player name.
|
||||
@riverpod
|
||||
Future<List<PlayerWithTeam>> topAssisters(TopAssistersRef ref) async {
|
||||
final teams = await ref.watch(teamsStreamProvider.future);
|
||||
final entries = <PlayerWithTeam>[
|
||||
for (final team in teams)
|
||||
for (final player in team.players) (player: player, team: team),
|
||||
];
|
||||
entries.sort((a, b) {
|
||||
final byAssists = b.player.assists.compareTo(a.player.assists);
|
||||
if (byAssists != 0) return byAssists;
|
||||
final byGoals = b.player.goalsScored.compareTo(a.player.goalsScored);
|
||||
if (byGoals != 0) return byGoals;
|
||||
return a.player.name.compareTo(b.player.name);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// League standings: teams sorted by wins (desc), then draws (desc), then by
|
||||
/// fewer losses, then name. The points column shown in the UI is computed as
|
||||
/// `wins * 3 + draws`.
|
||||
@riverpod
|
||||
Future<List<Team>> teamStandings(TeamStandingsRef ref) async {
|
||||
final teams = await ref.watch(teamsStreamProvider.future);
|
||||
final sorted = [...teams];
|
||||
sorted.sort((a, b) {
|
||||
final byWins = b.wins.compareTo(a.wins);
|
||||
if (byWins != 0) return byWins;
|
||||
final byDraws = b.draws.compareTo(a.draws);
|
||||
if (byDraws != 0) return byDraws;
|
||||
final byLosses = a.losses.compareTo(b.losses);
|
||||
if (byLosses != 0) return byLosses;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'stats_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$topScorersHash() => r'217ba2c980b0ac979f18b59f6093ba51ba8ab8d2';
|
||||
|
||||
/// Top scorers across every team, sorted by goals scored (descending). Ties
|
||||
/// are broken by assists, then by player name so the order is deterministic.
|
||||
///
|
||||
/// Copied from [topScorers].
|
||||
@ProviderFor(topScorers)
|
||||
final topScorersProvider =
|
||||
AutoDisposeFutureProvider<List<PlayerWithTeam>>.internal(
|
||||
topScorers,
|
||||
name: r'topScorersProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$topScorersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef TopScorersRef = AutoDisposeFutureProviderRef<List<PlayerWithTeam>>;
|
||||
String _$topAssistersHash() => r'2f95133f5b72f4e1ae7001e01e6bd57856d04ad4';
|
||||
|
||||
/// Top assisters across every team, sorted by assists (descending). Ties are
|
||||
/// broken by goals, then by player name.
|
||||
///
|
||||
/// Copied from [topAssisters].
|
||||
@ProviderFor(topAssisters)
|
||||
final topAssistersProvider =
|
||||
AutoDisposeFutureProvider<List<PlayerWithTeam>>.internal(
|
||||
topAssisters,
|
||||
name: r'topAssistersProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$topAssistersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef TopAssistersRef = AutoDisposeFutureProviderRef<List<PlayerWithTeam>>;
|
||||
String _$teamStandingsHash() => r'644f974075e26a852b073c7bc155a38bb59045d0';
|
||||
|
||||
/// League standings: teams sorted by wins (desc), then draws (desc), then by
|
||||
/// fewer losses, then name. The points column shown in the UI is computed as
|
||||
/// `wins * 3 + draws`.
|
||||
///
|
||||
/// Copied from [teamStandings].
|
||||
@ProviderFor(teamStandings)
|
||||
final teamStandingsProvider = AutoDisposeFutureProvider<List<Team>>.internal(
|
||||
teamStandings,
|
||||
name: r'teamStandingsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$teamStandingsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef TeamStandingsRef = AutoDisposeFutureProviderRef<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,349 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../application/stats_notifier.dart';
|
||||
import 'widgets/leaderboard_tile.dart';
|
||||
import 'widgets/stat_bar_chart.dart';
|
||||
import 'widgets/stats_filter_bar.dart';
|
||||
|
||||
/// Stats hub: standings + player leaderboards. Driven by [DefaultTabController]
|
||||
/// so the [StatsFilterBar] in the AppBar bottom slot stays in sync with the
|
||||
/// [TabBarView] below.
|
||||
class StatsScreen extends ConsumerWidget {
|
||||
const StatsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stats'),
|
||||
bottom: const StatsFilterBar(),
|
||||
),
|
||||
body: const TabBarView(
|
||||
children: [
|
||||
_StandingsTab(),
|
||||
_ScorersTab(),
|
||||
_AssistsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _StandingsTab extends ConsumerWidget {
|
||||
const _StandingsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final standingsAsync = ref.watch(teamStandingsProvider);
|
||||
return standingsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: err.toString(),
|
||||
onRetry: () => ref.invalidate(teamStandingsProvider),
|
||||
),
|
||||
data: (teams) {
|
||||
if (teams.isEmpty) {
|
||||
return const _EmptyState(
|
||||
icon: Icons.emoji_events_outlined,
|
||||
title: 'No standings yet',
|
||||
body: 'League standings will appear once teams have played games.',
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
const _SectionHeader(label: 'League standings'),
|
||||
const _StandingsHeaderRow(),
|
||||
for (var i = 0; i < teams.length; i++)
|
||||
LeaderboardTile.team(
|
||||
rank: i + 1,
|
||||
team: teams[i],
|
||||
navContext: context,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionHeader(label: 'Wins by team'),
|
||||
StatBarChart(
|
||||
valueLabel: 'Wins',
|
||||
data: [
|
||||
for (final t in teams)
|
||||
StatBarDatum(label: t.name, value: t.wins),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StandingsHeaderRow extends StatelessWidget {
|
||||
const _StandingsHeaderRow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final style = theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 34, child: Text('#', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text('TEAM', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: 70, child: Text('W · D · L', style: style)),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
'PTS',
|
||||
style: style,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player leaderboards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ScorersTab extends ConsumerWidget {
|
||||
const _ScorersTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scorersAsync = ref.watch(topScorersProvider);
|
||||
return scorersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: err.toString(),
|
||||
onRetry: () => ref.invalidate(topScorersProvider),
|
||||
),
|
||||
data: (entries) => _PlayerLeaderboardView(
|
||||
entries: entries,
|
||||
statSelector: (p) => p.player.goalsScored,
|
||||
statLabel: 'goals',
|
||||
chartLabel: 'Goals',
|
||||
headerTitle: 'Top scorers',
|
||||
chartTitle: 'Top 6 scorers',
|
||||
emptyTitle: 'No goals yet',
|
||||
emptyBody: 'Player goal tallies will appear here once games are logged.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistsTab extends ConsumerWidget {
|
||||
const _AssistsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assistsAsync = ref.watch(topAssistersProvider);
|
||||
return assistsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: err.toString(),
|
||||
onRetry: () => ref.invalidate(topAssistersProvider),
|
||||
),
|
||||
data: (entries) => _PlayerLeaderboardView(
|
||||
entries: entries,
|
||||
statSelector: (p) => p.player.assists,
|
||||
statLabel: 'assists',
|
||||
chartLabel: 'Assists',
|
||||
headerTitle: 'Top assists',
|
||||
chartTitle: 'Top 6 assist leaders',
|
||||
emptyTitle: 'No assists yet',
|
||||
emptyBody:
|
||||
'Player assist tallies will appear here once games are logged.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared layout for the two player-stat tabs: chart on top, ranked list below.
|
||||
class _PlayerLeaderboardView extends StatelessWidget {
|
||||
const _PlayerLeaderboardView({
|
||||
required this.entries,
|
||||
required this.statSelector,
|
||||
required this.statLabel,
|
||||
required this.chartLabel,
|
||||
required this.headerTitle,
|
||||
required this.chartTitle,
|
||||
required this.emptyTitle,
|
||||
required this.emptyBody,
|
||||
});
|
||||
|
||||
final List<PlayerWithTeam> entries;
|
||||
final int Function(PlayerWithTeam) statSelector;
|
||||
final String statLabel;
|
||||
final String chartLabel;
|
||||
final String headerTitle;
|
||||
final String chartTitle;
|
||||
final String emptyTitle;
|
||||
final String emptyBody;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Drop players who haven't scored anything in the active category so the
|
||||
// chart and list stay meaningful when the season just started.
|
||||
final ranked = entries.where((e) => statSelector(e) > 0).toList();
|
||||
|
||||
if (ranked.isEmpty) {
|
||||
return _EmptyState(
|
||||
icon: Icons.bar_chart_outlined,
|
||||
title: emptyTitle,
|
||||
body: emptyBody,
|
||||
);
|
||||
}
|
||||
|
||||
final top = ranked.take(6).toList(growable: false);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
_SectionHeader(label: chartTitle),
|
||||
StatBarChart(
|
||||
valueLabel: chartLabel,
|
||||
data: [
|
||||
for (final e in top)
|
||||
StatBarDatum(label: e.player.name, value: statSelector(e)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SectionHeader(label: headerTitle),
|
||||
for (var i = 0; i < ranked.length; i++)
|
||||
LeaderboardTile.player(
|
||||
rank: i + 1,
|
||||
entry: ranked[i],
|
||||
statValue: statSelector(ranked[i]),
|
||||
statLabel: statLabel,
|
||||
navContext: context,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
@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(icon, size: 64, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
body,
|
||||
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 stats', 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,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../teams/domain/team.dart';
|
||||
import '../../application/stats_notifier.dart';
|
||||
|
||||
/// Ranked row used in every leaderboard list on the Stats screen.
|
||||
///
|
||||
/// Use either [LeaderboardTile.player] for the player leaderboards or
|
||||
/// [LeaderboardTile.team] for the league standings — both share the rank
|
||||
/// medal styling and tap affordances.
|
||||
class LeaderboardTile extends StatelessWidget {
|
||||
const LeaderboardTile._({
|
||||
required this.rank,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.trailingValue,
|
||||
required this.trailingLabel,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Player leaderboard variant. Tapping navigates to the player's team page.
|
||||
factory LeaderboardTile.player({
|
||||
Key? key,
|
||||
required int rank,
|
||||
required PlayerWithTeam entry,
|
||||
required int statValue,
|
||||
required String statLabel,
|
||||
BuildContext? navContext,
|
||||
}) {
|
||||
return LeaderboardTile._(
|
||||
rank: rank,
|
||||
title: entry.player.name,
|
||||
subtitle: entry.team.name,
|
||||
trailingValue: statValue,
|
||||
trailingLabel: statLabel,
|
||||
onTap: navContext == null
|
||||
? null
|
||||
: () => navContext.go('/teams/${entry.team.id}'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Team standings variant. Tapping navigates to the team detail page.
|
||||
factory LeaderboardTile.team({
|
||||
Key? key,
|
||||
required int rank,
|
||||
required Team team,
|
||||
BuildContext? navContext,
|
||||
}) {
|
||||
final points = team.wins * 3 + team.draws;
|
||||
return LeaderboardTile._(
|
||||
rank: rank,
|
||||
title: team.name,
|
||||
subtitle: '${team.wins}W · ${team.draws}D · ${team.losses}L',
|
||||
trailingValue: points,
|
||||
trailingLabel: 'pts',
|
||||
onTap: navContext == null
|
||||
? null
|
||||
: () => navContext.go('/teams/${team.id}'),
|
||||
);
|
||||
}
|
||||
|
||||
final int rank;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final int trailingValue;
|
||||
final String trailingLabel;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_RankMedal(rank: rank),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_TrailingStat(value: trailingValue, label: trailingLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RankMedal extends StatelessWidget {
|
||||
const _RankMedal({required this.rank});
|
||||
|
||||
final int rank;
|
||||
|
||||
// Podium medal colors — only hardcoded colors in the feature.
|
||||
static const _gold = Color(0xFFFFD700);
|
||||
static const _silver = Color(0xFFC0C0C0);
|
||||
static const _bronze = Color(0xFFCD7F32);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isPodium = rank >= 1 && rank <= 3;
|
||||
final medalColor = switch (rank) {
|
||||
1 => _gold,
|
||||
2 => _silver,
|
||||
3 => _bronze,
|
||||
_ => scheme.surfaceContainerHighest,
|
||||
};
|
||||
final textColor = isPodium ? Colors.black : scheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: medalColor,
|
||||
shape: BoxShape.circle,
|
||||
border: isPodium
|
||||
? null
|
||||
: Border.all(color: scheme.outlineVariant, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrailingStat extends StatelessWidget {
|
||||
const _TrailingStat({required this.value, required this.label});
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$value',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A single labelled bar in [StatBarChart].
|
||||
class StatBarDatum {
|
||||
const StatBarDatum({required this.label, required this.value});
|
||||
|
||||
/// Short label rendered along the X axis (kept terse so it fits).
|
||||
final String label;
|
||||
final int value;
|
||||
}
|
||||
|
||||
/// Lightweight wrapper around [BarChart] for the top-6 leaderboards. Renders
|
||||
/// vertical bars with a numeric Y axis and the supplied [data] labels on X.
|
||||
class StatBarChart extends StatelessWidget {
|
||||
const StatBarChart({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.valueLabel,
|
||||
this.height = 280,
|
||||
});
|
||||
|
||||
/// Sorted list of bars to render (highest first); only the first 6 are used.
|
||||
final List<StatBarDatum> data;
|
||||
|
||||
/// Used for the Y axis title, e.g. "Goals".
|
||||
final String valueLabel;
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final visible = data.take(6).toList(growable: false);
|
||||
|
||||
if (visible.isEmpty) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No data yet',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final maxValue = visible
|
||||
.map((d) => d.value)
|
||||
.fold<int>(0, (a, b) => a > b ? a : b);
|
||||
// Round the y-axis ceiling up to the nearest sensible interval so the
|
||||
// grid lines land on whole numbers.
|
||||
final yMax = maxValue <= 4 ? 4.0 : (maxValue + 2).toDouble();
|
||||
final interval = yMax <= 6 ? 1.0 : (yMax / 5).ceilToDouble();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 16, 8),
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: yMax,
|
||||
minY: 0,
|
||||
barTouchData: BarTouchData(
|
||||
enabled: true,
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipColor: (_) => scheme.surfaceContainerHigh,
|
||||
tooltipBorder: BorderSide(color: scheme.outlineVariant),
|
||||
getTooltipItem: (group, _, rod, _) {
|
||||
final datum = visible[group.x];
|
||||
return BarTooltipItem(
|
||||
'${datum.label}\n',
|
||||
theme.textTheme.bodySmall!.copyWith(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${rod.toY.toInt()} $valueLabel',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: interval,
|
||||
getDrawingHorizontalLine: (_) => FlLine(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
axisNameWidget: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
valueLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
axisNameSize: 18,
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: interval,
|
||||
reservedSize: 32,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value == 0 || value > yMax) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
value.toInt().toString(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 36,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index < 0 || index >= visible.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
_shortLabel(visible[index].label),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < visible.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: visible[i].value.toDouble(),
|
||||
color: scheme.primary,
|
||||
width: 22,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
),
|
||||
backDrawRodData: BackgroundBarChartRodData(
|
||||
show: true,
|
||||
toY: yMax,
|
||||
color: scheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compresses long names like "Marcus Reed" → "M. Reed" so X-axis labels
|
||||
/// don't overflow on phone widths.
|
||||
static String _shortLabel(String full) {
|
||||
final parts = full.trim().split(RegExp(r'\s+'));
|
||||
if (parts.length < 2) {
|
||||
return parts.first.length > 10
|
||||
? '${parts.first.substring(0, 9)}…'
|
||||
: parts.first;
|
||||
}
|
||||
final last = parts.last;
|
||||
final first = parts.first;
|
||||
final initial = first.isEmpty ? '' : '${first[0]}. ';
|
||||
final candidate = '$initial$last';
|
||||
if (candidate.length <= 12) return candidate;
|
||||
return '${candidate.substring(0, 11)}…';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Top tab strip for the Stats screen. Hosted inside an [AppBar] `bottom:`
|
||||
/// slot and driven by an ambient [DefaultTabController].
|
||||
class StatsFilterBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const StatsFilterBar({super.key});
|
||||
|
||||
static const double _height = 48;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(_height);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return TabBar(
|
||||
isScrollable: false,
|
||||
labelColor: scheme.primary,
|
||||
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||
indicatorColor: scheme.primary,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
tabs: const [
|
||||
Tab(text: 'Standings'),
|
||||
Tab(text: 'Top Scorers'),
|
||||
Tab(text: 'Top Assists'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../domain/suggestion.dart';
|
||||
import '../infrastructure/suggestions_repository.dart';
|
||||
|
||||
part 'suggestions_notifier.g.dart';
|
||||
|
||||
/// Tracks the submission lifecycle of the suggestion form.
|
||||
///
|
||||
/// State is an `AsyncValue<void>`:
|
||||
/// * idle → `AsyncData(null)` after [build]
|
||||
/// * busy → `AsyncLoading()` while a submit is in flight
|
||||
/// * done → `AsyncData(null)` after a successful submit
|
||||
/// * error → `AsyncError(...)` on failure
|
||||
@riverpod
|
||||
class SuggestionsNotifier extends _$SuggestionsNotifier {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Submits a suggestion. UI should already have validated [text] length
|
||||
/// before calling — this method does not re-validate.
|
||||
Future<void> submit({
|
||||
required String text,
|
||||
required bool isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final repo = ref.read(suggestionsRepositoryProvider);
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await repo.submitSuggestion(
|
||||
text: text.trim(),
|
||||
isAnonymous: isAnonymous,
|
||||
userId: isAnonymous ? null : userId,
|
||||
displayName: isAnonymous ? null : displayName,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams the current user's previously-submitted suggestions.
|
||||
///
|
||||
/// Emits an empty list when the user is signed out, so the UI can render a
|
||||
/// stable widget tree without juggling auth-vs-stream loading states.
|
||||
@riverpod
|
||||
Stream<List<Suggestion>> userSuggestions(UserSuggestionsRef ref) async* {
|
||||
final auth = ref.watch(authNotifierProvider);
|
||||
final user = auth.valueOrNull;
|
||||
if (user == null) {
|
||||
yield <Suggestion>[];
|
||||
return;
|
||||
}
|
||||
final repo = ref.watch(suggestionsRepositoryProvider);
|
||||
yield* repo.watchUserSuggestions(user.uid);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'suggestions_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$userSuggestionsHash() => r'8544dca51c0cb3453bfc7219fde2ec43e55b3106';
|
||||
|
||||
/// Streams the current user's previously-submitted suggestions.
|
||||
///
|
||||
/// Emits an empty list when the user is signed out, so the UI can render a
|
||||
/// stable widget tree without juggling auth-vs-stream loading states.
|
||||
///
|
||||
/// Copied from [userSuggestions].
|
||||
@ProviderFor(userSuggestions)
|
||||
final userSuggestionsProvider =
|
||||
AutoDisposeStreamProvider<List<Suggestion>>.internal(
|
||||
userSuggestions,
|
||||
name: r'userSuggestionsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$userSuggestionsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef UserSuggestionsRef = AutoDisposeStreamProviderRef<List<Suggestion>>;
|
||||
String _$suggestionsNotifierHash() =>
|
||||
r'f7a4d35220e955e11bbd10872c8e2d838cc1a3a7';
|
||||
|
||||
/// Tracks the submission lifecycle of the suggestion form.
|
||||
///
|
||||
/// State is an `AsyncValue<void>`:
|
||||
/// * idle → `AsyncData(null)` after [build]
|
||||
/// * busy → `AsyncLoading()` while a submit is in flight
|
||||
/// * done → `AsyncData(null)` after a successful submit
|
||||
/// * error → `AsyncError(...)` on failure
|
||||
///
|
||||
/// Copied from [SuggestionsNotifier].
|
||||
@ProviderFor(SuggestionsNotifier)
|
||||
final suggestionsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<SuggestionsNotifier, void>.internal(
|
||||
SuggestionsNotifier.new,
|
||||
name: r'suggestionsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$suggestionsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SuggestionsNotifier = AutoDisposeAsyncNotifier<void>;
|
||||
// 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,108 @@
|
||||
enum SuggestionStatus { pending, reviewed, implemented }
|
||||
|
||||
class Suggestion {
|
||||
const Suggestion({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.isAnonymous,
|
||||
required this.submittedAt,
|
||||
required this.status,
|
||||
this.userId,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String text;
|
||||
final bool isAnonymous;
|
||||
final String? userId;
|
||||
final String? displayName;
|
||||
final DateTime submittedAt;
|
||||
final SuggestionStatus status;
|
||||
|
||||
Suggestion copyWith({
|
||||
String? id,
|
||||
String? text,
|
||||
bool? isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
DateTime? submittedAt,
|
||||
SuggestionStatus? status,
|
||||
}) {
|
||||
return Suggestion(
|
||||
id: id ?? this.id,
|
||||
text: text ?? this.text,
|
||||
isAnonymous: isAnonymous ?? this.isAnonymous,
|
||||
userId: userId ?? this.userId,
|
||||
displayName: displayName ?? this.displayName,
|
||||
submittedAt: submittedAt ?? this.submittedAt,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
factory Suggestion.fromJson(Map<String, dynamic> data) {
|
||||
return Suggestion(
|
||||
id: (data['id'] as String?) ?? '',
|
||||
text: (data['text'] as String?) ?? '',
|
||||
isAnonymous: _parseBool(data['is_anonymous']),
|
||||
userId: data['user_id'] as String?,
|
||||
displayName: data['display_name'] as String?,
|
||||
submittedAt: _parseDate(data['submitted_at']) ?? DateTime.now(),
|
||||
status: _parseStatus(data['status'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return <String, Object?>{
|
||||
'text': text,
|
||||
'is_anonymous': isAnonymous,
|
||||
'user_id': isAnonymous ? null : userId,
|
||||
'display_name': isAnonymous ? null : displayName,
|
||||
'status': status.name,
|
||||
};
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(Object? v) {
|
||||
if (v is String && v.isNotEmpty) return DateTime.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _parseBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is int) return v != 0;
|
||||
if (v is String) return v == '1' || v.toLowerCase() == 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
static SuggestionStatus _parseStatus(String? raw) {
|
||||
switch (raw) {
|
||||
case 'reviewed':
|
||||
return SuggestionStatus.reviewed;
|
||||
case 'implemented':
|
||||
return SuggestionStatus.implemented;
|
||||
default:
|
||||
return SuggestionStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Suggestion &&
|
||||
other.id == id &&
|
||||
other.text == text &&
|
||||
other.isAnonymous == isAnonymous &&
|
||||
other.userId == userId &&
|
||||
other.displayName == displayName &&
|
||||
other.submittedAt == submittedAt &&
|
||||
other.status == status;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id, text, isAnonymous, userId, displayName, submittedAt, status,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Suggestion(id: $id, status: ${status.name}, anonymous: $isAnonymous)';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../domain/suggestion.dart';
|
||||
|
||||
part 'suggestions_repository.g.dart';
|
||||
|
||||
class SuggestionsRepository {
|
||||
SuggestionsRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<void> submitSuggestion({
|
||||
required String text,
|
||||
required bool isAnonymous,
|
||||
String? userId,
|
||||
String? displayName,
|
||||
}) async {
|
||||
await _api.post('/suggestions/index.php', {
|
||||
'text': text,
|
||||
'is_anonymous': isAnonymous,
|
||||
'display_name': displayName ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Suggestion>> fetchUserSuggestions() async {
|
||||
final data = await _api.get('/suggestions/index.php');
|
||||
final list = (data['suggestions'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Suggestion.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<List<Suggestion>> fetchAllSuggestions() async {
|
||||
final data = await _api.get('/suggestions/index.php');
|
||||
final list = (data['suggestions'] as List?) ?? [];
|
||||
return list.whereType<Map<String, dynamic>>().map(Suggestion.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<void> updateStatus(String id, SuggestionStatus status) async {
|
||||
await _api.put(
|
||||
'/suggestions/detail.php',
|
||||
{'status': status.name},
|
||||
params: {'id': id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSuggestion(String id) async {
|
||||
await _api.delete('/suggestions/detail.php', params: {'id': id});
|
||||
}
|
||||
|
||||
Stream<List<Suggestion>> watchUserSuggestions(String userId) async* {
|
||||
yield await fetchUserSuggestions();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchUserSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<Suggestion>> watchAllSuggestions() async* {
|
||||
yield await fetchAllSuggestions();
|
||||
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetchAllSuggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SuggestionsRepository suggestionsRepository(SuggestionsRepositoryRef ref) {
|
||||
return SuggestionsRepository(ref.watch(apiClientProvider));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'suggestions_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$suggestionsRepositoryHash() =>
|
||||
r'5cf92a23c07a7d135224b0fcd2831f68f4a9a27f';
|
||||
|
||||
/// See also [suggestionsRepository].
|
||||
@ProviderFor(suggestionsRepository)
|
||||
final suggestionsRepositoryProvider = Provider<SuggestionsRepository>.internal(
|
||||
suggestionsRepository,
|
||||
name: r'suggestionsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$suggestionsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef SuggestionsRepositoryRef = ProviderRef<SuggestionsRepository>;
|
||||
// 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,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/application/auth_notifier.dart';
|
||||
import '../application/suggestions_notifier.dart';
|
||||
import 'widgets/suggestion_form.dart';
|
||||
import 'widgets/suggestion_list_tile.dart';
|
||||
|
||||
/// Top-level Suggestions screen.
|
||||
///
|
||||
/// Top half is the always-visible [SuggestionForm]. Bottom half lists the
|
||||
/// signed-in user's past suggestions via [userSuggestionsProvider], or a
|
||||
/// gentle sign-in prompt when there's no current user.
|
||||
class SuggestionsScreen extends ConsumerWidget {
|
||||
const SuggestionsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final authUser = ref.watch(authNotifierProvider).valueOrNull;
|
||||
final suggestionsAsync = ref.watch(userSuggestionsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Suggestions')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
const SuggestionForm(),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Your Suggestions',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (authUser == null)
|
||||
_SignInPrompt(colors: colors, textTheme: theme.textTheme)
|
||||
else
|
||||
suggestionsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (err, _) => _ErrorState(
|
||||
message: 'Could not load your suggestions.',
|
||||
detail: '$err',
|
||||
colors: colors,
|
||||
textTheme: theme.textTheme,
|
||||
),
|
||||
data: (suggestions) {
|
||||
if (suggestions.isEmpty) {
|
||||
return _EmptyState(
|
||||
colors: colors,
|
||||
textTheme: theme.textTheme,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (final s in suggestions)
|
||||
SuggestionListTile(suggestion: s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignInPrompt extends StatelessWidget {
|
||||
const _SignInPrompt({required this.colors, required this.textTheme});
|
||||
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.lock_outline, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sign in to view your past suggestions.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState({required this.colors, required this.textTheme});
|
||||
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 36,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No suggestions yet — share your first idea above.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({
|
||||
required this.message,
|
||||
required this.detail,
|
||||
required this.colors,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String detail;
|
||||
final ColorScheme colors;
|
||||
final TextTheme textTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.error_outline, color: colors.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
detail,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../auth/application/auth_notifier.dart';
|
||||
import '../../application/suggestions_notifier.dart';
|
||||
|
||||
/// Form for submitting a new community suggestion.
|
||||
///
|
||||
/// Owns its own [TextEditingController] and the "anonymous" toggle. Wires
|
||||
/// into [suggestionsNotifierProvider] for submission state — the FilledButton
|
||||
/// switches to a spinner while loading, and shows snackbars on success/error.
|
||||
class SuggestionForm extends ConsumerStatefulWidget {
|
||||
const SuggestionForm({super.key});
|
||||
|
||||
static const int _maxChars = 500;
|
||||
static const int _minChars = 10;
|
||||
|
||||
@override
|
||||
ConsumerState<SuggestionForm> createState() => _SuggestionFormState();
|
||||
}
|
||||
|
||||
class _SuggestionFormState extends ConsumerState<SuggestionForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _controller = TextEditingController();
|
||||
bool _isAnonymous = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validate(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.length < SuggestionForm._minChars) {
|
||||
return 'Please write at least ${SuggestionForm._minChars} characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _onSubmit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final user = ref.read(authNotifierProvider).valueOrNull;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
await ref.read(suggestionsNotifierProvider.notifier).submit(
|
||||
text: _controller.text,
|
||||
isAnonymous: _isAnonymous,
|
||||
userId: user?.uid,
|
||||
displayName: user?.displayName,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final state = ref.read(suggestionsNotifierProvider);
|
||||
state.when(
|
||||
data: (_) {
|
||||
_controller.clear();
|
||||
_formKey.currentState?.reset();
|
||||
setState(() {});
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Thanks for your suggestion!')),
|
||||
);
|
||||
},
|
||||
loading: () {},
|
||||
error: (err, _) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not submit suggestion: $err'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final submissionState = ref.watch(suggestionsNotifierProvider);
|
||||
final isSubmitting = submissionState.isLoading;
|
||||
final authUser = ref.watch(authNotifierProvider).valueOrNull;
|
||||
|
||||
final submittingAs = (!_isAnonymous && authUser != null)
|
||||
? (authUser.displayName?.trim().isNotEmpty ?? false
|
||||
? authUser.displayName!.trim()
|
||||
: authUser.email)
|
||||
: null;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Share an idea',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tell us what would make Winded better — new features, '
|
||||
'tournament formats, anything.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
enabled: !isSubmitting,
|
||||
minLines: 3,
|
||||
maxLines: 8,
|
||||
maxLength: SuggestionForm._maxChars,
|
||||
textInputAction: TextInputAction.newline,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(SuggestionForm._maxChars),
|
||||
],
|
||||
validator: _validate,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type your suggestion here…',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
value: _isAnonymous,
|
||||
onChanged: isSubmitting
|
||||
? null
|
||||
: (value) => setState(() => _isAnonymous = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Submit anonymously'),
|
||||
subtitle: Text(
|
||||
_isAnonymous
|
||||
? 'Your name will not be attached to this suggestion.'
|
||||
: 'Admins will see who submitted this.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (submittingAs != null) ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.person_outline,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Submitting as: $submittingAs',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: isSubmitting ? null : _onSubmit,
|
||||
icon: isSubmitting
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_outlined),
|
||||
label: Text(isSubmitting ? 'Submitting…' : 'Submit Suggestion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/suggestion.dart';
|
||||
|
||||
/// Card-style row showing one of the current user's past suggestions.
|
||||
///
|
||||
/// Truncates the body to three lines, prints a friendly relative-ish date,
|
||||
/// and renders a status chip whose color matches the suggestion lifecycle.
|
||||
class SuggestionListTile extends StatelessWidget {
|
||||
const SuggestionListTile({super.key, required this.suggestion});
|
||||
|
||||
final Suggestion suggestion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final dateLabel =
|
||||
DateFormat.yMMMd().add_jm().format(suggestion.submittedAt);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.text,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(status: suggestion.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (suggestion.isAnonymous) ...<Widget>[
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.visibility_off_outlined,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Anonymous',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final SuggestionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
final (Color background, Color foreground, String label) = switch (status) {
|
||||
SuggestionStatus.pending => (
|
||||
colors.surfaceContainerHighest,
|
||||
colors.onSurfaceVariant,
|
||||
'Pending',
|
||||
),
|
||||
SuggestionStatus.reviewed => (
|
||||
Colors.amber.withValues(alpha: 0.18),
|
||||
Colors.amber.shade300,
|
||||
'Reviewed',
|
||||
),
|
||||
SuggestionStatus.implemented => (
|
||||
Colors.green.withValues(alpha: 0.18),
|
||||
Colors.green.shade300,
|
||||
'Implemented',
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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