| 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 2 | |
| 3 | import '../../../core/providers/storage_providers.dart'; |
| 4 | import '../../../core/storage/entities.dart'; |
| 5 | import '../../../core/storage/objectbox_store.dart'; |
| 6 | import '../../../objectbox.g.dart'; |
| 7 | import '../data/models/persona.dart'; |
| 8 | import '../utils/persona_prompt_utils.dart'; |
| 9 | |
| 10 | final personaSearchQueryProvider = |
| 11 | NotifierProvider<_PersonaSearchNotifier, String>( |
| 12 | _PersonaSearchNotifier.new, |
| 13 | ); |
| 14 | |
| 15 | final selectedPersonasProvider = |
| 16 | NotifierProvider<SelectedPersonasNotifier, List<Persona>>( |
| 17 | SelectedPersonasNotifier.new, |
| 18 | ); |
| 19 | |
| 20 | /// First selected persona, if any (legacy convenience). |
| 21 | final selectedPersonaProvider = Provider<Persona?>((ref) { |
| 22 | final personas = ref.watch(selectedPersonasProvider); |
| 23 | return personas.isEmpty ? null : personas.first; |
| 24 | }); |
| 25 | |
| 26 | class SelectedPersonasNotifier extends Notifier<List<Persona>> { |
| 27 | @override |
| 28 | List<Persona> build() => const []; |
| 29 | |
| 30 | void setPersonas(List<Persona> personas) { |
| 31 | state = List.unmodifiable(personas); |
| 32 | } |
| 33 | |
| 34 | void toggle(Persona persona) { |
| 35 | if (state.any((p) => p.id == persona.id)) { |
| 36 | state = state.where((p) => p.id != persona.id).toList(); |
| 37 | } else { |
| 38 | state = [...state, persona]; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | void clear() { |
| 43 | state = const []; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | class _PersonaSearchNotifier extends Notifier<String> { |
| 48 | @override |
| 49 | String build() => ''; |
| 50 | |
| 51 | void setQuery(String q) => state = q; |
| 52 | void clear() => state = ''; |
| 53 | } |
| 54 | |
| 55 | final personaCategoryFilterProvider = |
| 56 | NotifierProvider<_CategoryFilterNotifier, String?>( |
| 57 | _CategoryFilterNotifier.new, |
| 58 | ); |
| 59 | |
| 60 | final personaPreviewSystemPromptsProvider = |
| 61 | NotifierProvider<_PreviewSystemPromptsNotifier, bool>( |
| 62 | _PreviewSystemPromptsNotifier.new, |
| 63 | ); |
| 64 | |
| 65 | class _PreviewSystemPromptsNotifier extends Notifier<bool> { |
| 66 | @override |
| 67 | bool build() => false; |
| 68 | |
| 69 | void toggle() => state = !state; |
| 70 | void set(bool value) => state = value; |
| 71 | } |
| 72 | |
| 73 | class _CategoryFilterNotifier extends Notifier<String?> { |
| 74 | @override |
| 75 | String? build() => null; |
| 76 | |
| 77 | void setCategory(String? cat) => state = cat; |
| 78 | } |
| 79 | |
| 80 | final personasNotifierProvider = |
| 81 | AsyncNotifierProvider<PersonasNotifier, List<Persona>>( |
| 82 | PersonasNotifier.new, |
| 83 | ); |
| 84 | |
| 85 | class PersonasNotifier extends AsyncNotifier<List<Persona>> { |
| 86 | @override |
| 87 | Future<List<Persona>> build() async { |
| 88 | return _loadAll(); |
| 89 | } |
| 90 | |
| 91 | /// Seeds built-in personas when the database is first created. Called once |
| 92 | /// during app bootstrap so the provider [build] stays a pure read. |
| 93 | static void seedIfNeeded(ObjectBoxStore db) { |
| 94 | if (db.personaBox.isEmpty()) { |
| 95 | for (final preset in builtInPersonas) { |
| 96 | db.personaBox.put(PersonaEntity.fromDomain(preset)); |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | Future<List<Persona>> _loadAll() async { |
| 102 | return _loadWithoutSeeding(); |
| 103 | } |
| 104 | |
| 105 | Future<Persona> createPersona({ |
| 106 | required String name, |
| 107 | required String emoji, |
| 108 | required String systemPrompt, |
| 109 | String? description, |
| 110 | String? category, |
| 111 | Map<String, dynamic>? preferredParams, |
| 112 | }) async { |
| 113 | if (!ref.mounted) { |
| 114 | throw StateError('Cannot create persona on unmounted notifier'); |
| 115 | } |
| 116 | final db = ref.read(databaseProvider); |
| 117 | final now = DateTime.now(); |
| 118 | final persona = Persona( |
| 119 | id: now.millisecondsSinceEpoch.toString(), |
| 120 | name: name, |
| 121 | emoji: emoji, |
| 122 | systemPrompt: systemPrompt, |
| 123 | description: description, |
| 124 | isBuiltIn: false, |
| 125 | createdAt: now, |
| 126 | updatedAt: now, |
| 127 | category: category, |
| 128 | preferredParams: preferredParams, |
| 129 | ); |
| 130 | db.personaBox.put(PersonaEntity.fromDomain(persona)); |
| 131 | final data = await _loadAll(); |
| 132 | if (ref.mounted) { |
| 133 | state = AsyncData(data); |
| 134 | } |
| 135 | return persona; |
| 136 | } |
| 137 | |
| 138 | Future<void> updatePersona(Persona updated) async { |
| 139 | if (!ref.mounted) return; |
| 140 | final db = ref.read(databaseProvider); |
| 141 | final persona = updated.copyWith(updatedAt: DateTime.now()); |
| 142 | |
| 143 | final query = db.personaBox |
| 144 | .query(PersonaEntity_.id.equals(persona.id)) |
| 145 | .build(); |
| 146 | final existing = query.findFirst(); |
| 147 | query.close(); |
| 148 | |
| 149 | final entity = PersonaEntity.fromDomain(persona); |
| 150 | if (existing != null) { |
| 151 | entity.internalId = existing.internalId; |
| 152 | } |
| 153 | db.personaBox.put(entity); |
| 154 | |
| 155 | final data = await _loadAll(); |
| 156 | if (ref.mounted) { |
| 157 | state = AsyncData(data); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | Future<void> deletePersona(String id) async { |
| 162 | if (!ref.mounted) return; |
| 163 | final db = ref.read(databaseProvider); |
| 164 | final query = db.personaBox.query(PersonaEntity_.id.equals(id)).build(); |
| 165 | db.personaBox.removeMany(query.findIds()); |
| 166 | query.close(); |
| 167 | |
| 168 | final convQuery = db.conversationBox |
| 169 | .query(ConversationEntity_.personaId.equals(id)) |
| 170 | .build(); |
| 171 | for (final entity in convQuery.find()) { |
| 172 | entity.personaId = null; |
| 173 | db.conversationBox.put(entity); |
| 174 | } |
| 175 | convQuery.close(); |
| 176 | |
| 177 | final data = await _loadWithoutSeeding(); |
| 178 | if (ref.mounted) { |
| 179 | state = AsyncData(data); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /// Re-adds any built-in personas that were deleted, without touching |
| 184 | /// custom ones or duplicating built-ins that are still present. |
| 185 | Future<void> restoreBuiltInPersonas() async { |
| 186 | if (!ref.mounted) return; |
| 187 | final db = ref.read(databaseProvider); |
| 188 | final existingIds = db.personaBox.getAll().map((e) => e.id).toSet(); |
| 189 | for (final preset in builtInPersonas) { |
| 190 | if (!existingIds.contains(preset.id)) { |
| 191 | db.personaBox.put(PersonaEntity.fromDomain(preset)); |
| 192 | } |
| 193 | } |
| 194 | final data = await _loadWithoutSeeding(); |
| 195 | if (ref.mounted) { |
| 196 | state = AsyncData(data); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | Future<List<Persona>> _loadWithoutSeeding() async { |
| 201 | if (!ref.mounted) return []; |
| 202 | final db = ref.read(databaseProvider); |
| 203 | final entities = db.personaBox.getAll(); |
| 204 | final personas = entities.map((e) => e.toDomain()).toList(); |
| 205 | personas.sort((a, b) { |
| 206 | if (a.isBuiltIn != b.isBuiltIn) return a.isBuiltIn ? 1 : -1; |
| 207 | return a.name.compareTo(b.name); |
| 208 | }); |
| 209 | return personas; |
| 210 | } |
| 211 | |
| 212 | Future<Persona> clonePersona(String id) async { |
| 213 | final personas = state.value ?? []; |
| 214 | final original = personas.firstWhere( |
| 215 | (p) => p.id == id, |
| 216 | orElse: () => throw Exception('Persona not found'), |
| 217 | ); |
| 218 | |
| 219 | final now = DateTime.now(); |
| 220 | final clone = Persona( |
| 221 | id: now.millisecondsSinceEpoch.toString(), |
| 222 | name: '${original.name} (Copy)', |
| 223 | emoji: original.emoji, |
| 224 | systemPrompt: original.systemPrompt, |
| 225 | description: original.description, |
| 226 | isBuiltIn: false, |
| 227 | createdAt: now, |
| 228 | updatedAt: now, |
| 229 | category: original.category, |
| 230 | preferredParams: original.preferredParams != null |
| 231 | ? Map<String, dynamic>.from(original.preferredParams!) |
| 232 | : null, |
| 233 | ); |
| 234 | final db = ref.read(databaseProvider); |
| 235 | db.personaBox.put(PersonaEntity.fromDomain(clone)); |
| 236 | state = AsyncData(await _loadAll()); |
| 237 | return clone; |
| 238 | } |
| 239 | |
| 240 | static final List<Persona> builtInPersonas = [ |
| 241 | Persona( |
| 242 | id: 'builtin-general', |
| 243 | name: 'General Assistant', |
| 244 | emoji: '🤖', |
| 245 | systemPrompt: |
| 246 | 'You are a helpful, knowledgeable AI assistant. Provide clear, accurate, and concise responses. When you\'re not sure about something, say so. Use markdown formatting for structured responses.', |
| 247 | description: 'Helpful, knowledgeable assistant', |
| 248 | isBuiltIn: true, |
| 249 | createdAt: DateTime(2026), |
| 250 | updatedAt: DateTime(2026), |
| 251 | category: 'General', |
| 252 | ), |
| 253 | Persona( |
| 254 | id: 'builtin-code', |
| 255 | name: 'Code Assistant', |
| 256 | emoji: '🧑💻', |
| 257 | systemPrompt: |
| 258 | 'You are an expert software engineer. Help with coding questions, debugging, code reviews, and architecture decisions. Always provide code examples with proper syntax highlighting. Explain your reasoning. Follow best practices and mention potential pitfalls. When writing code, include comments for complex logic.', |
| 259 | description: 'Expert software engineer', |
| 260 | isBuiltIn: true, |
| 261 | createdAt: DateTime(2026), |
| 262 | updatedAt: DateTime(2026), |
| 263 | category: 'Coding', |
| 264 | ), |
| 265 | Persona( |
| 266 | id: 'builtin-math', |
| 267 | name: 'Math Tutor', |
| 268 | emoji: '📐', |
| 269 | systemPrompt: |
| 270 | 'You are a patient and thorough math tutor. Explain concepts step by step, starting from fundamentals. Use examples to illustrate abstract concepts. When solving problems, show your work clearly. Encourage the student and offer practice problems.', |
| 271 | description: 'Patient, step-by-step math tutor', |
| 272 | isBuiltIn: true, |
| 273 | createdAt: DateTime(2026), |
| 274 | updatedAt: DateTime(2026), |
| 275 | category: 'Education', |
| 276 | ), |
| 277 | Persona( |
| 278 | id: 'builtin-story', |
| 279 | name: 'Story Writer', |
| 280 | emoji: '✍️', |
| 281 | systemPrompt: |
| 282 | 'You are a creative fiction writer. Help craft engaging stories, develop characters, build worlds, and write dialogue. Match the tone and style the user requests. Offer constructive suggestions to improve narratives. Be creative and take risks with your writing while staying true to the user\'s vision.', |
| 283 | description: 'Creative fiction writer', |
| 284 | isBuiltIn: true, |
| 285 | createdAt: DateTime(2026), |
| 286 | updatedAt: DateTime(2026), |
| 287 | category: 'Creative', |
| 288 | ), |
| 289 | Persona( |
| 290 | id: 'builtin-tutor', |
| 291 | name: 'General Tutor', |
| 292 | emoji: '📚', |
| 293 | systemPrompt: |
| 294 | 'You are an educational tutor skilled in all subjects. Explain complex topics in simple terms. Use analogies, examples, and visual descriptions. Break down topics into digestible pieces. Check understanding by posing questions. Adapt your explanation style to the student\'s level.', |
| 295 | description: 'Skilled tutor for all subjects', |
| 296 | isBuiltIn: true, |
| 297 | createdAt: DateTime(2026), |
| 298 | updatedAt: DateTime(2026), |
| 299 | category: 'Education', |
| 300 | ), |
| 301 | Persona( |
| 302 | id: 'builtin-editor', |
| 303 | name: 'Writing Editor', |
| 304 | emoji: '✏️', |
| 305 | systemPrompt: |
| 306 | 'You are a professional writing editor. Help improve text clarity, grammar, style, and structure. Provide specific suggestions with explanations. Maintain the author\'s voice while improving readability. Offer alternatives rather than dictating changes. Format feedback clearly with before/after examples.', |
| 307 | description: 'Professional writing editor', |
| 308 | isBuiltIn: true, |
| 309 | createdAt: DateTime(2026), |
| 310 | updatedAt: DateTime(2026), |
| 311 | category: 'Creative', |
| 312 | ), |
| 313 | Persona( |
| 314 | id: 'builtin-summarizer', |
| 315 | name: 'Summarizer', |
| 316 | emoji: '📋', |
| 317 | systemPrompt: |
| 318 | 'You are a concise summarizer. When given text, provide clear, accurate summaries that capture the key points. Use bullet points for multiple items. Maintain the original meaning without adding interpretation. Adjust summary length based on the input — shorter inputs get shorter summaries.', |
| 319 | description: 'Concise, accurate summarizer', |
| 320 | isBuiltIn: true, |
| 321 | createdAt: DateTime(2026), |
| 322 | updatedAt: DateTime(2026), |
| 323 | category: 'General', |
| 324 | ), |
| 325 | ]; |
| 326 | } |
| 327 | |
| 328 | final filteredPersonasProvider = Provider<List<Persona>>((ref) { |
| 329 | final personasAsync = ref.watch(personasNotifierProvider); |
| 330 | final personas = personasAsync.value ?? []; |
| 331 | final category = ref.watch(personaCategoryFilterProvider); |
| 332 | final query = ref.watch(personaSearchQueryProvider).toLowerCase(); |
| 333 | |
| 334 | var filtered = personas; |
| 335 | if (category != null && category.isNotEmpty) { |
| 336 | filtered = filtered.where((p) => p.category == category).toList(); |
| 337 | } |
| 338 | if (query.isNotEmpty) { |
| 339 | filtered = filtered |
| 340 | .where( |
| 341 | (p) => |
| 342 | p.name.toLowerCase().contains(query) || |
| 343 | (p.description?.toLowerCase().contains(query) ?? false) || |
| 344 | p.systemPrompt.toLowerCase().contains(query), |
| 345 | ) |
| 346 | .toList(); |
| 347 | } |
| 348 | return filtered; |
| 349 | }); |
| 350 | |
| 351 | final builtInPersonasProvider = Provider<List<Persona>>((ref) { |
| 352 | final personas = ref.watch(personasNotifierProvider).value ?? []; |
| 353 | return personas.where((p) => p.isBuiltIn).toList(); |
| 354 | }); |
| 355 | |
| 356 | final userPersonasProvider = Provider<List<Persona>>((ref) { |
| 357 | final personas = ref.watch(personasNotifierProvider).value ?? []; |
| 358 | return personas.where((p) => !p.isBuiltIn).toList(); |
| 359 | }); |
| 360 | |
| 361 | final personaByIdProvider = Provider.family<Persona?, String>((ref, id) { |
| 362 | try { |
| 363 | final personas = ref.watch(personasNotifierProvider).value ?? []; |
| 364 | return personas.firstWhere((p) => p.id == id); |
| 365 | } catch (_) { |
| 366 | return null; |
| 367 | } |
| 368 | }); |
| 369 | |
| 370 | final personasForConversationProvider = Provider.family<List<Persona>, String?>( |
| 371 | (ref, personaIdsRaw) { |
| 372 | final personas = ref.watch(personasNotifierProvider).value ?? []; |
| 373 | return PersonaPromptUtils.resolvePersonas(personaIdsRaw, personas); |
| 374 | }, |
| 375 | ); |