| 1 | import 'package:cue/cue.dart'; |
| 2 | import 'package:flutter/material.dart'; |
| 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 4 | import 'package:go_router/go_router.dart'; |
| 5 | import 'package:hugeicons/hugeicons.dart'; |
| 6 | import 'package:shadcn_ui/shadcn_ui.dart'; |
| 7 | |
| 8 | import '../../core/routes/app_routes.dart'; |
| 9 | import '../../core/components/app_sizes.dart'; |
| 10 | import '../../core/models/enums.dart'; |
| 11 | import '../../l10n/app_localizations.dart'; |
| 12 | import '../chat/providers/chat_providers.dart'; |
| 13 | import '../lm_studio_catalog/views/lm_studio_download_widgets.dart'; |
| 14 | import '../servers/providers/server_providers.dart'; |
| 15 | import 'components/active_server_indicator.dart'; |
| 16 | import 'components/conversation_drawer_header.dart'; |
| 17 | import 'components/drawer_nav_item.dart'; |
| 18 | import 'components/github_repo_card.dart'; |
| 19 | import 'components/sidebar_search_button.dart'; |
| 20 | import '../tts/views/components/tts_player_bar.dart'; |
| 21 | |
| 22 | class SidebarWidget extends ConsumerWidget { |
| 23 | const SidebarWidget({super.key}); |
| 24 | |
| 25 | @override |
| 26 | Widget build(BuildContext context, WidgetRef ref) { |
| 27 | final l10n = AppLocalizations.of(context)!; |
| 28 | final theme = Theme.of(context); |
| 29 | final location = GoRouterState.of(context).uri.toString(); |
| 30 | |
| 31 | final isHistory = location.startsWith(AppRoutes.chatHistory); |
| 32 | final isSavedMessages = location.startsWith(AppRoutes.savedMessages); |
| 33 | final isServers = location.startsWith(AppRoutes.servers); |
| 34 | final isMcpTools = location.startsWith(AppRoutes.mcpTools); |
| 35 | final isPersonas = location.startsWith(AppRoutes.personas); |
| 36 | final isLocalModels = location.startsWith(AppRoutes.onDeviceModels); |
| 37 | final isTtsModels = location.startsWith(AppRoutes.ttsModels); |
| 38 | final isCloudSync = location.startsWith(AppRoutes.cloudSync); |
| 39 | final isSettings = location == AppRoutes.settings; |
| 40 | final isHome = location == AppRoutes.home || location == '/'; |
| 41 | final hasActiveChat = ref.watch(hasActiveChatSessionProvider); |
| 42 | final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary)); |
| 43 | final activeServer = ref.watch(activeServerProvider); |
| 44 | final isLmStudio = |
| 45 | activeServer != null && activeServer.type == ServerType.lmStudio; |
| 46 | |
| 47 | return Container( |
| 48 | width: AppSizes.sidebarWidth, |
| 49 | decoration: BoxDecoration( |
| 50 | color: theme.scaffoldBackgroundColor, |
| 51 | border: Directionality.of(context) == TextDirection.rtl |
| 52 | ? Border(left: BorderSide(color: theme.colorScheme.outline)) |
| 53 | : Border(right: BorderSide(color: theme.colorScheme.outline)), |
| 54 | ), |
| 55 | child: SafeArea( |
| 56 | child: Cue.onMount( |
| 57 | motion: .smooth(), |
| 58 | child: Column( |
| 59 | children: [ |
| 60 | Actor( |
| 61 | acts: [.fadeIn(), .slideX(from: -0.05)], |
| 62 | child: const ConversationDrawerHeader(), |
| 63 | ), |
| 64 | |
| 65 | // New Chat Button |
| 66 | Actor( |
| 67 | delay: 60.ms, |
| 68 | acts: [.fadeIn(), .slideX(from: -0.04)], |
| 69 | child: Padding( |
| 70 | padding: const EdgeInsets.symmetric( |
| 71 | horizontal: 16, |
| 72 | vertical: 8, |
| 73 | ), |
| 74 | child: ShadButton( |
| 75 | width: double.infinity, |
| 76 | leading: const HugeIcon( |
| 77 | icon: HugeIcons.strokeRoundedAdd01, |
| 78 | size: 20, |
| 79 | ), |
| 80 | onPressed: () { |
| 81 | ref.read(chatProvider.notifier).startNewConversation(); |
| 82 | context.go(AppRoutes.home); |
| 83 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) { |
| 84 | Navigator.pop(context); |
| 85 | } |
| 86 | }, |
| 87 | child: Text(l10n.nav_new_chat), |
| 88 | ), |
| 89 | ), |
| 90 | ), |
| 91 | |
| 92 | if (hasActiveChat) |
| 93 | Actor( |
| 94 | delay: 120.ms, |
| 95 | acts: [.fadeIn(), .slideX(from: -0.04)], |
| 96 | child: Padding( |
| 97 | padding: EdgeInsets.fromLTRB(16, 0, 16, isHome ? 8 : 8), |
| 98 | child: isTemporary |
| 99 | ? ShadButton.outline( |
| 100 | width: double.infinity, |
| 101 | leading: const HugeIcon( |
| 102 | icon: HugeIcons.strokeRoundedArrowLeft01, |
| 103 | size: 18, |
| 104 | ), |
| 105 | onPressed: () { |
| 106 | if (!isHome) context.go(AppRoutes.home); |
| 107 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 108 | false) { |
| 109 | Navigator.pop(context); |
| 110 | } |
| 111 | }, |
| 112 | child: Text(l10n.return_to_temp_chat), |
| 113 | ) |
| 114 | : ShadButton.secondary( |
| 115 | width: double.infinity, |
| 116 | leading: const HugeIcon( |
| 117 | icon: HugeIcons.strokeRoundedArrowLeft01, |
| 118 | size: 18, |
| 119 | ), |
| 120 | onPressed: () { |
| 121 | if (!isHome) context.go(AppRoutes.home); |
| 122 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 123 | false) { |
| 124 | Navigator.pop(context); |
| 125 | } |
| 126 | }, |
| 127 | child: Text(l10n.return_to_chat), |
| 128 | ), |
| 129 | ), |
| 130 | ), |
| 131 | |
| 132 | Actor( |
| 133 | delay: 180.ms, |
| 134 | acts: [.fadeIn()], |
| 135 | child: const SidebarSearchButton(), |
| 136 | ), |
| 137 | const SizedBox(height: 8), |
| 138 | const Divider(height: 1, indent: 16, endIndent: 16), |
| 139 | const SizedBox(height: 12), |
| 140 | |
| 141 | // Primary Navigation |
| 142 | Expanded( |
| 143 | child: Actor( |
| 144 | delay: 220.ms, |
| 145 | acts: [.fadeIn(), .slideY(from: 0.04)], |
| 146 | child: SingleChildScrollView( |
| 147 | child: Column( |
| 148 | children: [ |
| 149 | DrawerNavItem( |
| 150 | iconData: HugeIcons.strokeRoundedChatting01, |
| 151 | label: l10n.nav_history, |
| 152 | isSelected: isHistory, |
| 153 | onTap: () { |
| 154 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 155 | false) { |
| 156 | Navigator.pop(context); |
| 157 | } |
| 158 | context.go(AppRoutes.chatHistory); |
| 159 | }, |
| 160 | ), |
| 161 | DrawerNavItem( |
| 162 | iconData: HugeIcons.strokeRoundedBookmark02, |
| 163 | label: l10n.nav_saved_messages, |
| 164 | isSelected: isSavedMessages, |
| 165 | onTap: () { |
| 166 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 167 | false) { |
| 168 | Navigator.pop(context); |
| 169 | } |
| 170 | context.go(AppRoutes.savedMessages); |
| 171 | }, |
| 172 | ), |
| 173 | DrawerNavItem( |
| 174 | iconData: HugeIcons.strokeRoundedServerStack01, |
| 175 | label: l10n.nav_servers, |
| 176 | isSelected: isServers, |
| 177 | onTap: () { |
| 178 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 179 | false) { |
| 180 | Navigator.pop(context); |
| 181 | } |
| 182 | context.go(AppRoutes.servers); |
| 183 | }, |
| 184 | ), |
| 185 | DrawerNavItem( |
| 186 | iconData: HugeIcons.strokeRoundedMcpServer, |
| 187 | label: l10n.mcp_tools_title, |
| 188 | isSelected: isMcpTools, |
| 189 | onTap: () { |
| 190 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 191 | false) { |
| 192 | Navigator.pop(context); |
| 193 | } |
| 194 | context.go(AppRoutes.mcpTools); |
| 195 | }, |
| 196 | ), |
| 197 | if (isLmStudio) |
| 198 | DrawerNavItem( |
| 199 | iconData: HugeIcons.strokeRoundedAiSearch, |
| 200 | label: l10n.lm_studio_model_search, |
| 201 | isSelected: false, |
| 202 | trailing: const LmDownloadIndicatorButton( |
| 203 | compact: true, |
| 204 | ), |
| 205 | onTap: () { |
| 206 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 207 | false) { |
| 208 | Navigator.pop(context); |
| 209 | } |
| 210 | context.push( |
| 211 | AppRoutes.lmStudioModelBrowser, |
| 212 | extra: activeServer, |
| 213 | ); |
| 214 | }, |
| 215 | ), |
| 216 | DrawerNavItem( |
| 217 | iconData: HugeIcons.strokeRoundedSmartPhone01, |
| 218 | label: l10n.nav_local_models, |
| 219 | isSelected: isLocalModels, |
| 220 | onTap: () { |
| 221 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 222 | false) { |
| 223 | Navigator.pop(context); |
| 224 | } |
| 225 | context.go(AppRoutes.onDeviceModels); |
| 226 | }, |
| 227 | ), |
| 228 | DrawerNavItem( |
| 229 | iconData: HugeIcons.strokeRoundedVoice, |
| 230 | label: l10n.nav_tts, |
| 231 | isSelected: isTtsModels, |
| 232 | onTap: () { |
| 233 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 234 | false) { |
| 235 | Navigator.pop(context); |
| 236 | } |
| 237 | context.go(AppRoutes.ttsModels); |
| 238 | }, |
| 239 | ), |
| 240 | DrawerNavItem( |
| 241 | iconData: HugeIcons.strokeRoundedCompass01, |
| 242 | label: l10n.nav_personas, |
| 243 | isSelected: isPersonas, |
| 244 | onTap: () { |
| 245 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 246 | false) { |
| 247 | Navigator.pop(context); |
| 248 | } |
| 249 | context.go(AppRoutes.personas); |
| 250 | }, |
| 251 | ), |
| 252 | const SizedBox(height: 8), |
| 253 | const Divider(height: 1, indent: 16, endIndent: 16), |
| 254 | const SizedBox(height: 8), |
| 255 | DrawerNavItem( |
| 256 | iconData: HugeIcons.strokeRoundedCloudSavingDone02, |
| 257 | label: l10n.cloud_sync, |
| 258 | isSelected: isCloudSync, |
| 259 | onTap: () { |
| 260 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 261 | false) { |
| 262 | Navigator.pop(context); |
| 263 | } |
| 264 | context.go(AppRoutes.cloudSync); |
| 265 | }, |
| 266 | ), |
| 267 | DrawerNavItem( |
| 268 | iconData: HugeIcons.strokeRoundedSettings01, |
| 269 | label: l10n.nav_settings, |
| 270 | isSelected: isSettings, |
| 271 | onTap: () { |
| 272 | if (Scaffold.maybeOf(context)?.isDrawerOpen ?? |
| 273 | false) { |
| 274 | Navigator.pop(context); |
| 275 | } |
| 276 | context.go(AppRoutes.settings); |
| 277 | }, |
| 278 | ), |
| 279 | ], |
| 280 | ), |
| 281 | ), |
| 282 | ), |
| 283 | ), |
| 284 | |
| 285 | Actor( |
| 286 | delay: 320.ms, |
| 287 | acts: [.fadeIn(), .slideY(from: 0.06)], |
| 288 | child: const TtsPlayerBar(), |
| 289 | ), |
| 290 | |
| 291 | // Bottom Section |
| 292 | Actor( |
| 293 | delay: 360.ms, |
| 294 | acts: [.fadeIn(), .slideY(from: 0.08)], |
| 295 | child: const GitHubRepoCard(), |
| 296 | ), |
| 297 | const Divider(height: 1), |
| 298 | Actor( |
| 299 | delay: 400.ms, |
| 300 | acts: [.fadeIn()], |
| 301 | child: const ActiveServerIndicator(), |
| 302 | ), |
| 303 | const SizedBox(height: 16), |
| 304 | ], |
| 305 | ), |
| 306 | ), |
| 307 | ), |
| 308 | ); |
| 309 | } |
| 310 | } |