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