@tduncan / Cortana / commits / dd3b325

feat: add on-device model support and overhaul sidebar UI components with search and theme toggles

abdulmominsakib committed Apr 16, 2026 at 18:05 UTC dd3b3254d4e413837589d9d163fc555a51798561
9 files changed +636 -179
lib/features/chat/providers/chat_providers.dart
+9 -2
@@ -68,8 +68,15 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
68 if (activeServer.type == ServerType.openRouter) return;
69
70 try {
71 - final apiService = ref.read(serverApiServiceProvider);
72 - final loadedModels = await apiService.fetchRunningModels(activeServer);
71 + final Set<String> loadedModels;
72 + if (activeServer.type == ServerType.onDevice) {
73 + final engine = ref.watch(onDeviceEngineProvider);
74 + loadedModels = engine.loadedModelId != null ? {engine.loadedModelId!} : {};
75 + } else {
76 + final apiService = ref.read(serverApiServiceProvider);
77 + loadedModels = await apiService.fetchRunningModels(activeServer);
78 + }
79 +
80 if (loadedModels.isEmpty) return;
81
82 final availableModels = await ref.read(
lib/features/models/screens/model_picker_sheet.dart
+46 -15
@@ -757,18 +757,29 @@ class _OnDeviceModelTile extends ConsumerWidget {
757 downloadProgress.status == DownloadStatus.pending);
758 final isPaused = downloadProgress?.status == DownloadStatus.paused;
759
760 - return Container(
761 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
762 - margin: const EdgeInsets.only(bottom: 4),
763 - decoration: BoxDecoration(
764 - color: isSelected ? accent.withValues(alpha: 0.1) : Colors.transparent,
765 - borderRadius: BorderRadius.circular(8),
766 - border: isSelected
767 - ? Border.all(color: accent.withValues(alpha: 0.3))
768 - : null,
769 - ),
770 - child: Row(
771 - children: [
760 + return InkWell(
761 + onTap: () {
762 + if (isLoaded) {
763 + _selectModel(context, ref);
764 + } else if (isDownloaded && !isCurrentlyLoading && !isDownloading) {
765 + _loadModel(context, ref);
766 + }
767 + },
768 + borderRadius: BorderRadius.circular(8),
769 + child: Container(
770 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
771 + margin: const EdgeInsets.only(bottom: 4),
772 + decoration: BoxDecoration(
773 + color: isSelected
774 + ? accent.withValues(alpha: 0.1)
775 + : Colors.transparent,
776 + borderRadius: BorderRadius.circular(8),
777 + border: isSelected
778 + ? Border.all(color: accent.withValues(alpha: 0.3))
779 + : null,
780 + ),
781 + child: Row(
782 + children: [
783 Expanded(
784 child: Column(
785 crossAxisAlignment: CrossAxisAlignment.start,
@@ -938,8 +949,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
949 ],
950 ],
951 ),
941 - );
942 - }
952 + ),
953 + );
954 +}
955
956 void _loadModel(BuildContext context, WidgetRef ref) async {
957 final settings = ref.read(settingsProvider);
@@ -953,7 +965,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
965 model.parameterLabel.replaceAll(RegExp(r'[^0-9]'), ''),
966 ),
967 serverType: ServerType.onDevice,
956 - serverId: '',
968 + serverId: 'on-device',
969 );
970
971 await engineNotifier.loadModel(model.id, settings.preferredBackend);
@@ -961,9 +973,28 @@ class _OnDeviceModelTile extends ConsumerWidget {
973 final engineState = ref.read(onDeviceEngineProvider);
974 if (engineState.loadedModelId == model.id) {
975 ref.read(selectedModelProvider.notifier).setModel(modelInfo);
976 + if (context.mounted) {
977 + Navigator.pop(context);
978 + }
979 }
980 }
981
982 + void _selectModel(BuildContext context, WidgetRef ref) {
983 + final modelInfo = ModelInfo(
984 + id: model.id,
985 + name: model.name,
986 + description: model.description,
987 + parameterCount: int.tryParse(
988 + model.parameterLabel.replaceAll(RegExp(r'[^0-9]'), ''),
989 + ),
990 + serverType: ServerType.onDevice,
991 + serverId: 'on-device',
992 + );
993 +
994 + ref.read(selectedModelProvider.notifier).setModel(modelInfo);
995 + Navigator.pop(context);
996 + }
997 +
998 void _unloadModel(BuildContext context, WidgetRef ref) async {
999 final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1000 await engineNotifier.unloadModel();
lib/features/servers/providers/server_providers.dart
+31
@@ -8,6 +8,9 @@ import '../../../core/storage/entities.dart';
8 import '../../models/data/model_cache.dart';
9
10 import '../../../objectbox.g.dart';
11 +import '../../models/data/models/model_info.dart';
12 +import '../../on_device/providers/on_device_providers.dart';
13 +import '../../on_device/data/models/on_device_model.dart';
14
15 final _modelCache = ModelCache();
16
@@ -79,6 +82,14 @@ final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
82 server,
83 ) async {
84 ref.watch(loadedModelsRefreshProvider);
85 +
86 + if (server.type == ServerType.onDevice) {
87 + final engineState = ref.watch(onDeviceEngineProvider);
88 + return engineState.loadedModelId != null
89 + ? {engineState.loadedModelId!}
90 + : {};
91 + }
92 +
93 final apiService = ref.watch(serverApiServiceProvider);
94 try {
95 return await apiService.fetchRunningModels(server);
@@ -101,6 +112,26 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
112 orElse: () => throw Exception('Server not found'),
113 );
114 final apiService = ref.watch(serverApiServiceProvider);
115 +
116 + if (server.type == ServerType.onDevice) {
117 + final models = OnDeviceModel.curatedModels
118 + .map(
119 + (m) => ModelInfo(
120 + id: m.id,
121 + name: m.name,
122 + description: m.description,
123 + parameterCount: int.tryParse(
124 + m.parameterLabel.replaceAll(RegExp(r'[^0-9]'), ''),
125 + ),
126 + serverType: ServerType.onDevice,
127 + serverId: server.id,
128 + ),
129 + )
130 + .toList();
131 + _modelCache.put(serverId, models);
132 + return models;
133 + }
134 +
135 final models = await apiService.fetchModels(server);
136 _modelCache.put(serverId, models);
137 return models;
lib/features/sidebar/components/active_server_indicator.dart
+269 -28
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:hugeicons/hugeicons.dart';
4 import 'package:go_router/go_router.dart';
5 +import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../../core/models/enums.dart';
8 import '../../../core/routes/app_routes.dart';
@@ -41,8 +42,11 @@ class ActiveServerIndicator extends ConsumerWidget {
42 Widget build(BuildContext context, WidgetRef ref) {
43 final theme = Theme.of(context);
44 final colorScheme = theme.colorScheme;
45 + final isDark = theme.brightness == Brightness.dark;
46 +
47 final activeServer = ref.watch(activeServerProvider);
48 final connectionStatus = ref.watch(connectionStatusProvider);
49 + final serversAsync = ref.watch(serversProvider);
50
51 if (activeServer == null) {
52 return Padding(
@@ -50,8 +54,8 @@ class ActiveServerIndicator extends ConsumerWidget {
54 child: Container(
55 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
56 decoration: BoxDecoration(
53 - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
54 - borderRadius: BorderRadius.circular(8),
57 + color: colorScheme.surfaceContainerHighest.withAlpha(50),
58 + borderRadius: BorderRadius.circular(12),
59 ),
60 child: Row(
61 children: [
@@ -85,20 +89,251 @@ class ActiveServerIndicator extends ConsumerWidget {
89
90 return Padding(
91 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
88 - child: GestureDetector(
92 + child: InkWell(
93 onTap: () {
90 - context.go(AppRoutes.servers);
91 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
92 - Navigator.pop(context);
93 - }
94 + final servers = serversAsync.value ?? [];
95 +
96 + showModalBottomSheet(
97 + context: context,
98 + isScrollControlled: true,
99 + backgroundColor: Colors.transparent,
100 + builder: (context) => Container(
101 + decoration: BoxDecoration(
102 + color: theme.colorScheme.surface,
103 + borderRadius: const BorderRadius.vertical(
104 + top: Radius.circular(24),
105 + ),
106 + ),
107 + child: Column(
108 + mainAxisSize: MainAxisSize.min,
109 + children: [
110 + const SizedBox(height: 12),
111 + // Drag Handle
112 + Container(
113 + width: 40,
114 + height: 4,
115 + decoration: BoxDecoration(
116 + color: colorScheme.onSurfaceVariant.withAlpha(50),
117 + borderRadius: BorderRadius.circular(2),
118 + ),
119 + ),
120 + const SizedBox(height: 16),
121 + // Header
122 + Padding(
123 + padding: const EdgeInsets.symmetric(horizontal: 20),
124 + child: Row(
125 + crossAxisAlignment: CrossAxisAlignment.start,
126 + children: [
127 + Expanded(
128 + child: Column(
129 + crossAxisAlignment: CrossAxisAlignment.start,
130 + children: [
131 + Text(
132 + 'Switch Server',
133 + style: theme.textTheme.titleLarge?.copyWith(
134 + fontWeight: FontWeight.bold,
135 + ),
136 + ),
137 + const SizedBox(height: 4),
138 + Text(
139 + 'Choose a server to connect to',
140 + style: theme.textTheme.bodyMedium?.copyWith(
141 + color: colorScheme.onSurfaceVariant,
142 + ),
143 + ),
144 + ],
145 + ),
146 + ),
147 + IconButton(
148 + onPressed: () => Navigator.pop(context),
149 + icon: const Icon(Icons.close, size: 20),
150 + visualDensity: VisualDensity.compact,
151 + color: colorScheme.onSurfaceVariant,
152 + ),
153 + ],
154 + ),
155 + ),
156 + const SizedBox(height: 8),
157 + // Content
158 + ConstrainedBox(
159 + constraints: BoxConstraints(
160 + maxHeight: MediaQuery.sizeOf(context).height * 0.6,
161 + ),
162 + child: SingleChildScrollView(
163 + padding: const EdgeInsets.symmetric(
164 + horizontal: 20,
165 + vertical: 12,
166 + ),
167 + child: Column(
168 + mainAxisSize: MainAxisSize.min,
169 + children: [
170 + ...servers.asMap().entries.map((entry) {
171 + final index = entry.key;
172 + final server = entry.value;
173 + final isCurrentlyActive =
174 + server.id == activeServer.id;
175 + final serverIconName = server.iconName;
176 + final currentServerIcon = serverIconName != null
177 + ? (getHugeIconByName(serverIconName)?.icon ??
178 + getDefaultServerIcon(
179 + server.type.name,
180 + )?.icon)
181 + : getDefaultServerIcon(server.type.name)?.icon;
182 +
183 + return TweenAnimationBuilder<double>(
184 + duration: Duration(
185 + milliseconds: 300 + (index * 50),
186 + ),
187 + curve: Curves.easeOutQuart,
188 + tween: Tween(begin: 0.0, end: 1.0),
189 + builder: (context, value, child) {
190 + return Opacity(
191 + opacity: value,
192 + child: Transform.translate(
193 + offset: Offset(0, 20 * (1 - value)),
194 + child: child,
195 + ),
196 + );
197 + },
198 + child: Padding(
199 + padding: const EdgeInsets.only(bottom: 8.0),
200 + child: InkWell(
201 + onTap: isCurrentlyActive
202 + ? null
203 + : () {
204 + ref
205 + .read(
206 + activeServerProvider.notifier,
207 + )
208 + .setActiveServer(server);
209 + Navigator.pop(context);
210 + },
211 + borderRadius: BorderRadius.circular(16),
212 + child: Container(
213 + padding: const EdgeInsets.all(16),
214 + decoration: BoxDecoration(
215 + color: isCurrentlyActive
216 + ? (isDark
217 + ? colorScheme.primary.withAlpha(
218 + 30,
219 + )
220 + : colorScheme.primary.withAlpha(
221 + 20,
222 + ))
223 + : (isDark
224 + ? Colors.white.withAlpha(10)
225 + : Colors.black.withAlpha(5)),
226 + borderRadius: BorderRadius.circular(16),
227 + border: Border.all(
228 + color: isCurrentlyActive
229 + ? colorScheme.primary.withAlpha(100)
230 + : Colors.transparent,
231 + ),
232 + ),
233 + child: Row(
234 + children: [
235 + HugeIcon(
236 + icon:
237 + currentServerIcon ??
238 + HugeIcons
239 + .strokeRoundedServerStack01,
240 + size: 20,
241 + color: isCurrentlyActive
242 + ? colorScheme.primary
243 + : colorScheme.onSurface,
244 + ),
245 + const SizedBox(width: 12),
246 + Expanded(
247 + child: Column(
248 + crossAxisAlignment:
249 + CrossAxisAlignment.start,
250 + children: [
251 + Text(
252 + server.name,
253 + style: TextStyle(
254 + fontWeight: isCurrentlyActive
255 + ? FontWeight.w600
256 + : FontWeight.w500,
257 + color: isCurrentlyActive
258 + ? colorScheme.primary
259 + : colorScheme.onSurface,
260 + ),
261 + ),
262 + Text(
263 + isCurrentlyActive
264 + ? 'Active'
265 + : (server.host.isEmpty
266 + ? 'Local'
267 + : server.host),
268 + style: TextStyle(
269 + fontSize: 12,
270 + color: isCurrentlyActive
271 + ? colorScheme.primary
272 + .withAlpha(150)
273 + : colorScheme
274 + .onSurfaceVariant,
275 + ),
276 + ),
277 + ],
278 + ),
279 + ),
280 + if (isCurrentlyActive)
281 + Icon(
282 + Icons.check_circle,
283 + size: 20,
284 + color: colorScheme.primary,
285 + )
286 + else
287 + const Icon(
288 + Icons.chevron_right,
289 + size: 20,
290 + color: Colors.grey,
291 + ),
292 + ],
293 + ),
294 + ),
295 + ),
296 + ),
297 + );
298 + }),
299 + const SizedBox(height: 12),
300 + const ShadSeparator.horizontal(),
301 + const SizedBox(height: 12),
302 + ShadButton.ghost(
303 + width: double.infinity,
304 + leading: const Icon(Icons.settings, size: 18),
305 + onPressed: () {
306 + Navigator.pop(context);
307 + context.go(AppRoutes.servers);
308 + if (Scaffold.maybeOf(context)?.isDrawerOpen ??
309 + false) {
310 + Navigator.pop(context);
311 + }
312 + },
313 + child: const Text('Manage Servers'),
314 + ),
315 + ],
316 + ),
317 + ),
318 + ),
319 + const SizedBox(height: 12),
320 + ],
321 + ),
322 + ),
323 + );
324 },
325 + borderRadius: BorderRadius.circular(12),
326 child: Container(
327 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
328 decoration: BoxDecoration(
98 - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
99 - borderRadius: BorderRadius.circular(8),
329 + color: isDark
330 + ? Colors.white.withAlpha(15)
331 + : Colors.black.withAlpha(10),
332 + borderRadius: BorderRadius.circular(12),
333 border: Border.all(
101 - color: colorScheme.outline.withValues(alpha: 0.2),
334 + color: isDark
335 + ? Colors.white.withAlpha(20)
336 + : Colors.black.withAlpha(10),
337 ),
338 ),
339 child: Row(
@@ -106,7 +341,7 @@ class ActiveServerIndicator extends ConsumerWidget {
341 HugeIcon(
342 icon: serverIcon ?? HugeIcons.strokeRoundedServerStack01,
343 size: 18,
109 - color: colorScheme.onSurface,
344 + color: isDark ? Colors.white70 : Colors.black87,
345 ),
346 const SizedBox(width: 10),
347 Expanded(
@@ -117,31 +352,37 @@ class ActiveServerIndicator extends ConsumerWidget {
352 Text(
353 activeServer.name,
354 style: theme.textTheme.bodySmall?.copyWith(
120 - fontWeight: FontWeight.w500,
121 - color: colorScheme.onSurface,
355 + fontWeight: FontWeight.w600,
356 + color: isDark ? Colors.white : Colors.black,
357 ),
358 overflow: TextOverflow.ellipsis,
359 maxLines: 1,
360 ),
126 - Text(
127 - _getStatusText(connectionStatus),
128 - style: theme.textTheme.labelSmall?.copyWith(
129 - color: statusColor,
130 - fontSize: 10,
131 - fontWeight: FontWeight.w500,
132 - ),
361 + Row(
362 + children: [
363 + Container(
364 + width: 6,
365 + height: 6,
366 + decoration: BoxDecoration(
367 + color: statusColor,
368 + shape: BoxShape.circle,
369 + ),
370 + ),
371 + const SizedBox(width: 6),
372 + Text(
373 + _getStatusText(connectionStatus),
374 + style: theme.textTheme.labelSmall?.copyWith(
375 + color: statusColor.withAlpha(200),
376 + fontSize: 10,
377 + fontWeight: FontWeight.w600,
378 + ),
379 + ),
380 + ],
381 ),
382 ],
383 ),
384 ),
137 - Container(
138 - width: 8,
139 - height: 8,
140 - decoration: BoxDecoration(
141 - color: statusColor,
142 - shape: BoxShape.circle,
143 - ),
144 - ),
385 + const Icon(Icons.unfold_more, size: 16, color: Colors.grey),
386 ],
387 ),
388 ),
lib/features/sidebar/components/conversation_drawer_header.dart
+16 -38
@@ -1,10 +1,5 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 -import 'package:go_router/go_router.dart';
4 -import 'package:shadcn_ui/shadcn_ui.dart';
5 -
6 -import '../../../core/routes/app_routes.dart';
7 -import '../../chat/providers/chat_providers.dart';
3
4 class ConversationDrawerHeader extends ConsumerWidget {
5 const ConversationDrawerHeader({super.key});
@@ -17,41 +12,24 @@ class ConversationDrawerHeader extends ConsumerWidget {
12 return Container(
13 padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8),
14 child: Row(
20 - mainAxisAlignment: MainAxisAlignment.spaceBetween,
15 children: [
22 - Row(
23 - children: [
24 - Container(
25 - padding: const EdgeInsets.all(8),
26 - decoration: BoxDecoration(
27 - borderRadius: BorderRadius.circular(8),
28 - ),
29 - child: Image.asset(
30 - 'assets/images/logo.webp',
31 - width: 24,
32 - height: 24,
33 - ),
34 - ),
35 - const SizedBox(width: 12),
36 - Text(
37 - 'LocalMind',
38 - style: TextStyle(
39 - fontSize: 18,
40 - fontWeight: FontWeight.bold,
41 - color: isDark ? Colors.white : Colors.black,
42 - ),
43 - ),
44 - ],
16 + Container(
17 + padding: const EdgeInsets.all(8),
18 + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
19 + child: Image.asset(
20 + 'assets/images/logo.webp',
21 + width: 24,
22 + height: 24,
23 + ),
24 ),
46 - ShadIconButton.outline(
47 - icon: const Icon(LucideIcons.plus, size: 20),
48 - onPressed: () {
49 - ref.read(chatProvider.notifier).startNewConversation();
50 - context.go(AppRoutes.home);
51 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
52 - Navigator.pop(context);
53 - }
54 - },
25 + const SizedBox(width: 12),
26 + Text(
27 + 'LocalMind',
28 + style: TextStyle(
29 + fontSize: 18,
30 + fontWeight: FontWeight.bold,
31 + color: isDark ? Colors.white : Colors.black,
32 + ),
33 ),
34 ],
35 ),
lib/features/sidebar/components/drawer_nav_item.dart
+34 -22
@@ -21,31 +21,43 @@ class DrawerNavItem extends StatelessWidget {
21 final isDark = theme.brightness == Brightness.dark;
22
23 return Padding(
24 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
25 - child: ListTile(
26 - leading: HugeIcon(
27 - icon: iconData,
28 - size: 22,
29 - color: isSelected
30 - ? (isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB))
31 - : (isDark ? const Color(0xFF888888) : const Color(0xFF666666)),
32 - ),
33 - title: Text(
34 - label,
35 - style: TextStyle(
36 - fontSize: 15,
37 - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
24 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
25 + child: InkWell(
26 + onTap: onTap,
27 + borderRadius: BorderRadius.circular(12),
28 + child: Container(
29 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
30 + decoration: BoxDecoration(
31 color: isSelected
39 - ? (isDark ? Colors.white : Colors.black)
40 - : (isDark ? const Color(0xFFA0A0A0) : const Color(0xFF666666)),
32 + ? (isDark
33 + ? theme.colorScheme.primary.withAlpha(30)
34 + : theme.colorScheme.primary.withAlpha(20))
35 + : Colors.transparent,
36 + borderRadius: BorderRadius.circular(12),
37 + ),
38 + child: Row(
39 + children: [
40 + HugeIcon(
41 + icon: iconData,
42 + size: 20,
43 + color: isSelected
44 + ? theme.colorScheme.primary
45 + : (isDark ? Colors.white70 : Colors.black54),
46 + ),
47 + const SizedBox(width: 16),
48 + Text(
49 + label,
50 + style: TextStyle(
51 + fontSize: 14,
52 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
53 + color: isSelected
54 + ? (isDark ? Colors.white : theme.colorScheme.primary)
55 + : (isDark ? Colors.white70 : Colors.black87),
56 + ),
57 + ),
58 + ],
59 ),
60 ),
43 - selected: isSelected,
44 - selectedTileColor: isDark
45 - ? const Color(0xFF3B82F6).withAlpha(25)
46 - : const Color(0xFF2563EB).withAlpha(25),
47 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
48 - onTap: onTap,
61 ),
62 );
63 }
lib/features/sidebar/components/sidebar_search_button.dart new
+68
@@ -0,0 +1,68 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:hugeicons/hugeicons.dart';
3 +import 'package:go_router/go_router.dart';
4 +import '../../../core/routes/app_routes.dart';
5 +
6 +class SidebarSearchButton extends StatelessWidget {
7 + const SidebarSearchButton({super.key});
8 +
9 + @override
10 + Widget build(BuildContext context) {
11 + final theme = Theme.of(context);
12 + final isDark = theme.brightness == Brightness.dark;
13 +
14 + return Padding(
15 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
16 + child: InkWell(
17 + onTap: () {
18 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
19 + Navigator.pop(context);
20 + }
21 + context.go(AppRoutes.chatHistory);
22 + },
23 + borderRadius: BorderRadius.circular(12),
24 + child: Container(
25 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
26 + decoration: BoxDecoration(
27 + color: isDark
28 + ? theme.colorScheme.surfaceContainerHighest.withAlpha(50)
29 + : theme.colorScheme.surfaceContainerHighest.withAlpha(100),
30 + borderRadius: BorderRadius.circular(12),
31 + border: Border.all(
32 + color: isDark
33 + ? theme.colorScheme.outlineVariant.withAlpha(100)
34 + : theme.colorScheme.outlineVariant,
35 + ),
36 + ),
37 + child: Row(
38 + children: [
39 + HugeIcon(
40 + icon: HugeIcons.strokeRoundedSearch01,
41 + size: 20,
42 + color: isDark ? Colors.white70 : Colors.black54,
43 + ),
44 + const SizedBox(width: 12),
45 + Text(
46 + 'Search conversations...',
47 + style: TextStyle(
48 + fontSize: 14,
49 + color: isDark ? Colors.white38 : Colors.black38,
50 + ),
51 + ),
52 + const Spacer(),
53 + Text(
54 + '⌘K',
55 + style: TextStyle(
56 + fontSize: 11,
57 + fontWeight: FontWeight.w500,
58 + color: isDark ? Colors.white24 : Colors.black26,
59 + letterSpacing: 0.5,
60 + ),
61 + ),
62 + ],
63 + ),
64 + ),
65 + ),
66 + );
67 + }
68 +}
lib/features/sidebar/components/theme_toggle_button.dart new
+52
@@ -0,0 +1,52 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:shadcn_ui/shadcn_ui.dart';
4 +import '../../../core/providers/app_providers.dart';
5 +import '../../../core/theme/app_theme.dart';
6 +
7 +class ThemeToggleButton extends ConsumerWidget {
8 + const ThemeToggleButton({super.key});
9 +
10 + @override
11 + Widget build(BuildContext context, WidgetRef ref) {
12 + final themeMode = ref.watch(themeModeProvider);
13 +
14 + return ShadIconButton.ghost(
15 + onPressed: () {
16 + // Cycle through: System -> Light -> Dark -> Claude -> System
17 + final nextMode = AppThemeType.values[(themeMode.index + 1) % AppThemeType.values.length];
18 + ref.read(themeModeProvider.notifier).setThemeMode(nextMode);
19 + },
20 + icon: AnimatedSwitcher(
21 + duration: const Duration(milliseconds: 300),
22 + transitionBuilder: (child, animation) {
23 + return RotationTransition(
24 + turns: animation,
25 + child: FadeTransition(
26 + opacity: animation,
27 + child: child,
28 + ),
29 + );
30 + },
31 + child: Icon(
32 + _getIconForMode(themeMode),
33 + key: ValueKey(themeMode),
34 + size: 20,
35 + ),
36 + ),
37 + );
38 + }
39 +
40 + IconData _getIconForMode(AppThemeType mode) {
41 + switch (mode) {
42 + case AppThemeType.system:
43 + return LucideIcons.monitor;
44 + case AppThemeType.light:
45 + return LucideIcons.sun;
46 + case AppThemeType.dark:
47 + return LucideIcons.moon;
48 + case AppThemeType.claude:
49 + return LucideIcons.palmtree;
50 + }
51 + }
52 +}
lib/features/sidebar/sidebar_widget.dart
+111 -74
@@ -2,16 +2,17 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:hugeicons/hugeicons.dart';
5 +import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../core/routes/app_routes.dart';
7 -import '../conversations/providers/conversation_providers.dart';
8 -import '../conversations/views/components/conversation_empty_state.dart';
9 -import '../conversations/views/components/conversation_list.dart';
10 -import '../conversations/views/components/conversation_search_bar.dart';
8 +import '../../core/providers/app_providers.dart';
9 +import '../../core/theme/app_theme.dart';
10 +import '../chat/providers/chat_providers.dart';
11 import 'components/active_server_indicator.dart';
12 import 'components/conversation_drawer_header.dart';
13 import 'components/drawer_nav_item.dart';
14 import 'components/github_repo_card.dart';
15 +import 'components/sidebar_search_button.dart';
16
17 class SidebarWidget extends ConsumerWidget {
18 const SidebarWidget({super.key});
@@ -19,14 +20,12 @@ class SidebarWidget extends ConsumerWidget {
20 @override
21 Widget build(BuildContext context, WidgetRef ref) {
22 final theme = Theme.of(context);
22 - final groupedConversations = ref.watch(groupedConversationsProvider);
23 - final activeConversation = ref.watch(activeConversationProvider);
24 - final searchQuery = ref.watch(conversationSearchProvider);
25 -
23 final location = GoRouterState.of(context).uri.toString();
24 +
25 final isServers = location.startsWith(AppRoutes.servers);
26 final isPersonas = location.startsWith(AppRoutes.personas);
29 - final isHistory = location.startsWith(AppRoutes.chatHistory);
27 + final themeMode = ref.watch(themeModeProvider);
28 + final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
29
30 return Container(
31 width: 300,
@@ -38,81 +37,119 @@ class SidebarWidget extends ConsumerWidget {
37 child: Column(
38 children: [
39 const ConversationDrawerHeader(),
41 - const ConversationSearchBar(),
42 - const Divider(height: 1),
40 +
41 + // New Chat Button
42 + Padding(
43 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
44 + child: ShadButton(
45 + width: double.infinity,
46 + leading: const Icon(LucideIcons.plus, size: 20),
47 + onPressed: () {
48 + ref.read(chatProvider.notifier).startNewConversation();
49 + context.go(AppRoutes.home);
50 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
51 + Navigator.pop(context);
52 + }
53 + },
54 + child: const Text('New Chat'),
55 + ),
56 + ),
57 +
58 + const SidebarSearchButton(),
59 + const SizedBox(height: 8),
60 + const Divider(height: 1, indent: 16, endIndent: 16),
61 + const SizedBox(height: 12),
62 +
63 + // Primary Navigation
64 Expanded(
44 - child: groupedConversations.when(
45 - data: (grouped) => grouped.isEmpty
46 - ? ConversationEmptyState(
47 - isSearching: searchQuery.isNotEmpty,
48 - )
49 - : ConversationList(
50 - groupedConversations: grouped,
51 - activeConversation: activeConversation,
52 - ),
53 - loading: () => const Center(
54 - child: CircularProgressIndicator(strokeWidth: 2),
55 - ),
56 - error: (err, stack) => Center(
57 - child: Text(
58 - 'Error: $err',
59 - style: TextStyle(color: theme.colorScheme.error),
60 - ),
65 + child: SingleChildScrollView(
66 + child: Column(
67 + children: [
68 + DrawerNavItem(
69 + iconData: HugeIcons.strokeRoundedServerStack01,
70 + label: 'Servers',
71 + isSelected: isServers,
72 + onTap: () {
73 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
74 + Navigator.pop(context);
75 + }
76 + context.go(AppRoutes.servers);
77 + },
78 + ),
79 + DrawerNavItem(
80 + iconData: HugeIcons.strokeRoundedSmartPhone01,
81 + label: 'Local Models',
82 + isSelected: isLocalModels,
83 + onTap: () {
84 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
85 + Navigator.pop(context);
86 + }
87 + context.go(AppRoutes.onDeviceModels);
88 + },
89 + ),
90 + DrawerNavItem(
91 + iconData: HugeIcons.strokeRoundedCompass01,
92 + label: 'Personas',
93 + isSelected: isPersonas,
94 + onTap: () {
95 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
96 + Navigator.pop(context);
97 + }
98 + context.go(AppRoutes.personas);
99 + },
100 + ),
101 + const SizedBox(height: 8),
102 + const Divider(height: 1, indent: 16, endIndent: 16),
103 + const SizedBox(height: 8),
104 + DrawerNavItem(
105 + iconData: _getThemeIcon(themeMode),
106 + label: 'Appearance: ${_getThemeLabel(themeMode)}',
107 + isSelected: false,
108 + onTap: () {
109 + final nextMode = AppThemeType.values[
110 + (themeMode.index + 1) % AppThemeType.values.length];
111 + ref.read(themeModeProvider.notifier).setThemeMode(nextMode);
112 + },
113 + ),
114 + ],
115 ),
116 ),
117 ),
118 +
119 + // Bottom Section
120 const GitHubRepoCard(),
121 const Divider(height: 1),
122 const ActiveServerIndicator(),
67 - const SizedBox(height: 8),
68 - DrawerNavItem(
69 - iconData: HugeIcons.strokeRoundedClock01,
70 - label: 'History',
71 - isSelected: isHistory,
72 - onTap: () {
73 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
74 - Navigator.pop(context);
75 - }
76 - context.go(AppRoutes.chatHistory);
77 - },
78 - ),
79 - DrawerNavItem(
80 - iconData: HugeIcons.strokeRoundedServerStack01,
81 - label: 'Servers',
82 - isSelected: isServers,
83 - onTap: () {
84 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
85 - Navigator.pop(context);
86 - }
87 - context.go(AppRoutes.servers);
88 - },
89 - ),
90 - DrawerNavItem(
91 - iconData: HugeIcons.strokeRoundedSmartPhone01,
92 - label: 'Local Models',
93 - isSelected: location.startsWith(AppRoutes.onDeviceModels),
94 - onTap: () {
95 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
96 - Navigator.pop(context);
97 - }
98 - context.go(AppRoutes.onDeviceModels);
99 - },
100 - ),
101 - DrawerNavItem(
102 - iconData: HugeIcons.strokeRoundedCompass01,
103 - label: 'Personas',
104 - isSelected: isPersonas,
105 - onTap: () {
106 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
107 - Navigator.pop(context);
108 - }
109 - context.go(AppRoutes.personas);
110 - },
111 - ),
112 - const SizedBox(height: 8),
123 + const SizedBox(height: 16),
124 ],
125 ),
126 ),
127 );
128 }
129 +
130 + List<List<dynamic>> _getThemeIcon(AppThemeType mode) {
131 + switch (mode) {
132 + case AppThemeType.system:
133 + return HugeIcons.strokeRoundedSettings01;
134 + case AppThemeType.light:
135 + return HugeIcons.strokeRoundedSun01;
136 + case AppThemeType.dark:
137 + return HugeIcons.strokeRoundedMoon02;
138 + case AppThemeType.claude:
139 + return HugeIcons.strokeRoundedPaintBrush02;
140 + }
141 + }
142 +
143 + String _getThemeLabel(AppThemeType mode) {
144 + switch (mode) {
145 + case AppThemeType.system:
146 + return 'System';
147 + case AppThemeType.light:
148 + return 'Light';
149 + case AppThemeType.dark:
150 + return 'Dark';
151 + case AppThemeType.claude:
152 + return 'Claude';
153 + }
154 + }
155 }