main
dart 508 lines 17 KB
Raw
1 import 'package:hugeicons/hugeicons.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:localmind/l10n/app_localizations.dart';
6 import '../data/models/persona.dart';
7 import '../providers/personas_providers.dart';
8
9 class CreatePersonaScreen extends ConsumerStatefulWidget {
10 final Persona? editPersona;
11
12 const CreatePersonaScreen({super.key, this.editPersona});
13
14 @override
15 ConsumerState<CreatePersonaScreen> createState() =>
16 _CreatePersonaScreenState();
17 }
18
19 class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
20 final _formKey = GlobalKey<FormState>();
21 late TextEditingController _nameController;
22 late TextEditingController _descriptionController;
23 late TextEditingController _promptController;
24 late TextEditingController _tempController;
25 late TextEditingController _topPController;
26
27 late String _selectedEmoji;
28 String? _selectedCategory;
29 bool _showAdvanced = false;
30 bool _isSaving = false;
31 bool _showPreview = false;
32
33 bool get _isEditing => widget.editPersona != null;
34
35 static const _emojis = [
36 '🤖',
37 '🧑‍💻',
38 '📐',
39 '✍️',
40 '📚',
41 '✏️',
42 '📋',
43 '🎯',
44 '🔍',
45 '💡',
46 '🎨',
47 '🧪',
48 '🔬',
49 '📝',
50 '🧠',
51 '🎭',
52 '🎵',
53 '📊',
54 '🌐',
55 '💬',
56 '🛠️',
57 '🎓',
58 '📖',
59 '🧪',
60 '🎮',
61 '🗺️',
62 '🤝',
63 '⚡',
64 ];
65
66 static const _categories = ['General', 'Coding', 'Education', 'Creative'];
67
68 @override
69 void initState() {
70 super.initState();
71 final p = widget.editPersona;
72 _nameController = TextEditingController(text: p?.name ?? '');
73 _descriptionController = TextEditingController(text: p?.description ?? '');
74 _promptController = TextEditingController(text: p?.systemPrompt ?? '');
75 _selectedEmoji = p?.emoji ?? '🤖';
76 _selectedCategory = p?.category;
77
78 final params = p?.preferredParams;
79 _tempController = TextEditingController(
80 text: params != null && params['temperature'] != null
81 ? params['temperature'].toString()
82 : '',
83 );
84 _topPController = TextEditingController(
85 text: params != null && params['topP'] != null
86 ? params['topP'].toString()
87 : '',
88 );
89 }
90
91 @override
92 void dispose() {
93 _nameController.dispose();
94 _descriptionController.dispose();
95 _promptController.dispose();
96 _tempController.dispose();
97 _topPController.dispose();
98 super.dispose();
99 }
100
101 Future<void> _save() async {
102 if (_formKey.currentState?.validate() != true) return;
103
104 setState(() => _isSaving = true);
105
106 final notifier = ref.read(personasNotifierProvider.notifier);
107 final name = _nameController.text.trim();
108 final prompt = _promptController.text.trim();
109 final desc = _descriptionController.text.trim();
110
111 Map<String, dynamic>? params;
112 final temp = double.tryParse(_tempController.text.trim());
113 final topP = double.tryParse(_topPController.text.trim());
114 if (temp != null || topP != null) {
115 params = {};
116 if (temp != null) params['temperature'] = temp;
117 if (topP != null) params['topP'] = topP;
118 }
119
120 var hasPopped = false;
121 try {
122 if (_isEditing) {
123 await notifier.updatePersona(
124 widget.editPersona!.copyWith(
125 name: name,
126 emoji: _selectedEmoji,
127 systemPrompt: prompt,
128 description: desc.isNotEmpty ? desc : null,
129 category: _selectedCategory,
130 preferredParams: params,
131 ),
132 );
133 } else {
134 await notifier.createPersona(
135 name: name,
136 emoji: _selectedEmoji,
137 systemPrompt: prompt,
138 description: desc.isNotEmpty ? desc : null,
139 category: _selectedCategory,
140 preferredParams: params,
141 );
142 }
143
144 if (mounted) {
145 final l10n = AppLocalizations.of(context);
146 final messenger = ScaffoldMessenger.maybeOf(context);
147 hasPopped = true;
148 if (Navigator.canPop(context)) {
149 Navigator.pop(context);
150 } else {
151 context.pop();
152 }
153 if (l10n != null && messenger != null) {
154 messenger.showSnackBar(
155 SnackBar(
156 content: Text(
157 _isEditing ? l10n.persona_updated : l10n.persona_created,
158 ),
159 ),
160 );
161 }
162 }
163 } catch (e) {
164 hasPopped = false;
165 if (mounted) {
166 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
167 SnackBar(
168 content: Text(
169 AppLocalizations.of(context)?.error_with_message(e.toString()) ??
170 'Error: $e',
171 ),
172 backgroundColor: Colors.red,
173 ),
174 );
175 }
176 } finally {
177 if (mounted && !hasPopped) {
178 setState(() => _isSaving = false);
179 }
180 }
181 }
182
183 @override
184 Widget build(BuildContext context) {
185 final l10n = AppLocalizations.of(context)!;
186 final theme = Theme.of(context);
187 final isDark = theme.brightness == Brightness.dark;
188
189 return Scaffold(
190 appBar: AppBar(
191 title: Text(_isEditing ? l10n.edit_persona : l10n.create_persona),
192 leading: IconButton(
193 icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
194 onPressed: () => context.pop(),
195 ),
196 actions: [
197 TextButton(
198 onPressed: _isSaving ? null : _save,
199 child: _isSaving
200 ? const SizedBox(
201 width: 16,
202 height: 16,
203 child: CircularProgressIndicator(strokeWidth: 2),
204 )
205 : Text(_isEditing ? l10n.save : l10n.create_persona_button),
206 ),
207 ],
208 ),
209 body: Form(
210 key: _formKey,
211 child: ListView(
212 padding: const EdgeInsets.all(16),
213 children: [
214 Text(
215 l10n.emoji_label,
216 style: TextStyle(
217 fontSize: 14,
218 fontWeight: FontWeight.w500,
219 color: isDark ? Colors.white : Colors.black,
220 ),
221 ),
222 const SizedBox(height: 8),
223 SizedBox(
224 height: 48,
225 child: ListView.separated(
226 scrollDirection: Axis.horizontal,
227 itemCount: _emojis.length,
228 separatorBuilder: (_, _) => const SizedBox(width: 4),
229 itemBuilder: (context, index) {
230 final emoji = _emojis[index];
231 final isSelected = emoji == _selectedEmoji;
232 return GestureDetector(
233 onTap: () => setState(() => _selectedEmoji = emoji),
234 child: Container(
235 width: 40,
236 height: 40,
237 decoration: BoxDecoration(
238 color: isSelected
239 ? (isDark
240 ? const Color(
241 0xFF3B82F6,
242 ).withValues(alpha: 0.2)
243 : const Color(
244 0xFF2563EB,
245 ).withValues(alpha: 0.15))
246 : (isDark
247 ? const Color(0xFF1F1F1F)
248 : const Color(0xFFF5F5F5)),
249 borderRadius: BorderRadius.circular(8),
250 border: Border.all(
251 color: isSelected
252 ? (isDark
253 ? const Color(0xFF3B82F6)
254 : const Color(0xFF2563EB))
255 : (isDark
256 ? const Color(0xFF3A3A3A)
257 : const Color(0xFFE5E5E5)),
258 ),
259 ),
260 child: Center(
261 child: Text(
262 emoji,
263 style: const TextStyle(fontSize: 20),
264 ),
265 ),
266 ),
267 );
268 },
269 ),
270 ),
271 const SizedBox(height: 20),
272
273 TextFormField(
274 controller: _nameController,
275 decoration: InputDecoration(
276 labelText: l10n.name_label,
277 hintText: l10n.my_persona_hint,
278 ),
279 validator: (v) {
280 if (v == null || v.trim().isEmpty) return l10n.name_required;
281 if (v.trim().length > 50) return l10n.name_max_50;
282 return null;
283 },
284 textInputAction: TextInputAction.next,
285 maxLength: 50,
286 ),
287 const SizedBox(height: 12),
288
289 DropdownButtonFormField<String>(
290 initialValue: _selectedCategory,
291 decoration: InputDecoration(labelText: l10n.category_label),
292 items: _categories
293 .map((c) => DropdownMenuItem(value: c, child: Text(c)))
294 .toList(),
295 onChanged: (v) => setState(() => _selectedCategory = v),
296 ),
297 const SizedBox(height: 12),
298
299 TextFormField(
300 controller: _descriptionController,
301 decoration: InputDecoration(
302 labelText: l10n.description_optional,
303 hintText: l10n.description_hint,
304 ),
305 maxLength: 200,
306 textInputAction: TextInputAction.next,
307 ),
308 const SizedBox(height: 12),
309
310 Row(
311 mainAxisAlignment: MainAxisAlignment.spaceBetween,
312 children: [
313 Text(
314 l10n.system_prompt,
315 style: TextStyle(
316 fontSize: 14,
317 fontWeight: FontWeight.w500,
318 color: isDark ? Colors.white : Colors.black,
319 ),
320 ),
321 Row(
322 children: [
323 Text(
324 l10n.character_count_max(_promptController.text.length),
325 style: TextStyle(
326 fontSize: 12,
327 color: _promptController.text.length > 4000
328 ? Colors.red
329 : (isDark
330 ? const Color(0xFF666666)
331 : const Color(0xFF999999)),
332 ),
333 ),
334 const SizedBox(width: 8),
335 IconButton(
336 icon: HugeIcon(
337 icon: _showPreview
338 ? HugeIcons.strokeRoundedPencilEdit02
339 : HugeIcons.strokeRoundedEye,
340 size: 18,
341 ),
342 tooltip: _showPreview ? l10n.edit : l10n.preview,
343 onPressed: () =>
344 setState(() => _showPreview = !_showPreview),
345 ),
346 ],
347 ),
348 ],
349 ),
350 const SizedBox(height: 4),
351 if (_showPreview)
352 Container(
353 width: double.infinity,
354 padding: const EdgeInsets.all(12),
355 decoration: BoxDecoration(
356 color: isDark
357 ? const Color(0xFF1F1F1F)
358 : const Color(0xFFF5F5F5),
359 borderRadius: BorderRadius.circular(8),
360 border: Border.all(
361 color: isDark
362 ? const Color(0xFF3A3A3A)
363 : const Color(0xFFE5E5E5),
364 ),
365 ),
366 child: Text(
367 _promptController.text.isEmpty
368 ? l10n.no_prompt_placeholder
369 : _promptController.text,
370 style: TextStyle(
371 fontSize: 14,
372 color: _promptController.text.isEmpty
373 ? (isDark
374 ? const Color(0xFF555555)
375 : const Color(0xFFBBBBBB))
376 : (isDark ? Colors.white : Colors.black),
377 ),
378 ),
379 )
380 else
381 TextFormField(
382 controller: _promptController,
383 decoration: InputDecoration(
384 hintText: l10n.prompt_hint,
385 alignLabelWithHint: true,
386 ),
387 maxLines: 8,
388 minLines: 4,
389 maxLength: 4000,
390 validator: (v) {
391 if (v == null || v.trim().isEmpty) {
392 return l10n.prompt_required;
393 }
394 if (v.trim().length > 4000) return l10n.prompt_max_chars;
395 return null;
396 },
397 onChanged: (_) => setState(() {}),
398 ),
399 const SizedBox(height: 16),
400
401 GestureDetector(
402 onTap: () => setState(() => _showAdvanced = !_showAdvanced),
403 child: Row(
404 children: [
405 HugeIcon(
406 icon: _showAdvanced
407 ? HugeIcons.strokeRoundedArrowUp01
408 : HugeIcons.strokeRoundedArrowDown01,
409 color: isDark
410 ? const Color(0xFF888888)
411 : const Color(0xFF999999),
412 ),
413 const SizedBox(width: 4),
414 Text(
415 l10n.advanced_settings,
416 style: TextStyle(
417 fontSize: 14,
418 fontWeight: FontWeight.w500,
419 color: isDark
420 ? const Color(0xFF888888)
421 : const Color(0xFF999999),
422 ),
423 ),
424 ],
425 ),
426 ),
427 if (_showAdvanced) ...[
428 const SizedBox(height: 12),
429 Row(
430 children: [
431 Expanded(
432 child: TextFormField(
433 controller: _tempController,
434 decoration: InputDecoration(
435 labelText: l10n.temperature_label,
436 hintText: l10n.temp_hint,
437 ),
438 keyboardType: const TextInputType.numberWithOptions(
439 decimal: true,
440 ),
441 validator: (v) {
442 if (v == null || v.trim().isEmpty) return null;
443 final val = double.tryParse(v.trim());
444 if (val == null || val < 0 || val > 2) {
445 return l10n.range_0_2;
446 } else {
447 return null;
448 }
449 },
450 ),
451 ),
452 const SizedBox(width: 12),
453 Expanded(
454 child: TextFormField(
455 controller: _topPController,
456 decoration: InputDecoration(
457 labelText: l10n.top_p_label,
458 hintText: l10n.top_p_hint,
459 ),
460 keyboardType: const TextInputType.numberWithOptions(
461 decimal: true,
462 ),
463 validator: (v) {
464 if (v == null || v.trim().isEmpty) return null;
465 final val = double.tryParse(v.trim());
466 if (val == null || val < 0 || val > 1) {
467 return l10n.range_0_1;
468 } else {
469 return null;
470 }
471 },
472 ),
473 ),
474 ],
475 ),
476 ],
477 const SizedBox(height: 32),
478 SizedBox(
479 width: double.infinity,
480 child: FilledButton(
481 onPressed: _isSaving ? null : _save,
482 style: FilledButton.styleFrom(
483 backgroundColor: theme.colorScheme.primary,
484 foregroundColor: theme.colorScheme.onPrimary,
485 padding: const EdgeInsets.symmetric(vertical: 14),
486 ),
487 child: _isSaving
488 ? SizedBox(
489 width: 20,
490 height: 20,
491 child: CircularProgressIndicator(
492 strokeWidth: 2,
493 color: theme.colorScheme.onPrimary,
494 ),
495 )
496 : Text(
497 _isEditing
498 ? l10n.save_changes
499 : l10n.create_persona_button,
500 ),
501 ),
502 ),
503 ],
504 ),
505 ),
506 );
507 }
508 }