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/comments_provider.dart';
12import 'providers/feed_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/landing_screen.dart';
18import 'services/streamable_service.dart';
19import 'services/vote_service.dart';
20import 'widgets/loading_error_states.dart';
21
22void main() async {
23 WidgetsFlutterBinding.ensureInitialized();
24
25 // Set system UI overlay style (Android navigation bar)
26 SystemChrome.setSystemUIOverlayStyle(
27 const SystemUiOverlayStyle(
28 systemNavigationBarColor: Color(0xFF0B0F14),
29 systemNavigationBarIconBrightness: Brightness.light,
30 ),
31 );
32
33 // Initialize auth provider
34 final authProvider = AuthProvider();
35 await authProvider.initialize();
36
37 // Initialize vote service with auth callbacks
38 // Votes go through the Coves backend (which proxies to PDS with DPoP)
39 // Includes token refresh and sign-out handlers for automatic 401 recovery
40 final voteService = VoteService(
41 sessionGetter: () async => authProvider.session,
42 didGetter: () => authProvider.did,
43 tokenRefresher: authProvider.refreshToken,
44 signOutHandler: authProvider.signOut,
45 );
46
47 runApp(
48 MultiProvider(
49 providers: [
50 ChangeNotifierProvider.value(value: authProvider),
51 ChangeNotifierProvider(
52 create:
53 (_) => VoteProvider(
54 voteService: voteService,
55 authProvider: authProvider,
56 ),
57 ),
58 ChangeNotifierProxyProvider2<AuthProvider, VoteProvider, FeedProvider>(
59 create:
60 (context) => FeedProvider(
61 authProvider,
62 voteProvider: context.read<VoteProvider>(),
63 voteService: voteService,
64 ),
65 update: (context, auth, vote, previous) {
66 // Reuse existing provider to maintain state across rebuilds
67 return previous ??
68 FeedProvider(
69 auth,
70 voteProvider: vote,
71 voteService: voteService,
72 );
73 },
74 ),
75 ChangeNotifierProxyProvider2<
76 AuthProvider,
77 VoteProvider,
78 CommentsProvider
79 >(
80 create:
81 (context) => CommentsProvider(
82 authProvider,
83 voteProvider: context.read<VoteProvider>(),
84 voteService: voteService,
85 ),
86 update: (context, auth, vote, previous) {
87 // Reuse existing provider to maintain state across rebuilds
88 return previous ??
89 CommentsProvider(
90 auth,
91 voteProvider: vote,
92 voteService: voteService,
93 );
94 },
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}