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