b239ae3e5f
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>
246 lines
9.0 KiB
Dart
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();
|
|
}
|
|
}
|