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
}