333 lines
11 KiB
Dart
333 lines
11 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:latlong2/latlong.dart';
|
||
import '../services/auth_service.dart';
|
||
import '../services/api_service.dart';
|
||
|
||
// ── Eager (critical path) ─────────────────────────────────────────────
|
||
import '../screens/home/feed_personal_screen.dart';
|
||
import '../screens/home/home_shell.dart';
|
||
import '../screens/auth/auth_gate.dart';
|
||
|
||
// ── Eager (admin – small, behind role check) ──────────────────────────
|
||
import '../screens/admin/admin_dashboard_screen.dart';
|
||
import '../screens/admin/admin_scaffold.dart';
|
||
import '../screens/admin/admin_user_base_screen.dart';
|
||
import '../screens/admin/moderation_queue_screen.dart';
|
||
import '../screens/admin/admin_content_tools_screen.dart';
|
||
|
||
// ── Deferred (code-split for web) ─────────────────────────────────────
|
||
import '../screens/beacon/beacon_screen.dart' deferred as beacon_lib;
|
||
import '../screens/quips/create/quip_creation_flow.dart' deferred as quip_create_lib;
|
||
import '../screens/quips/feed/quips_feed_screen.dart' deferred as quips_feed_lib;
|
||
import '../screens/profile/viewable_profile_screen.dart' deferred as profile_lib;
|
||
import '../screens/profile/blocked_users_screen.dart' deferred as blocked_lib;
|
||
import '../screens/secure_chat/secure_chat_full_screen.dart' deferred as secure_chat_lib;
|
||
import '../screens/secure_chat/secure_chat_loader_screen.dart' deferred as chat_loader_lib;
|
||
import '../screens/post/threaded_conversation_screen.dart' deferred as threaded_lib;
|
||
import '../screens/clusters/clusters_screen.dart' deferred as clusters_lib;
|
||
import '../screens/clusters/group_screen.dart' deferred as group_screen_lib;
|
||
|
||
/// App routing config (GoRouter).
|
||
class AppRoutes {
|
||
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
||
GlobalKey<NavigatorState>();
|
||
|
||
static const String home = '/';
|
||
static const String homeAlias = '/home';
|
||
static const String userPrefix = '/u';
|
||
static const String postPrefix = '/p';
|
||
static const String beaconPrefix = '/beacon';
|
||
static const String quips = '/quips';
|
||
static const String profile = '/profile';
|
||
static const String secureChat = '/secure-chat';
|
||
static const String quipCreate = '/quips/create';
|
||
static const String clusters = '/clusters';
|
||
|
||
static final AuthRefreshNotifier _authRefreshNotifier =
|
||
AuthRefreshNotifier(AuthService.instance.authStateChanges);
|
||
|
||
static final GoRouter router = GoRouter(
|
||
navigatorKey: rootNavigatorKey,
|
||
initialLocation: homeAlias,
|
||
refreshListenable: _authRefreshNotifier,
|
||
redirect: _adminRedirect,
|
||
routes: [
|
||
GoRoute(
|
||
path: home,
|
||
redirect: (_, __) => homeAlias,
|
||
),
|
||
GoRoute(
|
||
path: '$userPrefix/:username',
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, state) => _deferred(
|
||
profile_lib.loadLibrary,
|
||
() => profile_lib.UnifiedProfileScreen(
|
||
handle: state.pathParameters['username'] ?? '',
|
||
),
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: quipCreate,
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, __) => _deferred(
|
||
quip_create_lib.loadLibrary,
|
||
() => quip_create_lib.QuipCreationFlow(),
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: secureChat,
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, __) => _deferred(
|
||
secure_chat_lib.loadLibrary,
|
||
() => secure_chat_lib.SecureChatFullScreen(),
|
||
),
|
||
routes: [
|
||
GoRoute(
|
||
path: ':id',
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, state) => _deferred(
|
||
chat_loader_lib.loadLibrary,
|
||
() => chat_loader_lib.SecureChatLoaderScreen(
|
||
conversationId: state.pathParameters['id'] ?? '',
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
GoRoute(
|
||
path: '$postPrefix/:id',
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, state) => _deferred(
|
||
threaded_lib.loadLibrary,
|
||
() => threaded_lib.ThreadedConversationScreen(
|
||
rootPostId: state.pathParameters['id'] ?? '',
|
||
),
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: clusters,
|
||
parentNavigatorKey: rootNavigatorKey,
|
||
builder: (_, __) => _deferred(
|
||
clusters_lib.loadLibrary,
|
||
() => clusters_lib.ClustersScreen(),
|
||
),
|
||
),
|
||
StatefulShellRoute.indexedStack(
|
||
builder: (context, state, navigationShell) => AuthGate(
|
||
authenticatedChild: HomeShell(navigationShell: navigationShell),
|
||
),
|
||
branches: [
|
||
StatefulShellBranch(
|
||
routes: [
|
||
GoRoute(
|
||
path: homeAlias,
|
||
builder: (_, __) => const FeedPersonalScreen(),
|
||
),
|
||
],
|
||
),
|
||
StatefulShellBranch(
|
||
routes: [
|
||
GoRoute(
|
||
path: quips,
|
||
builder: (_, state) => _deferred(
|
||
quips_feed_lib.loadLibrary,
|
||
() => quips_feed_lib.QuipsFeedScreen(
|
||
initialPostId: state.uri.queryParameters['postId'],
|
||
),
|
||
),
|
||
),
|
||
|
||
],
|
||
),
|
||
StatefulShellBranch(
|
||
routes: [
|
||
GoRoute(
|
||
path: '/beacon',
|
||
builder: (_, __) => _deferred(
|
||
beacon_lib.loadLibrary,
|
||
() => beacon_lib.BeaconScreen(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
StatefulShellBranch(
|
||
routes: [
|
||
GoRoute(
|
||
path: profile,
|
||
builder: (_, __) => _deferred(
|
||
profile_lib.loadLibrary,
|
||
() => profile_lib.UnifiedProfileScreen(),
|
||
),
|
||
routes: [
|
||
GoRoute(
|
||
path: 'blocked',
|
||
builder: (_, __) => _deferred(
|
||
blocked_lib.loadLibrary,
|
||
() => blocked_lib.BlockedUsersScreen(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
ShellRoute(
|
||
builder: (context, state, child) {
|
||
final index = _adminIndexForPath(state.uri.path);
|
||
return AdminScaffold(selectedIndex: index, child: child);
|
||
},
|
||
routes: [
|
||
GoRoute(
|
||
path: '/admin',
|
||
builder: (_, __) => const AdminDashboardScreen(),
|
||
),
|
||
GoRoute(
|
||
path: '/admin/moderation',
|
||
builder: (_, __) => const ModerationQueueScreen(),
|
||
),
|
||
GoRoute(
|
||
path: '/admin/users',
|
||
builder: (_, __) => const AdminUserBaseScreen(),
|
||
),
|
||
GoRoute(
|
||
path: '/admin/content-tools',
|
||
builder: (_, __) => const AdminContentToolsScreen(),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
|
||
static int _adminIndexForPath(String path) {
|
||
if (path.startsWith('/admin/moderation')) return 1;
|
||
if (path.startsWith('/admin/users')) return 2;
|
||
if (path.startsWith('/admin/content-tools')) return 3;
|
||
return 0;
|
||
}
|
||
|
||
static FutureOr<String?> _adminRedirect(
|
||
BuildContext context,
|
||
GoRouterState state,
|
||
) async {
|
||
final path = state.uri.path;
|
||
if (!path.startsWith('/admin')) return null;
|
||
|
||
final user = AuthService.instance.currentUser;
|
||
if (user == null) return homeAlias;
|
||
|
||
try {
|
||
final data = await ApiService.instance.callGoApi('/profile', method: 'GET');
|
||
final profile = data['profile'];
|
||
if (profile is Map<String, dynamic>) {
|
||
final role = profile['role'] as String?;
|
||
if (role == 'admin' || role == 'moderator') {
|
||
return null;
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
return homeAlias;
|
||
}
|
||
|
||
/// Navigate to a user profile by username
|
||
static void navigateToProfile(BuildContext context, String username) {
|
||
context.push('/u/$username');
|
||
}
|
||
|
||
/// Get shareable URL for a user profile
|
||
/// Returns: https://sojorn.net/u/username
|
||
static String getProfileUrl(
|
||
String username, {
|
||
String baseUrl = 'https://sojorn.net',
|
||
}) {
|
||
return '$baseUrl/u/$username';
|
||
}
|
||
|
||
/// Get shareable URL for a quip
|
||
/// Returns: https://sojorn.net/quips?postId=postid
|
||
static String getQuipUrl(
|
||
String postId, {
|
||
String baseUrl = 'https://sojorn.net',
|
||
}) {
|
||
return '$baseUrl/quips?postId=$postId';
|
||
}
|
||
|
||
/// Get shareable URL for a post
|
||
/// Returns: https://sojorn.net/p/postid
|
||
static String getPostUrl(
|
||
String postId, {
|
||
String baseUrl = 'https://sojorn.net',
|
||
}) {
|
||
return '$baseUrl/p/$postId';
|
||
}
|
||
|
||
|
||
/// Get shareable URL for a beacon location
|
||
/// Returns: https://sojorn.net/beacon?lat=...&long=...
|
||
static String getBeaconUrl(
|
||
double lat,
|
||
double long, {
|
||
String baseUrl = 'https://sojorn.net',
|
||
}) {
|
||
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
|
||
}
|
||
|
||
/// Navigate to a beacon location
|
||
static void navigateToBeacon(BuildContext context, LatLng location) {
|
||
final url =
|
||
'/beacon?lat=${location.latitude.toStringAsFixed(6)}&long=${location.longitude.toStringAsFixed(6)}';
|
||
context.push(url);
|
||
}
|
||
|
||
/// Navigate to secure chat
|
||
static void navigateToSecureChat(BuildContext context) {
|
||
context.push(secureChat);
|
||
}
|
||
|
||
/// Navigate to clusters/capsules discovery
|
||
static void navigateToClusters(BuildContext context) {
|
||
context.push(clusters);
|
||
}
|
||
}
|
||
|
||
class AuthRefreshNotifier extends ChangeNotifier {
|
||
late final StreamSubscription<AuthState> _subscription;
|
||
|
||
AuthRefreshNotifier(Stream<AuthState> stream) {
|
||
_subscription = stream.listen((_) => notifyListeners());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_subscription.cancel();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
// ── Deferred loading helper ───────────────────────────────────────────
|
||
/// Wraps a deferred import in a FutureBuilder that shows a loading
|
||
/// spinner until the JS chunk is fetched, then builds the real screen.
|
||
Widget _deferred(Future<void> Function() load, Widget Function() build) {
|
||
return FutureBuilder<void>(
|
||
future: load(),
|
||
builder: (_, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const Scaffold(
|
||
body: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return const Scaffold(
|
||
body: Center(
|
||
child: Text('Failed to load. Please go back and try again.'),
|
||
),
|
||
);
|
||
}
|
||
return build();
|
||
},
|
||
);
|
||
}
|