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:
2026-05-14 20:13:57 -07:00
commit b239ae3e5f
208 changed files with 19187 additions and 0 deletions
@@ -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?>;
+48
View File
@@ -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
+322
View File
@@ -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
+129
View File
@@ -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;
}
}
+46
View File
@@ -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)';
}
+36
View File
@@ -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
+111
View File
@@ -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})';
}
+84
View File
@@ -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)';
}
+174
View File
@@ -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,
),
),
);
}
}