Files
philip b239ae3e5f 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>
2026-05-14 20:13:57 -07:00

246 lines
9.0 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/admin/presentation/admin_shell.dart';
import '../../features/admin/presentation/brackets/admin_bracket_form_screen.dart';
import '../../features/admin/presentation/brackets/admin_brackets_screen.dart';
import '../../features/admin/presentation/events/admin_event_form_screen.dart';
import '../../features/admin/presentation/events/admin_events_screen.dart';
import '../../features/admin/presentation/pending/admin_pending_screen.dart';
import '../../features/admin/presentation/suggestions/admin_suggestions_screen.dart';
import '../../features/admin/presentation/teams/admin_team_form_screen.dart';
import '../../features/admin/presentation/teams/admin_teams_screen.dart';
import '../../features/auth/application/auth_notifier.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/auth/presentation/register_screen.dart';
import '../../features/brackets/presentation/bracket_detail_screen.dart';
import '../../features/brackets/presentation/brackets_screen.dart';
import '../../features/events/presentation/event_detail_screen.dart';
import '../../features/events/presentation/events_screen.dart';
import '../../features/media/presentation/media_screen.dart';
import '../../features/profile/application/profile_notifier.dart';
import '../../features/profile/domain/user_profile.dart';
import '../../features/profile/presentation/manager_dashboard_screen.dart';
import '../../features/profile/presentation/my_profile_screen.dart';
import '../../features/profile/presentation/player_profile_screen.dart';
import '../../features/stats/presentation/stats_screen.dart';
import '../../features/suggestions/presentation/suggestions_screen.dart';
import '../../features/teams/presentation/create_team_screen.dart';
import '../../features/teams/presentation/team_detail_screen.dart';
import '../../features/teams/presentation/teams_screen.dart';
import '../admin/admin_guard.dart';
import '../shell/main_shell.dart';
/// Routes that an unauthenticated user is allowed to visit. Anything else
/// triggers a redirect to `/login`. Player profile pages stay reachable to
/// signed-out viewers so shared links work.
const _publicRoutes = {'/login', '/register'};
/// Path prefixes that anonymous viewers may visit even without a session.
/// Player profile pages are intentionally readable so a roster link shared
/// outside the app still works.
const _viewerPrefixes = <String>['/players/'];
final appRouterProvider = Provider<GoRouter>((ref) {
// GoRouter listens to this notifier and re-evaluates `redirect` whenever
// it fires — we ping it on every auth state change.
final refresh = _AuthRouterRefresh(ref);
ref.onDispose(refresh.dispose);
return GoRouter(
initialLocation: '/events',
refreshListenable: refresh,
redirect: (context, state) {
final auth = ref.read(authNotifierProvider);
// Don't redirect while the initial auth check is still loading —
// GoRouter will re-run this once the notifier has data.
if (auth.isLoading || !auth.hasValue) return null;
final user = auth.value;
final location = state.matchedLocation;
final isPublic = _publicRoutes.contains(location);
final isViewerOk = _viewerPrefixes.any(location.startsWith);
final isAdminRoute = location.startsWith('/admin');
final isManagerRoute = location == '/manager';
if (user == null && !isPublic && !isViewerOk) {
return '/login';
}
if (user != null && isPublic) {
return '/events';
}
if (isAdminRoute && !isAdmin(user)) {
return '/events';
}
if (isManagerRoute) {
// Manager dashboard is reserved for users with the manager role
// (admins also fall through — they have their own panel).
final role = ref.read(currentUserRoleProvider);
if (role != UserRole.manager) {
return '/events';
}
}
return null;
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/events',
builder: (context, state) => const EventsScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) =>
EventDetailScreen(eventId: state.pathParameters['id']!),
),
],
),
GoRoute(
path: '/brackets',
builder: (context, state) => const BracketsScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) =>
BracketDetailScreen(bracketId: state.pathParameters['id']!),
),
],
),
GoRoute(
path: '/teams',
builder: (context, state) => const TeamsScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => const CreateTeamScreen(),
),
GoRoute(
path: ':id',
builder: (context, state) =>
TeamDetailScreen(teamId: state.pathParameters['id']!),
),
],
),
GoRoute(
path: '/stats',
builder: (context, state) => const StatsScreen(),
),
GoRoute(
path: '/media',
builder: (context, state) => const MediaScreen(),
),
GoRoute(
path: '/suggestions',
builder: (context, state) => const SuggestionsScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const MyProfileScreen(),
),
GoRoute(
path: '/players/:uid',
builder: (context, state) =>
PlayerProfileScreen(uid: state.pathParameters['uid']!),
),
GoRoute(
path: '/manager',
builder: (context, state) => const ManagerDashboardScreen(),
),
],
),
ShellRoute(
builder: (context, state, child) => AdminShell(child: child),
routes: [
GoRoute(
path: '/admin/events',
builder: (context, state) => const AdminEventsScreen(),
),
GoRoute(
path: '/admin/events/new',
builder: (context, state) => const AdminEventFormScreen(),
),
GoRoute(
path: '/admin/events/:id/edit',
builder: (context, state) =>
AdminEventFormScreen(eventId: state.pathParameters['id']),
),
GoRoute(
path: '/admin/teams',
builder: (context, state) => const AdminTeamsScreen(),
),
GoRoute(
path: '/admin/teams/new',
builder: (context, state) => const AdminTeamFormScreen(),
),
GoRoute(
path: '/admin/teams/:id/edit',
builder: (context, state) =>
AdminTeamFormScreen(teamId: state.pathParameters['id']),
),
GoRoute(
path: '/admin/brackets',
builder: (context, state) => const AdminBracketsScreen(),
),
GoRoute(
path: '/admin/brackets/new',
builder: (context, state) => const AdminBracketFormScreen(),
),
GoRoute(
path: '/admin/brackets/:id/edit',
builder: (context, state) =>
AdminBracketFormScreen(bracketId: state.pathParameters['id']),
),
GoRoute(
path: '/admin/suggestions',
builder: (context, state) => const AdminSuggestionsScreen(),
),
GoRoute(
path: '/admin/pending',
builder: (context, state) => const AdminPendingScreen(),
),
],
),
],
);
});
/// Bridges the Riverpod auth notifier (and the derived role provider) to a
/// [ChangeNotifier] that GoRouter can subscribe to via `refreshListenable`.
/// Sign-in/out and role changes both ping GoRouter to re-run `redirect`.
class _AuthRouterRefresh extends ChangeNotifier {
_AuthRouterRefresh(this._ref) {
_authSub = _ref.listen<AsyncValue>(
authNotifierProvider,
(prev, next) => notifyListeners(),
fireImmediately: false,
);
_roleSub = _ref.listen<UserRole>(
currentUserRoleProvider,
(prev, next) {
if (prev != next) notifyListeners();
},
fireImmediately: false,
);
}
final Ref _ref;
late final ProviderSubscription<AsyncValue> _authSub;
late final ProviderSubscription<UserRole> _roleSub;
@override
void dispose() {
_authSub.close();
_roleSub.close();
super.dispose();
}
}