Main coves client
1import 'package:flutter/foundation.dart';
2import 'package:flutter/material.dart';
3import 'package:flutter/services.dart';
4import 'package:go_router/go_router.dart';
5import 'package:provider/provider.dart';
6
7import 'config/oauth_config.dart';
8import 'constants/app_colors.dart';
9import 'models/post.dart';
10import 'providers/auth_provider.dart';
11import 'providers/multi_feed_provider.dart';
12import 'providers/user_profile_provider.dart';
13import 'providers/vote_provider.dart';
14import 'screens/auth/login_screen.dart';
15import 'screens/home/main_shell_screen.dart';
16import 'screens/home/post_detail_screen.dart';
17import 'screens/home/profile_screen.dart';
18import 'screens/landing_screen.dart';
19import 'services/comment_service.dart';
20import 'services/comments_provider_cache.dart';
21import 'services/streamable_service.dart';
22import 'services/vote_service.dart';
23import 'widgets/loading_error_states.dart';
24
25void main() async {
26 WidgetsFlutterBinding.ensureInitialized();
27
28 // Set system UI overlay style (Android navigation bar)
29 SystemChrome.setSystemUIOverlayStyle(
30 const SystemUiOverlayStyle(
31 systemNavigationBarColor: Color(0xFF0B0F14),
32 systemNavigationBarIconBrightness: Brightness.light,
33 ),
34 );
35
36 // Initialize auth provider
37 final authProvider = AuthProvider();
38 await authProvider.initialize();
39
40 // Initialize vote service with auth callbacks
41 // Votes go through the Coves backend (which proxies to PDS with DPoP)
42 // Includes token refresh and sign-out handlers for automatic 401 recovery
43 final voteService = VoteService(
44 sessionGetter: () async => authProvider.session,
45 didGetter: () => authProvider.did,
46 tokenRefresher: authProvider.refreshToken,
47 signOutHandler: authProvider.signOut,
48 );
49
50 // Initialize comment service with auth callbacks
51 // Comments go through the Coves backend (which proxies to PDS with DPoP)
52 final commentService = CommentService(
53 sessionGetter: () async => authProvider.session,
54 tokenRefresher: authProvider.refreshToken,
55 signOutHandler: authProvider.signOut,
56 );
57
58 runApp(
59 MultiProvider(
60 providers: [
61 ChangeNotifierProvider.value(value: authProvider),
62 ChangeNotifierProvider(
63 create:
64 (_) => VoteProvider(
65 voteService: voteService,
66 authProvider: authProvider,
67 ),
68 ),
69 ChangeNotifierProxyProvider2<
70 AuthProvider,
71 VoteProvider,
72 MultiFeedProvider
73 >(
74 create:
75 (context) => MultiFeedProvider(
76 authProvider,
77 voteProvider: context.read<VoteProvider>(),
78 ),
79 update: (context, auth, vote, previous) {
80 // Reuse existing provider to maintain state across rebuilds
81 return previous ?? MultiFeedProvider(auth, voteProvider: vote);
82 },
83 ),
84 // CommentsProviderCache manages per-post CommentsProvider instances
85 // with LRU eviction and sign-out cleanup
86 ProxyProvider2<AuthProvider, VoteProvider, CommentsProviderCache>(
87 create:
88 (context) => CommentsProviderCache(
89 authProvider: authProvider,
90 voteProvider: context.read<VoteProvider>(),
91 commentService: commentService,
92 ),
93 update: (context, auth, vote, previous) {
94 // Reuse existing cache
95 return previous ??
96 CommentsProviderCache(
97 authProvider: auth,
98 voteProvider: vote,
99 commentService: commentService,
100 );
101 },
102 dispose: (_, cache) => cache.dispose(),
103 ),
104 // StreamableService for video embeds
105 Provider<StreamableService>(create: (_) => StreamableService()),
106 // UserProfileProvider for profile pages
107 ChangeNotifierProxyProvider<AuthProvider, UserProfileProvider>(
108 create: (context) => UserProfileProvider(authProvider),
109 update: (context, auth, previous) {
110 // Propagate auth changes to existing provider
111 previous?.updateAuthProvider(auth);
112 return previous ?? UserProfileProvider(auth);
113 },
114 ),
115 ],
116 child: const CovesApp(),
117 ),
118 );
119}
120
121class CovesApp extends StatelessWidget {
122 const CovesApp({super.key});
123
124 @override
125 Widget build(BuildContext context) {
126 final authProvider = Provider.of<AuthProvider>(context, listen: false);
127
128 return MaterialApp.router(
129 title: 'Coves',
130 theme: ThemeData(
131 colorScheme: ColorScheme.fromSeed(
132 seedColor: AppColors.primary,
133 brightness: Brightness.dark,
134 ),
135 useMaterial3: true,
136 ),
137 routerConfig: _createRouter(authProvider),
138 restorationScopeId: 'app',
139 debugShowCheckedModeBanner: false,
140 );
141 }
142}
143
144// GoRouter configuration factory
145GoRouter _createRouter(AuthProvider authProvider) {
146 return GoRouter(
147 routes: [
148 GoRoute(path: '/', builder: (context, state) => const LandingScreen()),
149 GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
150 GoRoute(
151 path: '/feed',
152 builder: (context, state) => const MainShellScreen(),
153 ),
154 GoRoute(
155 path: '/profile/:actor',
156 builder: (context, state) {
157 final actor = state.pathParameters['actor']!;
158 return ProfileScreen(actor: actor);
159 },
160 ),
161 GoRoute(
162 path: '/post/:postUri',
163 builder: (context, state) {
164 // Extract post from state.extra
165 final post = state.extra as FeedViewPost?;
166
167 // If no post provided via extra, show user-friendly error
168 if (post == null) {
169 if (kDebugMode) {
170 print('⚠️ PostDetailScreen: No post provided in route extras');
171 }
172 // Show not found screen with option to go back
173 return NotFoundError(
174 title: 'Post Not Found',
175 message:
176 'This post could not be loaded. It may have been '
177 'deleted or the link is invalid.',
178 onBackPressed: () {
179 // Navigate back to feed
180 context.go('/feed');
181 },
182 );
183 }
184
185 return PostDetailScreen(post: post);
186 },
187 ),
188 ],
189 refreshListenable: authProvider,
190 redirect: (context, state) {
191 final isAuthenticated = authProvider.isAuthenticated;
192 final isLoading = authProvider.isLoading;
193 final currentPath = state.uri.path;
194
195 // Don't redirect while loading initial auth state
196 if (isLoading) {
197 return null;
198 }
199
200 // If authenticated and on landing/login screen, redirect to feed
201 if (isAuthenticated && (currentPath == '/' || currentPath == '/login')) {
202 if (kDebugMode) {
203 print('🔄 User authenticated, redirecting to /feed');
204 }
205 return '/feed';
206 }
207
208 // Allow anonymous users to access /feed for browsing
209 // Sign-out redirect is handled explicitly in the sign-out action
210 return null;
211 },
212 errorBuilder: (context, state) {
213 // Check if this is an OAuth callback
214 if (state.uri.scheme == OAuthConfig.customScheme) {
215 if (kDebugMode) {
216 print(
217 '⚠️ OAuth callback in errorBuilder - '
218 'flutter_web_auth_2 should handle it',
219 );
220 print(' URI: ${state.uri}');
221 }
222 // Return nothing - just stay on current screen
223 // flutter_web_auth_2 will process the callback at native level
224 return const SizedBox.shrink();
225 }
226
227 // For other errors, show landing page
228 if (kDebugMode) {
229 print('⚠️ Router error: ${state.uri}');
230 }
231 return const LandingScreen();
232 },
233 );
234}