main
dart 330 lines 12.1 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/core/theme/colors.dart';
5 import 'package:localmind/features/models/data/models/model_info.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'metadata_chip.dart';
8
9 import 'inline_thinking_selector.dart';
10
11 class ModelTile extends StatelessWidget {
12 const ModelTile({
13 super.key,
14 required this.model,
15 required this.isSelected,
16 required this.isLoaded,
17 required this.isDark,
18 required this.onTap,
19 this.onLongPress,
20 this.onUnload,
21 this.isFavorite = false,
22 this.isDefault = false,
23 this.note,
24 this.isLoading = false,
25 });
26
27 final ModelInfo model;
28 final bool isSelected;
29 final bool isLoaded;
30 final bool isDark;
31 final VoidCallback onTap;
32 final VoidCallback? onLongPress;
33 final Future<void> Function()? onUnload;
34 final bool isFavorite;
35 final bool isDefault;
36 final String? note;
37 final bool isLoading;
38
39 @override
40 Widget build(BuildContext context) {
41 final l10n = AppLocalizations.of(context)!;
42 final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
43
44 return InkWell(
45 onTap: isLoading ? null : onTap,
46 onLongPress: isLoading ? null : onLongPress,
47 borderRadius: BorderRadius.circular(8),
48 child: Container(
49 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
50 margin: const EdgeInsets.only(bottom: 4),
51 decoration: BoxDecoration(
52 color: isSelected
53 ? accent.withValues(alpha: 0.1)
54 : Colors.transparent,
55 borderRadius: BorderRadius.circular(8),
56 border: isSelected
57 ? Border.all(color: accent.withValues(alpha: 0.3))
58 : null,
59 ),
60 child: Column(
61 mainAxisSize: MainAxisSize.min,
62 crossAxisAlignment: CrossAxisAlignment.stretch,
63 children: [
64 Row(
65 children: [
66 Expanded(
67 child: Column(
68 crossAxisAlignment: CrossAxisAlignment.start,
69 children: [
70 Row(
71 children: [
72 if (isFavorite) ...[
73 HugeIcon(
74 icon: HugeIcons.strokeRoundedStar,
75 size: 16,
76 color: Colors.amber[600],
77 ),
78 const SizedBox(width: 6),
79 ],
80 if (isDefault) ...[
81 Container(
82 padding: const EdgeInsets.symmetric(
83 horizontal: 6,
84 vertical: 1,
85 ),
86 decoration: BoxDecoration(
87 color: isDark
88 ? AppColors.darkAccent.withValues(
89 alpha: 0.18,
90 )
91 : AppColors.lightAccent.withValues(
92 alpha: 0.15,
93 ),
94 borderRadius: BorderRadius.circular(4),
95 ),
96 child: Text(
97 l10n.model_default_badge,
98 style: TextStyle(
99 fontSize: 10,
100 fontWeight: FontWeight.w600,
101 color: isDark
102 ? AppColors.darkAccent
103 : AppColors.lightAccent,
104 ),
105 ),
106 ),
107 const SizedBox(width: 6),
108 ],
109 Expanded(
110 child: Text(
111 model.displayName,
112 style: TextStyle(
113 fontSize: 15,
114 fontWeight: isSelected
115 ? FontWeight.w600
116 : FontWeight.w500,
117 color: isDark ? Colors.white : Colors.black,
118 ),
119 maxLines: 1,
120 overflow: TextOverflow.ellipsis,
121 ),
122 ),
123 ],
124 ),
125 if (note != null && note!.isNotEmpty) ...[
126 const SizedBox(height: 4),
127 Text(
128 note!,
129 style: TextStyle(
130 fontSize: 12,
131 color: isDark
132 ? AppColors.darkMutedText
133 : AppColors.lightMutedText,
134 ),
135 maxLines: 2,
136 overflow: TextOverflow.ellipsis,
137 ),
138 ],
139 const SizedBox(height: 4),
140 Wrap(
141 spacing: 6,
142 runSpacing: 4,
143 children: [
144 if (model.parameterCountDisplay != null &&
145 model.parameterCountDisplay!.isNotEmpty)
146 MetadataChip(
147 label: model.parameterCountDisplay!,
148 isDark: isDark,
149 ),
150 if (model.quantization != null &&
151 model.quantization!.isNotEmpty)
152 MetadataChip(
153 label: model.quantization!,
154 isDark: isDark,
155 ),
156 if (model.formattedSize != null &&
157 model.formattedSize!.isNotEmpty)
158 MetadataChip(
159 label: model.formattedSize!,
160 isDark: isDark,
161 ),
162 if (model.contextLength != null)
163 MetadataChip(
164 label: l10n.context_chip(
165 model.contextLength.toString(),
166 ),
167 isDark: isDark,
168 ),
169 if ((model.serverType == ServerType.openRouter ||
170 model.serverType == ServerType.requesty) &&
171 model.pricingLabel != null)
172 Tooltip(
173 message: model.isPricingFree
174 ? l10n.openrouter_pricing_free
175 : l10n.openrouter_pricing_tooltip(
176 model.formattedInputPrice ?? '',
177 model.formattedOutputPrice ?? '',
178 ),
179 child: MetadataChip(
180 label: model.isPricingFree
181 ? l10n.openrouter_pricing_free
182 : model.pricingLabel!,
183 isDark: isDark,
184 ),
185 ),
186 ],
187 ),
188 if (model.supportsReasoning)
189 InlineThinkingSelector(
190 isDark: isDark,
191 isSelected: isSelected,
192 onSelectModel: onTap,
193 supportedEfforts: model.supportedReasoningEfforts,
194 reasoningMandatory: model.reasoningMandatory,
195 ),
196 ],
197 ),
198 ),
199 if ((model.serverType == ServerType.lmStudio ||
200 model.serverType == ServerType.ollama) &&
201 isLoaded) ...[
202 HugeIcon(
203 icon: HugeIcons.strokeRoundedCpu,
204 size: 16,
205 color: accent,
206 ),
207 const SizedBox(width: 6),
208 IconButton(
209 icon: HugeIcon(
210 icon: HugeIcons.strokeRoundedPower,
211 size: 18,
212 color: Colors.red[400],
213 ),
214 onPressed: onUnload,
215 padding: EdgeInsets.zero,
216 constraints: const BoxConstraints(),
217 tooltip: l10n.unload_from_server,
218 ),
219 const SizedBox(width: 8),
220 ],
221 if (model.supportsVision ||
222 model.supportsReasoning ||
223 model.supportsToolUse) ...[
224 const SizedBox(width: 8),
225 _ModelCapabilityIcons(model: model, isDark: isDark),
226 ],
227 if (isSelected) ...[
228 const SizedBox(width: 4),
229 Container(
230 width: 22,
231 height: 22,
232 decoration: const BoxDecoration(
233 color: Colors.green,
234 shape: BoxShape.circle,
235 ),
236 child: const Center(
237 child: HugeIcon(
238 icon: HugeIcons.strokeRoundedTick01,
239 color: Colors.white,
240 size: 14,
241 ),
242 ),
243 ),
244 ] else if (!isLoading)
245 HugeIcon(
246 icon: HugeIcons.strokeRoundedArrowRight01,
247 size: 20,
248 color: isDark
249 ? AppColors.darkMutedText
250 : AppColors.lightMutedText,
251 ),
252 ],
253 ),
254 if (isLoading) ...[
255 const SizedBox(height: 8),
256 ClipRRect(
257 borderRadius: BorderRadius.circular(2),
258 child: LinearProgressIndicator(
259 minHeight: 3,
260 backgroundColor: accent.withValues(alpha: 0.15),
261 valueColor: AlwaysStoppedAnimation<Color>(accent),
262 ),
263 ),
264 ],
265 ],
266 ),
267 ),
268 );
269 }
270 }
271
272 class _ModelCapabilityIcons extends StatelessWidget {
273 const _ModelCapabilityIcons({required this.model, required this.isDark});
274
275 final ModelInfo model;
276 final bool isDark;
277
278 @override
279 Widget build(BuildContext context) {
280 final l10n = AppLocalizations.of(context)!;
281 final color = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
282
283 return Row(
284 mainAxisSize: MainAxisSize.min,
285 children: [
286 if (model.supportsVision)
287 _CapabilityIcon(
288 icon: HugeIcons.strokeRoundedEye,
289 tooltip: l10n.lm_studio_vision,
290 color: color,
291 ),
292 if (model.supportsReasoning)
293 _CapabilityIcon(
294 icon: HugeIcons.strokeRoundedBrain,
295 tooltip: l10n.lm_studio_reasoning,
296 color: color,
297 ),
298 if (model.supportsToolUse)
299 _CapabilityIcon(
300 icon: HugeIcons.strokeRoundedTools,
301 tooltip: l10n.lm_studio_tool_use,
302 color: color,
303 ),
304 ],
305 );
306 }
307 }
308
309 class _CapabilityIcon extends StatelessWidget {
310 const _CapabilityIcon({
311 required this.icon,
312 required this.tooltip,
313 required this.color,
314 });
315
316 final List<List<dynamic>> icon;
317 final String tooltip;
318 final Color color;
319
320 @override
321 Widget build(BuildContext context) {
322 return Padding(
323 padding: const EdgeInsets.only(left: 4),
324 child: Tooltip(
325 message: tooltip,
326 child: HugeIcon(icon: icon, size: 14, color: color),
327 ),
328 );
329 }
330 }