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