neoagent 2.4.1-beta.12 → 2.4.1-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_model_picker.dart +631 -0
- package/flutter_app/lib/main_settings.dart +30 -86
- package/package.json +1 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +58682 -58379
- package/server/services/ai/models.js +53 -0
- package/server/services/ai/providers/nvidia.js +165 -0
- package/server/services/ai/settings.js +10 -0
- package/server/services/integrations/google/provider.js +13 -0
- package/server/services/integrations/manager.js +5 -0
- package/server/services/integrations/trello/provider.js +8 -2
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// Model Picker — option type, helpers, button, and dialog
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
class _ModelPickerOption {
|
|
8
|
+
const _ModelPickerOption({
|
|
9
|
+
required this.value,
|
|
10
|
+
required this.label,
|
|
11
|
+
this.group = '',
|
|
12
|
+
this.subtitle,
|
|
13
|
+
this.color,
|
|
14
|
+
this.icon,
|
|
15
|
+
this.isAuto = false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
final String value;
|
|
19
|
+
final String label;
|
|
20
|
+
final String group;
|
|
21
|
+
final String? subtitle;
|
|
22
|
+
final Color? color;
|
|
23
|
+
final IconData? icon;
|
|
24
|
+
final bool isAuto;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Provider helpers ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
Color _providerPickerColor(String provider) {
|
|
30
|
+
final String p = provider.toLowerCase();
|
|
31
|
+
if (p.contains('google') || p.contains('gemini')) {
|
|
32
|
+
return const Color(0xFF4285F4);
|
|
33
|
+
}
|
|
34
|
+
if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
|
|
35
|
+
return const Color(0xFF10A37F);
|
|
36
|
+
}
|
|
37
|
+
if (p.contains('anthropic') || p.contains('claude')) {
|
|
38
|
+
return const Color(0xFFD97757);
|
|
39
|
+
}
|
|
40
|
+
if (p.contains('meta') || p.contains('llama')) return const Color(0xFF0668E1);
|
|
41
|
+
if (p.contains('mistral')) return const Color(0xFFF97316);
|
|
42
|
+
if (p.contains('grok') || p.contains('xai')) return const Color(0xFF9B5DE5);
|
|
43
|
+
if (p.contains('copilot') || p.contains('github')) {
|
|
44
|
+
return const Color(0xFF238636);
|
|
45
|
+
}
|
|
46
|
+
if (p.contains('deepgram')) return const Color(0xFF13D4A0);
|
|
47
|
+
if (p.contains('ollama')) return const Color(0xFF6C8EBF);
|
|
48
|
+
return const Color(0xFF7C8CFF);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
IconData _providerPickerIcon(String provider) {
|
|
52
|
+
final String p = provider.toLowerCase();
|
|
53
|
+
if (p.contains('google') || p.contains('gemini')) {
|
|
54
|
+
return Icons.auto_awesome_rounded;
|
|
55
|
+
}
|
|
56
|
+
if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
|
|
57
|
+
return Icons.bolt_rounded;
|
|
58
|
+
}
|
|
59
|
+
if (p.contains('anthropic') || p.contains('claude')) {
|
|
60
|
+
return Icons.menu_book_rounded;
|
|
61
|
+
}
|
|
62
|
+
if (p.contains('meta') || p.contains('llama')) {
|
|
63
|
+
return Icons.visibility_rounded;
|
|
64
|
+
}
|
|
65
|
+
if (p.contains('grok') || p.contains('xai')) return Icons.psychology_rounded;
|
|
66
|
+
if (p.contains('copilot') || p.contains('github')) return Icons.code_rounded;
|
|
67
|
+
if (p.contains('deepgram')) return Icons.hearing_rounded;
|
|
68
|
+
if (p.contains('ollama')) return Icons.device_hub_rounded;
|
|
69
|
+
return Icons.memory_rounded;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
String _providerPickerLabel(String id) {
|
|
73
|
+
const Map<String, String> labels = <String, String>{
|
|
74
|
+
'anthropic': 'Anthropic',
|
|
75
|
+
'openai': 'OpenAI',
|
|
76
|
+
'google': 'Google',
|
|
77
|
+
'gemini': 'Google',
|
|
78
|
+
'meta': 'Meta',
|
|
79
|
+
'mistral': 'Mistral',
|
|
80
|
+
'grok': 'xAI',
|
|
81
|
+
'xai': 'xAI',
|
|
82
|
+
'ollama': 'Ollama',
|
|
83
|
+
'github-copilot': 'GitHub Copilot',
|
|
84
|
+
'openai-codex': 'OpenAI',
|
|
85
|
+
'deepgram': 'Deepgram',
|
|
86
|
+
};
|
|
87
|
+
return labels[id.toLowerCase()] ?? id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Option builders ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
List<_ModelPickerOption> _modelPickerOptions(
|
|
93
|
+
List<ModelMeta> models, {
|
|
94
|
+
bool allowAuto = false,
|
|
95
|
+
}) {
|
|
96
|
+
return <_ModelPickerOption>[
|
|
97
|
+
if (allowAuto)
|
|
98
|
+
const _ModelPickerOption(
|
|
99
|
+
value: 'auto',
|
|
100
|
+
label: 'Smart Selector',
|
|
101
|
+
subtitle: 'Auto-routes to the best available model',
|
|
102
|
+
icon: Icons.auto_awesome_outlined,
|
|
103
|
+
isAuto: true,
|
|
104
|
+
),
|
|
105
|
+
...models.map((ModelMeta m) {
|
|
106
|
+
final List<String> parts = <String>[];
|
|
107
|
+
if (m.provider.isNotEmpty) parts.add(_providerPickerLabel(m.provider));
|
|
108
|
+
if (m.purpose.isNotEmpty) parts.add(m.purpose);
|
|
109
|
+
return _ModelPickerOption(
|
|
110
|
+
value: m.id,
|
|
111
|
+
label: m.label,
|
|
112
|
+
group: m.provider,
|
|
113
|
+
subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
|
|
114
|
+
color: _providerPickerColor(m.provider),
|
|
115
|
+
icon: _providerPickerIcon(m.provider),
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
List<_ModelPickerOption> _simplePickerOptions(List<String> values) {
|
|
122
|
+
return values
|
|
123
|
+
.map((String v) => _ModelPickerOption(value: v, label: v))
|
|
124
|
+
.toList();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Model Picker Button — drop-in trigger that opens the dialog
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
class _ModelPickerButton extends StatelessWidget {
|
|
132
|
+
const _ModelPickerButton({
|
|
133
|
+
required this.value,
|
|
134
|
+
required this.options,
|
|
135
|
+
required this.onChanged,
|
|
136
|
+
required this.dialogTitle,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
final String value;
|
|
140
|
+
final List<_ModelPickerOption> options;
|
|
141
|
+
final ValueChanged<String?> onChanged;
|
|
142
|
+
final String dialogTitle;
|
|
143
|
+
|
|
144
|
+
_ModelPickerOption get _current => options.firstWhere(
|
|
145
|
+
(o) => o.value == value,
|
|
146
|
+
orElse: () => _ModelPickerOption(value: value, label: value),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
@override
|
|
150
|
+
Widget build(BuildContext context) {
|
|
151
|
+
final _ModelPickerOption current = _current;
|
|
152
|
+
final Color iconColor = current.isAuto
|
|
153
|
+
? _accentHover
|
|
154
|
+
: (current.color ?? _textSecondary);
|
|
155
|
+
final IconData iconData = current.isAuto
|
|
156
|
+
? Icons.auto_awesome_outlined
|
|
157
|
+
: (current.icon ?? Icons.memory_rounded);
|
|
158
|
+
final bool showShell = current.icon != null || current.isAuto;
|
|
159
|
+
|
|
160
|
+
return Material(
|
|
161
|
+
color: Colors.transparent,
|
|
162
|
+
child: InkWell(
|
|
163
|
+
onTap: () => _openPicker(context),
|
|
164
|
+
borderRadius: BorderRadius.circular(12),
|
|
165
|
+
child: Container(
|
|
166
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
167
|
+
decoration: BoxDecoration(
|
|
168
|
+
color: _bgCard,
|
|
169
|
+
borderRadius: BorderRadius.circular(12),
|
|
170
|
+
border: Border.all(color: _border),
|
|
171
|
+
),
|
|
172
|
+
child: Row(
|
|
173
|
+
children: <Widget>[
|
|
174
|
+
if (showShell) ...<Widget>[
|
|
175
|
+
Container(
|
|
176
|
+
width: 32,
|
|
177
|
+
height: 32,
|
|
178
|
+
decoration: BoxDecoration(
|
|
179
|
+
color: iconColor.withValues(alpha: 0.14),
|
|
180
|
+
borderRadius: BorderRadius.circular(8),
|
|
181
|
+
),
|
|
182
|
+
child: Icon(iconData, size: 16, color: iconColor),
|
|
183
|
+
),
|
|
184
|
+
const SizedBox(width: 10),
|
|
185
|
+
],
|
|
186
|
+
Expanded(
|
|
187
|
+
child: Text(
|
|
188
|
+
current.label,
|
|
189
|
+
style: TextStyle(
|
|
190
|
+
fontSize: 14,
|
|
191
|
+
fontWeight: FontWeight.w600,
|
|
192
|
+
color: _textPrimary,
|
|
193
|
+
),
|
|
194
|
+
maxLines: 1,
|
|
195
|
+
overflow: TextOverflow.ellipsis,
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
const SizedBox(width: 6),
|
|
199
|
+
Icon(Icons.unfold_more_rounded, size: 16, color: _textMuted),
|
|
200
|
+
],
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
Future<void> _openPicker(BuildContext context) async {
|
|
208
|
+
await showGeneralDialog<void>(
|
|
209
|
+
context: context,
|
|
210
|
+
barrierDismissible: true,
|
|
211
|
+
barrierLabel: 'Dismiss',
|
|
212
|
+
barrierColor: Colors.black.withValues(alpha: 0.55),
|
|
213
|
+
transitionDuration: const Duration(milliseconds: 230),
|
|
214
|
+
transitionBuilder: (
|
|
215
|
+
BuildContext ctx,
|
|
216
|
+
Animation<double> animation,
|
|
217
|
+
Animation<double> secondary,
|
|
218
|
+
Widget child,
|
|
219
|
+
) {
|
|
220
|
+
return FadeTransition(
|
|
221
|
+
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
|
222
|
+
child: SlideTransition(
|
|
223
|
+
position: Tween<Offset>(
|
|
224
|
+
begin: const Offset(0, 0.04),
|
|
225
|
+
end: Offset.zero,
|
|
226
|
+
).animate(
|
|
227
|
+
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
|
228
|
+
),
|
|
229
|
+
child: child,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
pageBuilder: (
|
|
234
|
+
BuildContext dialogContext,
|
|
235
|
+
Animation<double> animation,
|
|
236
|
+
Animation<double> secondary,
|
|
237
|
+
) {
|
|
238
|
+
return _ModelPickerDialog(
|
|
239
|
+
title: dialogTitle,
|
|
240
|
+
options: options,
|
|
241
|
+
currentValue: value,
|
|
242
|
+
onChanged: (String v) {
|
|
243
|
+
onChanged(v);
|
|
244
|
+
Navigator.of(dialogContext).pop();
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
// Model Picker Dialog
|
|
254
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
class _ModelPickerDialog extends StatefulWidget {
|
|
257
|
+
const _ModelPickerDialog({
|
|
258
|
+
required this.title,
|
|
259
|
+
required this.options,
|
|
260
|
+
required this.currentValue,
|
|
261
|
+
required this.onChanged,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
final String title;
|
|
265
|
+
final List<_ModelPickerOption> options;
|
|
266
|
+
final String currentValue;
|
|
267
|
+
final ValueChanged<String> onChanged;
|
|
268
|
+
|
|
269
|
+
@override
|
|
270
|
+
State<_ModelPickerDialog> createState() => _ModelPickerDialogState();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class _ModelPickerDialogState extends State<_ModelPickerDialog> {
|
|
274
|
+
final TextEditingController _searchCtrl = TextEditingController();
|
|
275
|
+
String _query = '';
|
|
276
|
+
|
|
277
|
+
@override
|
|
278
|
+
void dispose() {
|
|
279
|
+
_searchCtrl.dispose();
|
|
280
|
+
super.dispose();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
List<_ModelPickerOption> get _filtered {
|
|
284
|
+
if (_query.isEmpty) return widget.options;
|
|
285
|
+
final String q = _query.toLowerCase();
|
|
286
|
+
return widget.options.where((_ModelPickerOption o) {
|
|
287
|
+
return o.label.toLowerCase().contains(q) ||
|
|
288
|
+
(o.subtitle?.toLowerCase().contains(q) ?? false) ||
|
|
289
|
+
o.group.toLowerCase().contains(q) ||
|
|
290
|
+
o.value.toLowerCase().contains(q);
|
|
291
|
+
}).toList();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@override
|
|
295
|
+
Widget build(BuildContext context) {
|
|
296
|
+
final List<_ModelPickerOption> filtered = _filtered;
|
|
297
|
+
final _ModelPickerOption? autoOption =
|
|
298
|
+
filtered.where((o) => o.isAuto).firstOrNull;
|
|
299
|
+
final List<_ModelPickerOption> regularOptions =
|
|
300
|
+
filtered.where((o) => !o.isAuto).toList();
|
|
301
|
+
|
|
302
|
+
final List<String> groups = <String>[];
|
|
303
|
+
final Map<String, List<_ModelPickerOption>> grouped =
|
|
304
|
+
<String, List<_ModelPickerOption>>{};
|
|
305
|
+
for (final _ModelPickerOption opt in regularOptions) {
|
|
306
|
+
if (!grouped.containsKey(opt.group)) {
|
|
307
|
+
groups.add(opt.group);
|
|
308
|
+
grouped[opt.group] = <_ModelPickerOption>[];
|
|
309
|
+
}
|
|
310
|
+
grouped[opt.group]!.add(opt);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
final bool hasGroups = groups.any((String g) => g.isNotEmpty);
|
|
314
|
+
|
|
315
|
+
return Center(
|
|
316
|
+
child: ConstrainedBox(
|
|
317
|
+
constraints: BoxConstraints(
|
|
318
|
+
maxWidth: 520,
|
|
319
|
+
minWidth: 300,
|
|
320
|
+
maxHeight: MediaQuery.sizeOf(context).height * 0.76,
|
|
321
|
+
),
|
|
322
|
+
child: Padding(
|
|
323
|
+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
|
324
|
+
child: Material(
|
|
325
|
+
color: _bgCard,
|
|
326
|
+
borderRadius: BorderRadius.circular(20),
|
|
327
|
+
elevation: 24,
|
|
328
|
+
shadowColor: Colors.black.withValues(alpha: 0.5),
|
|
329
|
+
child: Container(
|
|
330
|
+
decoration: BoxDecoration(
|
|
331
|
+
borderRadius: BorderRadius.circular(20),
|
|
332
|
+
border: Border.all(color: _borderLight),
|
|
333
|
+
),
|
|
334
|
+
child: ClipRRect(
|
|
335
|
+
borderRadius: BorderRadius.circular(20),
|
|
336
|
+
child: Column(
|
|
337
|
+
mainAxisSize: MainAxisSize.min,
|
|
338
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
339
|
+
children: <Widget>[
|
|
340
|
+
// Header
|
|
341
|
+
Padding(
|
|
342
|
+
padding: const EdgeInsets.fromLTRB(20, 16, 10, 12),
|
|
343
|
+
child: Row(
|
|
344
|
+
children: <Widget>[
|
|
345
|
+
Expanded(
|
|
346
|
+
child: Text(
|
|
347
|
+
widget.title,
|
|
348
|
+
style: TextStyle(
|
|
349
|
+
fontSize: 17,
|
|
350
|
+
fontWeight: FontWeight.w700,
|
|
351
|
+
color: _textPrimary,
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
355
|
+
IconButton(
|
|
356
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
357
|
+
icon: Icon(
|
|
358
|
+
Icons.close_rounded,
|
|
359
|
+
size: 20,
|
|
360
|
+
color: _textSecondary,
|
|
361
|
+
),
|
|
362
|
+
style: IconButton.styleFrom(
|
|
363
|
+
minimumSize: const Size(36, 36),
|
|
364
|
+
padding: EdgeInsets.zero,
|
|
365
|
+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
],
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
// Search bar
|
|
372
|
+
Padding(
|
|
373
|
+
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
|
374
|
+
child: TextField(
|
|
375
|
+
controller: _searchCtrl,
|
|
376
|
+
autofocus: true,
|
|
377
|
+
onChanged: (String v) =>
|
|
378
|
+
setState(() => _query = v.trim()),
|
|
379
|
+
style: TextStyle(color: _textPrimary, fontSize: 14),
|
|
380
|
+
decoration: InputDecoration(
|
|
381
|
+
hintText: 'Search…',
|
|
382
|
+
hintStyle: TextStyle(color: _textMuted, fontSize: 14),
|
|
383
|
+
prefixIcon: Icon(
|
|
384
|
+
Icons.search_rounded,
|
|
385
|
+
size: 18,
|
|
386
|
+
color: _textMuted,
|
|
387
|
+
),
|
|
388
|
+
suffixIcon: _query.isNotEmpty
|
|
389
|
+
? GestureDetector(
|
|
390
|
+
onTap: () => setState(() {
|
|
391
|
+
_searchCtrl.clear();
|
|
392
|
+
_query = '';
|
|
393
|
+
}),
|
|
394
|
+
child: Padding(
|
|
395
|
+
padding: const EdgeInsets.all(10),
|
|
396
|
+
child: Icon(
|
|
397
|
+
Icons.cancel_rounded,
|
|
398
|
+
size: 16,
|
|
399
|
+
color: _textMuted,
|
|
400
|
+
),
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
: null,
|
|
404
|
+
isDense: true,
|
|
405
|
+
contentPadding:
|
|
406
|
+
const EdgeInsets.symmetric(vertical: 10),
|
|
407
|
+
filled: true,
|
|
408
|
+
fillColor: _bgSecondary,
|
|
409
|
+
border: OutlineInputBorder(
|
|
410
|
+
borderRadius: BorderRadius.circular(12),
|
|
411
|
+
borderSide: BorderSide(color: _border),
|
|
412
|
+
),
|
|
413
|
+
enabledBorder: OutlineInputBorder(
|
|
414
|
+
borderRadius: BorderRadius.circular(12),
|
|
415
|
+
borderSide: BorderSide(color: _border),
|
|
416
|
+
),
|
|
417
|
+
focusedBorder: OutlineInputBorder(
|
|
418
|
+
borderRadius: BorderRadius.circular(12),
|
|
419
|
+
borderSide: BorderSide(color: _accent, width: 1.5),
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
Divider(height: 1, thickness: 1, color: _border),
|
|
425
|
+
// List
|
|
426
|
+
Flexible(
|
|
427
|
+
child: filtered.isEmpty
|
|
428
|
+
? Padding(
|
|
429
|
+
padding: const EdgeInsets.all(36),
|
|
430
|
+
child: Column(
|
|
431
|
+
mainAxisSize: MainAxisSize.min,
|
|
432
|
+
children: <Widget>[
|
|
433
|
+
Icon(
|
|
434
|
+
Icons.search_off_rounded,
|
|
435
|
+
size: 36,
|
|
436
|
+
color: _textMuted,
|
|
437
|
+
),
|
|
438
|
+
const SizedBox(height: 12),
|
|
439
|
+
Text(
|
|
440
|
+
'No results for "$_query"',
|
|
441
|
+
style: TextStyle(
|
|
442
|
+
color: _textSecondary,
|
|
443
|
+
fontSize: 14,
|
|
444
|
+
),
|
|
445
|
+
),
|
|
446
|
+
],
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
: ListView(
|
|
450
|
+
padding:
|
|
451
|
+
const EdgeInsets.symmetric(vertical: 6),
|
|
452
|
+
shrinkWrap: true,
|
|
453
|
+
children: <Widget>[
|
|
454
|
+
if (autoOption != null) ...<Widget>[
|
|
455
|
+
_PickerRow(
|
|
456
|
+
option: autoOption,
|
|
457
|
+
selected:
|
|
458
|
+
widget.currentValue == autoOption.value,
|
|
459
|
+
onTap: () =>
|
|
460
|
+
widget.onChanged(autoOption.value),
|
|
461
|
+
),
|
|
462
|
+
if (regularOptions.isNotEmpty)
|
|
463
|
+
Divider(
|
|
464
|
+
height: 1,
|
|
465
|
+
indent: 14,
|
|
466
|
+
endIndent: 14,
|
|
467
|
+
color: _border,
|
|
468
|
+
),
|
|
469
|
+
],
|
|
470
|
+
for (final String group in groups) ...<Widget>[
|
|
471
|
+
if (hasGroups && group.isNotEmpty)
|
|
472
|
+
_PickerGroupHeader(
|
|
473
|
+
label: _providerPickerLabel(group),
|
|
474
|
+
color: _providerPickerColor(group),
|
|
475
|
+
),
|
|
476
|
+
for (final _ModelPickerOption opt
|
|
477
|
+
in grouped[group]!)
|
|
478
|
+
_PickerRow(
|
|
479
|
+
option: opt,
|
|
480
|
+
selected:
|
|
481
|
+
widget.currentValue == opt.value,
|
|
482
|
+
onTap: () => widget.onChanged(opt.value),
|
|
483
|
+
),
|
|
484
|
+
],
|
|
485
|
+
],
|
|
486
|
+
),
|
|
487
|
+
),
|
|
488
|
+
],
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
),
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
500
|
+
// Sub-widgets
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
class _PickerGroupHeader extends StatelessWidget {
|
|
504
|
+
const _PickerGroupHeader({required this.label, required this.color});
|
|
505
|
+
|
|
506
|
+
final String label;
|
|
507
|
+
final Color color;
|
|
508
|
+
|
|
509
|
+
@override
|
|
510
|
+
Widget build(BuildContext context) {
|
|
511
|
+
return Padding(
|
|
512
|
+
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
|
513
|
+
child: Row(
|
|
514
|
+
children: <Widget>[
|
|
515
|
+
Container(
|
|
516
|
+
width: 6,
|
|
517
|
+
height: 6,
|
|
518
|
+
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
519
|
+
),
|
|
520
|
+
const SizedBox(width: 8),
|
|
521
|
+
Text(
|
|
522
|
+
label.toUpperCase(),
|
|
523
|
+
style: TextStyle(
|
|
524
|
+
fontSize: 10.5,
|
|
525
|
+
fontWeight: FontWeight.w700,
|
|
526
|
+
color: _textMuted,
|
|
527
|
+
letterSpacing: 0.8,
|
|
528
|
+
),
|
|
529
|
+
),
|
|
530
|
+
const SizedBox(width: 10),
|
|
531
|
+
Expanded(child: Container(height: 1, color: _border)),
|
|
532
|
+
],
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
class _PickerRow extends StatelessWidget {
|
|
539
|
+
const _PickerRow({
|
|
540
|
+
required this.option,
|
|
541
|
+
required this.selected,
|
|
542
|
+
required this.onTap,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
final _ModelPickerOption option;
|
|
546
|
+
final bool selected;
|
|
547
|
+
final VoidCallback onTap;
|
|
548
|
+
|
|
549
|
+
@override
|
|
550
|
+
Widget build(BuildContext context) {
|
|
551
|
+
final Color iconColor = option.isAuto
|
|
552
|
+
? _accentHover
|
|
553
|
+
: (option.color ?? _textSecondary);
|
|
554
|
+
final IconData iconData = option.isAuto
|
|
555
|
+
? Icons.auto_awesome_outlined
|
|
556
|
+
: (option.icon ?? Icons.memory_rounded);
|
|
557
|
+
final bool showShell = option.icon != null || option.isAuto;
|
|
558
|
+
|
|
559
|
+
return Material(
|
|
560
|
+
color: selected
|
|
561
|
+
? _accentMuted.withValues(alpha: 0.15)
|
|
562
|
+
: Colors.transparent,
|
|
563
|
+
child: InkWell(
|
|
564
|
+
onTap: onTap,
|
|
565
|
+
child: Padding(
|
|
566
|
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
|
567
|
+
child: Row(
|
|
568
|
+
children: <Widget>[
|
|
569
|
+
if (showShell) ...<Widget>[
|
|
570
|
+
Container(
|
|
571
|
+
width: 38,
|
|
572
|
+
height: 38,
|
|
573
|
+
decoration: BoxDecoration(
|
|
574
|
+
color:
|
|
575
|
+
iconColor.withValues(alpha: selected ? 0.2 : 0.11),
|
|
576
|
+
borderRadius: BorderRadius.circular(10),
|
|
577
|
+
border: selected
|
|
578
|
+
? Border.all(
|
|
579
|
+
color: iconColor.withValues(alpha: 0.38),
|
|
580
|
+
)
|
|
581
|
+
: null,
|
|
582
|
+
),
|
|
583
|
+
child: Icon(iconData, size: 18, color: iconColor),
|
|
584
|
+
),
|
|
585
|
+
const SizedBox(width: 12),
|
|
586
|
+
] else
|
|
587
|
+
const SizedBox(width: 4),
|
|
588
|
+
Expanded(
|
|
589
|
+
child: Column(
|
|
590
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
591
|
+
children: <Widget>[
|
|
592
|
+
Text(
|
|
593
|
+
option.label,
|
|
594
|
+
style: TextStyle(
|
|
595
|
+
fontSize: 14,
|
|
596
|
+
fontWeight: FontWeight.w600,
|
|
597
|
+
color: selected ? _accentHover : _textPrimary,
|
|
598
|
+
),
|
|
599
|
+
maxLines: 1,
|
|
600
|
+
overflow: TextOverflow.ellipsis,
|
|
601
|
+
),
|
|
602
|
+
if (option.subtitle != null) ...<Widget>[
|
|
603
|
+
const SizedBox(height: 2),
|
|
604
|
+
Text(
|
|
605
|
+
option.subtitle!,
|
|
606
|
+
style: TextStyle(fontSize: 12, color: _textMuted),
|
|
607
|
+
maxLines: 1,
|
|
608
|
+
overflow: TextOverflow.ellipsis,
|
|
609
|
+
),
|
|
610
|
+
],
|
|
611
|
+
],
|
|
612
|
+
),
|
|
613
|
+
),
|
|
614
|
+
const SizedBox(width: 8),
|
|
615
|
+
SizedBox(
|
|
616
|
+
width: 20,
|
|
617
|
+
child: selected
|
|
618
|
+
? Icon(
|
|
619
|
+
Icons.check_circle_rounded,
|
|
620
|
+
size: 18,
|
|
621
|
+
color: _accentHover,
|
|
622
|
+
)
|
|
623
|
+
: null,
|
|
624
|
+
),
|
|
625
|
+
],
|
|
626
|
+
),
|
|
627
|
+
),
|
|
628
|
+
),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|