| 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 | } |