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/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<AuthProvider, VoteProvider, FeedProvider>(
68 create:
69 (context) => FeedProvider(
70 authProvider,
71 voteProvider: context.read<VoteProvider>(),
72 ),
73 update: (context, auth, vote, previous) {
74 // Reuse existing provider to maintain state across rebuilds
75 return previous ?? FeedProvider(auth, voteProvider: vote);
76 },
77 ),
78 // CommentsProviderCache manages per-post CommentsProvider instances
79 // with LRU eviction and sign-out cleanup
80 ProxyProvider2<AuthProvider, VoteProvider, CommentsProviderCache>(
81 create: (context) => CommentsProviderCache(
82 authProvider: authProvider,
83 voteProvider: context.read<VoteProvider>(),
84 commentService: commentService,
85 ),
86 update: (context, auth, vote, previous) {
87 // Reuse existing cache
88 return previous ?? CommentsProviderCache(
89 authProvider: auth,
90 voteProvider: vote,
91 commentService: commentService,
92 );
93 },
94 dispose: (_, cache) => cache.dispose(),
95 ),
96 // StreamableService for video embeds
97 Provider<StreamableService>(create: (_) => StreamableService()),
98 ],
99 child: const CovesApp(),
100 ),
101 );
102}
103
104class CovesApp extends StatelessWidget {
105 const CovesApp({super.key});
106
107 @override
108 Widget build(BuildContext context) {
109 final authProvider = Provider.of<AuthProvider>(context, listen: false);
110
111 return MaterialApp.router(
112 title: 'Coves',
113 theme: ThemeData(
114 colorScheme: ColorScheme.fromSeed(
115 seedColor: AppColors.primary,
116 brightness: Brightness.dark,
117 ),
118 useMaterial3: true,
119 ),
120 routerConfig: _createRouter(authProvider),
121 restorationScopeId: 'app',
122 debugShowCheckedModeBanner: false,
123 );
124 }
125}
126
127// GoRouter configuration factory
128GoRouter _createRouter(AuthProvider authProvider) {
129 return GoRouter(
130 routes: [
131 GoRoute(path: '/', builder: (context, state) => const LandingScreen()),
132 GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
133 GoRoute(
134 path: '/feed',
135 builder: (context, state) => const MainShellScreen(),
136 ),
137 GoRoute(
138 path: '/post/:postUri',
139 builder: (context, state) {
140 // Extract post from state.extra
141 final post = state.extra as FeedViewPost?;
142
143 // If no post provided via extra, show user-friendly error
144 if (post == null) {
145 if (kDebugMode) {
146 print('⚠️ PostDetailScreen: No post provided in route extras');
147 }
148 // Show not found screen with option to go back
149 return NotFoundError(
150 title: 'Post Not Found',
151 message:
152 'This post could not be loaded. It may have been '
153 'deleted or the link is invalid.',
154 onBackPressed: () {
155 // Navigate back to feed
156 context.go('/feed');
157 },
158 );
159 }
160
161 return PostDetailScreen(post: post);
162 },
163 ),
164 ],
165 refreshListenable: authProvider,
166 redirect: (context, state) {
167 final isAuthenticated = authProvider.isAuthenticated;
168 final isLoading = authProvider.isLoading;
169 final currentPath = state.uri.path;
170
171 // Don't redirect while loading initial auth state
172 if (isLoading) {
173 return null;
174 }
175
176 // If authenticated and on landing/login screen, redirect to feed
177 if (isAuthenticated && (currentPath == '/' || currentPath == '/login')) {
178 if (kDebugMode) {
179 print('🔄 User authenticated, redirecting to /feed');
180 }
181 return '/feed';
182 }
183
184 // Allow anonymous users to access /feed for browsing
185 // Sign-out redirect is handled explicitly in the sign-out action
186 return null;
187 },
188 errorBuilder: (context, state) {
189 // Check if this is an OAuth callback
190 if (state.uri.scheme == OAuthConfig.customScheme) {
191 if (kDebugMode) {
192 print(
193 '⚠️ OAuth callback in errorBuilder - '
194 'flutter_web_auth_2 should handle it',
195 );
196 print(' URI: ${state.uri}');
197 }
198 // Return nothing - just stay on current screen
199 // flutter_web_auth_2 will process the callback at native level
200 return const SizedBox.shrink();
201 }
202
203 // For other errors, show landing page
204 if (kDebugMode) {
205 print('⚠️ Router error: ${state.uri}');
206 }
207 return const LandingScreen();
208 },
209 );
210}