main
dart 370 lines 12.8 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:flutter_localizations/flutter_localizations.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:go_router/go_router.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6 import 'l10n/app_localizations.dart';
7
8 import 'core/models/enums.dart';
9 import 'core/providers/app_providers.dart';
10 import 'core/routes/app_routes.dart';
11 import 'core/theme/app_theme.dart';
12 import 'core/utils/locale_utils.dart';
13 import 'core/widgets/android_assistant_invocation_host.dart';
14 import 'features/chat/providers/chat_providers.dart';
15 import 'features/conversations/providers/conversation_providers.dart' as conv;
16 import 'features/chat/views/chat_screen.dart';
17 import 'features/conversations/views/chat_history_screen.dart';
18 import 'features/mcp/views/mcp_tools_screen.dart';
19 import 'features/on_device/views/model_manager_screen.dart';
20 import 'features/os_widget/views/os_widget_invocation_host.dart';
21 import 'features/onboarding/screens/onboarding_language_screen.dart';
22 import 'features/onboarding/screens/onboarding_model_download_screen.dart';
23 import 'features/onboarding/screens/onboarding_notification_permission_screen.dart';
24 import 'features/onboarding/screens/onboarding_server_setup_screen.dart';
25 import 'features/onboarding/screens/onboarding_server_type_screen.dart';
26 import 'features/onboarding/screens/onboarding_theme_screen.dart';
27 import 'features/personas/views/create_persona_screen.dart';
28 import 'features/personas/views/persona_list_screen.dart';
29 import 'features/servers/data/models/server.dart';
30 import 'features/servers/views/add_server_screen.dart';
31 import 'features/servers/views/server_list_screen.dart';
32 import 'features/tts/views/tts_model_manager_screen.dart';
33 import 'features/saved_messages/views/saved_messages_screen.dart';
34 import 'features/settings/views/settings_screen.dart';
35 import 'features/cloud_sync/views/cloud_sync_screen.dart';
36 import 'features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart';
37 import 'features/sidebar/sidebar_drawer.dart';
38 import 'features/sidebar/sidebar_widget.dart';
39
40 final _rootNavigatorKey = GlobalKey<NavigatorState>();
41 final _shellNavigatorKey = GlobalKey<NavigatorState>();
42
43 final routerProvider = Provider<GoRouter>((ref) {
44 return GoRouter(
45 navigatorKey: _rootNavigatorKey,
46 initialLocation: '/',
47 redirect: (context, state) {
48 final hasCompletedOnboarding = ref
49 .read(settingsProvider)
50 .hasCompletedOnboarding;
51 final isGoingToOnboarding = state.uri.toString().startsWith(
52 '/onboarding',
53 );
54
55 if (!hasCompletedOnboarding && !isGoingToOnboarding) {
56 return AppRoutes.onboarding;
57 }
58
59 if (hasCompletedOnboarding && isGoingToOnboarding) {
60 return AppRoutes.home;
61 }
62
63 return null;
64 },
65 routes: [
66 GoRoute(
67 path: AppRoutes.onboarding,
68 pageBuilder: (context, state) =>
69 const MaterialPage(child: OnboardingLanguageScreen()),
70 ),
71 GoRoute(
72 path: AppRoutes.onboardingServerType,
73 pageBuilder: (context, state) =>
74 const MaterialPage(child: OnboardingServerTypeScreen()),
75 ),
76 GoRoute(
77 path: AppRoutes.onboardingSetup,
78 pageBuilder: (context, state) {
79 final serverType = state.extra as ServerType?;
80 return MaterialPage(
81 child: OnboardingServerSetupScreen(
82 selectedType: serverType ?? ServerType.lmStudio,
83 ),
84 );
85 },
86 ),
87 GoRoute(
88 path: AppRoutes.onboardingModelDownload,
89 pageBuilder: (context, state) =>
90 const MaterialPage(child: OnboardingModelDownloadScreen()),
91 ),
92 GoRoute(
93 path: AppRoutes.onboardingTheme,
94 pageBuilder: (context, state) =>
95 const MaterialPage(child: OnboardingThemeScreen()),
96 ),
97 GoRoute(
98 path: AppRoutes.onboardingNotifications,
99 pageBuilder: (context, state) =>
100 const MaterialPage(child: OnboardingNotificationPermissionScreen()),
101 ),
102 ShellRoute(
103 navigatorKey: _shellNavigatorKey,
104 builder: (context, state, child) {
105 return OsWidgetInvocationHost(
106 child: AndroidAssistantInvocationHost(
107 child: AppShell(child: child),
108 ),
109 );
110 },
111 routes: [
112 GoRoute(
113 path: AppRoutes.home,
114 pageBuilder: (context, state) =>
115 const NoTransitionPage(child: ChatScreen()),
116 ),
117 GoRoute(
118 path: AppRoutes.servers,
119 pageBuilder: (context, state) =>
120 const NoTransitionPage(child: ServerListScreen()),
121 ),
122 GoRoute(
123 path: AppRoutes.addServer,
124 pageBuilder: (context, state) {
125 final server = state.extra as Server?;
126 return MaterialPage(child: AddServerScreen(editServer: server));
127 },
128 ),
129 GoRoute(
130 path: AppRoutes.personas,
131 pageBuilder: (context, state) =>
132 const NoTransitionPage(child: PersonaListScreen()),
133 ),
134 GoRoute(
135 path: AppRoutes.createPersona,
136 pageBuilder: (context, state) {
137 final persona = state.extra as dynamic;
138 return MaterialPage(
139 child: CreatePersonaScreen(editPersona: persona),
140 );
141 },
142 ),
143 GoRoute(
144 path: AppRoutes.settings,
145 pageBuilder: (context, state) =>
146 const NoTransitionPage(child: SettingsViews()),
147 ),
148 GoRoute(
149 path: AppRoutes.cloudSync,
150 pageBuilder: (context, state) =>
151 const MaterialPage(child: CloudSyncScreen()),
152 ),
153 GoRoute(
154 path: AppRoutes.chatHistory,
155 pageBuilder: (context, state) =>
156 const NoTransitionPage(child: ChatHistoryScreen()),
157 ),
158 GoRoute(
159 path: AppRoutes.mcpTools,
160 pageBuilder: (context, state) =>
161 const NoTransitionPage(child: McpToolsScreen()),
162 ),
163 GoRoute(
164 path: AppRoutes.onDeviceModels,
165 pageBuilder: (context, state) =>
166 const NoTransitionPage(child: OnDeviceModelManagerScreen()),
167 ),
168 GoRoute(
169 path: AppRoutes.ttsModels,
170 pageBuilder: (context, state) =>
171 const NoTransitionPage(child: TtsModelManagerScreen()),
172 ),
173 GoRoute(
174 path: AppRoutes.savedMessages,
175 pageBuilder: (context, state) =>
176 const NoTransitionPage(child: SavedMessagesScreen()),
177 ),
178 GoRoute(
179 path: AppRoutes.lmStudioModelBrowser,
180 pageBuilder: (context, state) {
181 final server = state.extra as Server?;
182 if (server == null) {
183 return const MaterialPage(child: SizedBox.shrink());
184 }
185 return MaterialPage(
186 child: LmStudioModelBrowserScreen(server: server),
187 );
188 },
189 ),
190 ],
191 ),
192 ],
193 );
194 });
195
196 class App extends ConsumerWidget {
197 const App({super.key});
198
199 @override
200 Widget build(BuildContext context, WidgetRef ref) {
201 final router = ref.watch(routerProvider);
202 final appThemeType = ref.watch(themeModeProvider);
203 final localeCode = ref.watch(settingsProvider.select((s) => s.localeCode));
204 final selectedLocale = findSupportedLocale(
205 localeCode,
206 AppLocalizations.supportedLocales,
207 );
208
209 ThemeData theme = AppTheme.lightTheme;
210 ThemeData darkTheme = AppTheme.darkTheme;
211 ThemeMode themeMode = ThemeMode.system;
212
213 var shadTheme = AppTheme.lightShadTheme;
214 var shadDarkTheme = AppTheme.darkShadTheme;
215
216 switch (appThemeType) {
217 case AppThemeType.light:
218 themeMode = ThemeMode.light;
219 shadTheme = AppTheme.lightShadTheme;
220 break;
221 case AppThemeType.dark:
222 themeMode = ThemeMode.dark;
223 shadTheme = AppTheme.darkShadTheme;
224 // Also ensure shadDarkTheme matches so there's no mismatch
225 shadDarkTheme = AppTheme.darkShadTheme;
226 break;
227 case AppThemeType.claude:
228 themeMode = ThemeMode.light; // Force light mode for claude basically
229 theme = AppTheme.claudeTheme;
230 shadTheme = AppTheme.claudeShadTheme;
231 break;
232 case AppThemeType.system:
233 themeMode = ThemeMode.system;
234 shadTheme = AppTheme.lightShadTheme;
235 shadDarkTheme = AppTheme.darkShadTheme;
236 break;
237 }
238
239 return ShadApp.custom(
240 themeMode: themeMode,
241 theme: shadTheme,
242 darkTheme: shadDarkTheme,
243 appBuilder: (context) {
244 return MaterialApp.router(
245 title: 'LocalMind',
246 debugShowCheckedModeBanner: false,
247 theme: theme,
248 darkTheme: darkTheme,
249 themeMode: themeMode,
250 routerConfig: router,
251 localizationsDelegates: const [
252 AppLocalizations.delegate,
253 GlobalMaterialLocalizations.delegate,
254 GlobalWidgetsLocalizations.delegate,
255 GlobalCupertinoLocalizations.delegate,
256 ],
257 supportedLocales: AppLocalizations.supportedLocales,
258 locale: selectedLocale,
259 localeResolutionCallback: (locale, supportedLocales) {
260 if (selectedLocale != null) {
261 return selectedLocale;
262 }
263 for (final l in supportedLocales) {
264 if (l.languageCode == locale?.languageCode) return l;
265 }
266 return const Locale('en');
267 },
268 builder: (context, child) {
269 return ShadAppBuilder(child: child!);
270 },
271 );
272 },
273 );
274 }
275 }
276
277 class AppShell extends ConsumerWidget {
278 final Widget child;
279
280 const AppShell({super.key, required this.child});
281
282 @override
283 Widget build(BuildContext context, WidgetRef ref) {
284 final theme = Theme.of(context);
285 final isDark = theme.brightness == Brightness.dark;
286 final location = GoRouterState.of(context).uri.path;
287 final isHome = location == AppRoutes.home;
288 final hasActiveChat = ref.watch(conv.activeConversationProvider) != null;
289
290 void handleBack() {
291 if (!isHome) {
292 context.go(AppRoutes.home);
293 return;
294 }
295 if (hasActiveChat) {
296 final origin = ref.read(chatOriginProvider);
297 ref.read(chatOriginProvider.notifier).clear();
298 switch (origin) {
299 case ChatOrigin.history:
300 context.go(AppRoutes.chatHistory);
301 case ChatOrigin.savedMessages:
302 context.go(AppRoutes.savedMessages);
303 case ChatOrigin.none:
304 ref.read(chatProvider.notifier).startNewConversation();
305 }
306 }
307 // On the empty home screen on mobile, the back press is handled
308 // by the inner PopScope below (which opens the drawer instead of
309 // exiting the app).
310 }
311
312 return ShadResponsiveBuilder(
313 builder: (context, breakpoint) {
314 final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
315
316 if (isDesktop) {
317 return Scaffold(
318 body: PopScope(
319 // Always intercept back — never let it silently exit the
320 // app from inside the shell. The handler decides what back
321 // means for the current screen.
322 canPop: false,
323 onPopInvokedWithResult: (didPop, _) {
324 if (didPop) return;
325 handleBack();
326 },
327 child: Row(
328 children: [
329 const SidebarWidget(),
330 VerticalDivider(
331 width: 1,
332 thickness: 1,
333 color: isDark
334 ? const Color(0xFF1A1A1A)
335 : const Color(0xFFE5E5E5),
336 ),
337 Expanded(child: child),
338 ],
339 ),
340 ),
341 );
342 }
343
344 return Scaffold(
345 body: Builder(
346 builder: (scaffoldContext) => PopScope(
347 canPop: false,
348 onPopInvokedWithResult: (didPop, _) {
349 if (didPop) return;
350 if (isHome && !hasActiveChat) {
351 // On the empty home screen, open the side drawer instead
352 // of exiting the app — matches the menu icon on the
353 // top-left. This context must be below the Scaffold.
354 // Use maybeOf so we never throw if no Scaffold ancestor
355 // is present (e.g. when this widget is embedded in a
356 // route or surface that doesn't provide one).
357 Scaffold.maybeOf(scaffoldContext)?.openDrawer();
358 return;
359 }
360 handleBack();
361 },
362 child: child,
363 ),
364 ),
365 drawer: const ConversationDrawer(),
366 );
367 },
368 );
369 }
370 }