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