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