Still some AI integration issues
This commit is contained in:
parent
2e99ec1234
commit
b5449f7842
BIN
assets/gemini.png
Normal file
BIN
assets/gemini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
gemini-input-glow.png
Normal file
BIN
gemini-input-glow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -25,6 +25,8 @@ import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../utils/subject_suggestions.dart';
|
import '../../utils/subject_suggestions.dart';
|
||||||
|
import '../../widgets/gemini_button.dart';
|
||||||
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
|
||||||
// request metadata options used in task creation/editing dialogs
|
// request metadata options used in task creation/editing dialogs
|
||||||
const List<String> _requestTypeOptions = [
|
const List<String> _requestTypeOptions = [
|
||||||
|
|
@ -584,6 +586,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
bool saving = false;
|
bool saving = false;
|
||||||
|
bool titleProcessing = false;
|
||||||
|
bool descProcessing = false;
|
||||||
|
bool titleDeepSeek = false;
|
||||||
|
bool descDeepSeek = false;
|
||||||
final officesAsync = ref.watch(officesProvider);
|
final officesAsync = ref.watch(officesProvider);
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
|
|
@ -595,39 +601,94 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TypeAheadFormField<String>(
|
Row(
|
||||||
textFieldConfiguration: TextFieldConfiguration(
|
children: [
|
||||||
controller: titleController,
|
Expanded(
|
||||||
decoration: const InputDecoration(
|
child: GeminiAnimatedBorder(
|
||||||
labelText: 'Task title',
|
isProcessing: titleProcessing,
|
||||||
|
useDeepSeekColors: titleDeepSeek,
|
||||||
|
child: TypeAheadFormField<String>(
|
||||||
|
textFieldConfiguration: TextFieldConfiguration(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Task title',
|
||||||
|
),
|
||||||
|
enabled: !saving,
|
||||||
|
),
|
||||||
|
suggestionsCallback: (pattern) async {
|
||||||
|
return SubjectSuggestionEngine.suggest(
|
||||||
|
existingSubjects: existingSubjects,
|
||||||
|
query: pattern,
|
||||||
|
limit: 8,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, suggestion) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(suggestion),
|
||||||
|
),
|
||||||
|
onSuggestionSelected: (suggestion) {
|
||||||
|
titleController
|
||||||
|
..text = suggestion
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: suggestion.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabled: !saving,
|
GeminiButton(
|
||||||
),
|
textController: titleController,
|
||||||
suggestionsCallback: (pattern) async {
|
onTextUpdated: (updatedText) {
|
||||||
return SubjectSuggestionEngine.suggest(
|
setState(() {
|
||||||
existingSubjects: existingSubjects,
|
titleController.text = updatedText;
|
||||||
query: pattern,
|
});
|
||||||
limit: 8,
|
},
|
||||||
);
|
onProcessingStateChanged: (isProcessing) {
|
||||||
},
|
setState(() {
|
||||||
itemBuilder: (context, suggestion) =>
|
titleProcessing = isProcessing;
|
||||||
ListTile(dense: true, title: Text(suggestion)),
|
});
|
||||||
onSuggestionSelected: (suggestion) {
|
},
|
||||||
titleController
|
onProviderChanged: (isDeepSeek) {
|
||||||
..text = suggestion
|
setState(() => titleDeepSeek = isDeepSeek);
|
||||||
..selection = TextSelection.collapsed(
|
},
|
||||||
offset: suggestion.length,
|
tooltip: 'Improve task title with Gemini',
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
Row(
|
||||||
controller: descriptionController,
|
children: [
|
||||||
decoration: const InputDecoration(
|
Expanded(
|
||||||
labelText: 'Description',
|
child: GeminiAnimatedTextField(
|
||||||
),
|
controller: descriptionController,
|
||||||
maxLines: 3,
|
labelText: 'Description',
|
||||||
enabled: !saving,
|
maxLines: 3,
|
||||||
|
enabled: !saving,
|
||||||
|
isProcessing: descProcessing,
|
||||||
|
useDeepSeekColors: descDeepSeek,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: GeminiButton(
|
||||||
|
textController: descriptionController,
|
||||||
|
onTextUpdated: (updatedText) {
|
||||||
|
setState(() {
|
||||||
|
descriptionController.text = updatedText;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onProcessingStateChanged: (isProcessing) {
|
||||||
|
setState(() {
|
||||||
|
descProcessing = isProcessing;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onProviderChanged: (isDeepSeek) {
|
||||||
|
setState(() => descDeepSeek = isDeepSeek);
|
||||||
|
},
|
||||||
|
tooltip: 'Improve description with Gemini',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
officesAsync.when(
|
officesAsync.when(
|
||||||
|
|
|
||||||
202
lib/services/ai_service.dart
Normal file
202
lib/services/ai_service.dart
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Unified AI text-enhancement service.
|
||||||
|
///
|
||||||
|
/// Tries Gemini (free-tier flash/lite models) first, with automatic
|
||||||
|
/// 429-retry across all discovered models. If every Gemini model fails
|
||||||
|
/// (quota exhausted or any unrecoverable error) it seamlessly falls back
|
||||||
|
/// to the DeepSeek API.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final result = await AiService().enhanceText(
|
||||||
|
/// myText,
|
||||||
|
/// promptInstruction: 'Fix grammar and translate to English …',
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class AiService {
|
||||||
|
static final AiService _instance = AiService._internal();
|
||||||
|
factory AiService() => _instance;
|
||||||
|
|
||||||
|
late final String _geminiApiKey;
|
||||||
|
late final String _deepseekApiKey;
|
||||||
|
|
||||||
|
/// Cached Gemini model IDs (flash / lite, generateContent-capable).
|
||||||
|
List<String> _geminiModels = [];
|
||||||
|
|
||||||
|
AiService._internal() {
|
||||||
|
final gKey = dotenv.env['GEMINI_API_KEY'];
|
||||||
|
if (gKey == null || gKey.isEmpty) {
|
||||||
|
throw Exception('GEMINI_API_KEY not found in .env');
|
||||||
|
}
|
||||||
|
_geminiApiKey = gKey;
|
||||||
|
|
||||||
|
final dsKey = dotenv.env['DEEPSEEK_API_KEY'];
|
||||||
|
if (dsKey == null || dsKey.isEmpty) {
|
||||||
|
throw Exception('DEEPSEEK_API_KEY not found in .env');
|
||||||
|
}
|
||||||
|
_deepseekApiKey = dsKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fixes spelling / grammar, improves clarity, and translates [text] to
|
||||||
|
/// professional English.
|
||||||
|
///
|
||||||
|
/// Supply [promptInstruction] to give the model field-specific context
|
||||||
|
/// (e.g. "This is an IT helpdesk ticket subject …"). If omitted a
|
||||||
|
/// sensible generic instruction is used.
|
||||||
|
///
|
||||||
|
/// Order of preference:
|
||||||
|
/// 1. Gemini flash / lite models (auto-retry on 429 across all models)
|
||||||
|
/// 2. DeepSeek `deepseek-chat` (fallback on total Gemini failure)
|
||||||
|
///
|
||||||
|
/// Throws only if **both** providers fail.
|
||||||
|
/// [onFallbackToDeepSeek] is called (from the same isolate) just before
|
||||||
|
/// switching to the DeepSeek provider, so callers can update UI accordingly.
|
||||||
|
///
|
||||||
|
/// This method never throws — if both providers fail it returns [text] unchanged.
|
||||||
|
Future<String> enhanceText(
|
||||||
|
String text, {
|
||||||
|
String? promptInstruction,
|
||||||
|
void Function()? onFallbackToDeepSeek,
|
||||||
|
}) async {
|
||||||
|
if (text.trim().isEmpty) return text;
|
||||||
|
|
||||||
|
final instruction =
|
||||||
|
promptInstruction ??
|
||||||
|
'Fix spelling and grammar, improve clarity, and translate to '
|
||||||
|
'professional English. Return ONLY the improved text, '
|
||||||
|
'no explanations:';
|
||||||
|
final prompt = '$instruction\n\n"$text"';
|
||||||
|
|
||||||
|
// --- 1. Try Gemini ---
|
||||||
|
try {
|
||||||
|
return await _geminiGenerate(prompt, fallback: text);
|
||||||
|
} catch (_) {
|
||||||
|
// All Gemini models failed — fall through to DeepSeek.
|
||||||
|
onFallbackToDeepSeek?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Fallback: DeepSeek ---
|
||||||
|
try {
|
||||||
|
return await _deepseekGenerate(prompt, fallback: text);
|
||||||
|
} catch (_) {
|
||||||
|
// Both providers failed — return original text unchanged.
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gemini
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<List<String>> _getGeminiModels() async {
|
||||||
|
if (_geminiModels.isNotEmpty) return _geminiModels;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/models'
|
||||||
|
'?key=$_geminiApiKey',
|
||||||
|
);
|
||||||
|
final res = await http.get(uri);
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final rawModels = (data['models'] as List<dynamic>?) ?? [];
|
||||||
|
final discovered = <String>[];
|
||||||
|
for (final m in rawModels) {
|
||||||
|
final fullName = m['name'] as String? ?? '';
|
||||||
|
final lower = fullName.toLowerCase();
|
||||||
|
final methods =
|
||||||
|
(m['supportedGenerationMethods'] as List<dynamic>?) ?? [];
|
||||||
|
if (methods.contains('generateContent') &&
|
||||||
|
(lower.contains('flash') || lower.contains('lite'))) {
|
||||||
|
final id = fullName.startsWith('models/')
|
||||||
|
? fullName.substring('models/'.length)
|
||||||
|
: fullName;
|
||||||
|
discovered.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discovered.sort((a, b) => b.compareTo(a));
|
||||||
|
_geminiModels = discovered;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to hard-coded list.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_geminiModels.isEmpty) {
|
||||||
|
_geminiModels = [
|
||||||
|
'gemini-2.5-flash-lite',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'gemini-2.0-flash',
|
||||||
|
'gemini-1.5-flash',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _geminiModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _geminiGenerate(
|
||||||
|
String prompt, {
|
||||||
|
required String fallback,
|
||||||
|
}) async {
|
||||||
|
final models = await _getGeminiModels();
|
||||||
|
|
||||||
|
Object? lastError;
|
||||||
|
for (final modelId in models) {
|
||||||
|
try {
|
||||||
|
final model = GenerativeModel(model: modelId, apiKey: _geminiApiKey);
|
||||||
|
final response = await model.generateContent([Content.text(prompt)]);
|
||||||
|
return response.text ?? fallback;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
// Try the next model regardless of error type.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('All Gemini models failed. Last error: $lastError');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeepSeek (OpenAI-compatible REST)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<String> _deepseekGenerate(
|
||||||
|
String prompt, {
|
||||||
|
required String fallback,
|
||||||
|
}) async {
|
||||||
|
const url = 'https://api.deepseek.com/chat/completions';
|
||||||
|
final body = jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{'role': 'user', 'content': prompt},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $_deepseekApiKey',
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final choices = data['choices'] as List<dynamic>?;
|
||||||
|
final content = choices?.firstOrNull?['message']?['content'] as String?;
|
||||||
|
return content?.trim() ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception(
|
||||||
|
'DeepSeek request failed (HTTP ${res.statusCode}): ${res.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
lib/services/gemini_service.dart
Normal file
144
lib/services/gemini_service.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class GeminiService {
|
||||||
|
static final GeminiService _instance = GeminiService._internal();
|
||||||
|
|
||||||
|
late final String _apiKey;
|
||||||
|
|
||||||
|
/// Cache of valid model IDs (flash/lite, supporting generateContent).
|
||||||
|
List<String> _validModels = [];
|
||||||
|
|
||||||
|
factory GeminiService() => _instance;
|
||||||
|
|
||||||
|
GeminiService._internal() {
|
||||||
|
final apiKey = dotenv.env['GEMINI_API_KEY'];
|
||||||
|
if (apiKey == null || apiKey.isEmpty) {
|
||||||
|
throw Exception('GEMINI_API_KEY not found in .env file');
|
||||||
|
}
|
||||||
|
_apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Model discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Queries the Gemini REST API for available models and caches those that
|
||||||
|
/// - support `generateContent`, AND
|
||||||
|
/// - contain "flash" or "lite" in their name (free-tier / fast models).
|
||||||
|
///
|
||||||
|
/// Returns a stable cached list on subsequent calls.
|
||||||
|
Future<List<String>> _getValidModels() async {
|
||||||
|
if (_validModels.isNotEmpty) return _validModels;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/models?key=$_apiKey',
|
||||||
|
);
|
||||||
|
final response = await http.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final rawModels = (data['models'] as List<dynamic>?) ?? [];
|
||||||
|
|
||||||
|
final discovered = <String>[];
|
||||||
|
for (final m in rawModels) {
|
||||||
|
final fullName = (m['name'] as String? ?? '');
|
||||||
|
final lower = fullName.toLowerCase();
|
||||||
|
final methods =
|
||||||
|
(m['supportedGenerationMethods'] as List<dynamic>?) ?? [];
|
||||||
|
|
||||||
|
if (methods.contains('generateContent') &&
|
||||||
|
(lower.contains('flash') || lower.contains('lite'))) {
|
||||||
|
// Strip the "models/" prefix so it can be passed directly to
|
||||||
|
// GenerativeModel(model: ...).
|
||||||
|
final id = fullName.startsWith('models/')
|
||||||
|
? fullName.substring('models/'.length)
|
||||||
|
: fullName;
|
||||||
|
discovered.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending so newer/more-capable models are tried first.
|
||||||
|
discovered.sort((a, b) => b.compareTo(a));
|
||||||
|
_validModels = discovered;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to hard-coded list of known free-tier models below.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If discovery failed or returned nothing, use safe known fallbacks.
|
||||||
|
if (_validModels.isEmpty) {
|
||||||
|
_validModels = [
|
||||||
|
'gemini-2.5-flash-lite',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'gemini-2.0-flash',
|
||||||
|
'gemini-1.5-flash',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _validModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fixes spelling/grammar, improves clarity, and translates [text] to
|
||||||
|
/// professional English.
|
||||||
|
///
|
||||||
|
/// Provide a custom [promptInstruction] to give Gemini field-specific
|
||||||
|
/// context (subject, description, action-taken). If omitted a sensible
|
||||||
|
/// default is used.
|
||||||
|
///
|
||||||
|
/// Automatically retries with the next available model on 429 / quota
|
||||||
|
/// errors to minimise wasted quota calls.
|
||||||
|
Future<String> enhanceText(String text, {String? promptInstruction}) async {
|
||||||
|
if (text.trim().isEmpty) return text;
|
||||||
|
final instruction =
|
||||||
|
promptInstruction ??
|
||||||
|
'Fix spelling and grammar, improve clarity, and translate to '
|
||||||
|
'professional English. Return ONLY the improved text, no explanations:';
|
||||||
|
final prompt = '$instruction\n\n"$text"';
|
||||||
|
return _generateWithRetry(prompt, fallback: text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sends [prompt] to Gemini, retrying across all valid models on 429 errors.
|
||||||
|
/// Returns the model response on success, or throws if all models fail.
|
||||||
|
Future<String> _generateWithRetry(
|
||||||
|
String prompt, {
|
||||||
|
required String fallback,
|
||||||
|
}) async {
|
||||||
|
final models = await _getValidModels();
|
||||||
|
|
||||||
|
for (int i = 0; i < models.length; i++) {
|
||||||
|
try {
|
||||||
|
final model = GenerativeModel(model: models[i], apiKey: _apiKey);
|
||||||
|
final response = await model.generateContent([Content.text(prompt)]);
|
||||||
|
return response.text ?? fallback;
|
||||||
|
} catch (e) {
|
||||||
|
final msg = e.toString().toLowerCase();
|
||||||
|
final is429 =
|
||||||
|
msg.contains('429') ||
|
||||||
|
msg.contains('quota') ||
|
||||||
|
msg.contains('resource_exhausted');
|
||||||
|
|
||||||
|
if (!is429 || i == models.length - 1) {
|
||||||
|
// Non-quota error or last model — give up.
|
||||||
|
throw Exception('Gemini request failed: $e');
|
||||||
|
}
|
||||||
|
// Quota hit — try the next model.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here, but safety fallback.
|
||||||
|
throw Exception('All Gemini models exhausted');
|
||||||
|
}
|
||||||
|
}
|
||||||
237
lib/utils/subject_suggestions.dart
Normal file
237
lib/utils/subject_suggestions.dart
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
class SubjectSuggestionEngine {
|
||||||
|
const SubjectSuggestionEngine._();
|
||||||
|
|
||||||
|
static List<String> suggest({
|
||||||
|
required Iterable<String> existingSubjects,
|
||||||
|
required String query,
|
||||||
|
int limit = 8,
|
||||||
|
}) {
|
||||||
|
final statsByKey = <String, _SubjectStats>{};
|
||||||
|
|
||||||
|
for (final raw in existingSubjects) {
|
||||||
|
final cleaned = normalizeDisplay(raw);
|
||||||
|
if (cleaned.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final key = normalizeKey(cleaned);
|
||||||
|
if (key.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final stats = statsByKey.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => _SubjectStats(display: cleaned),
|
||||||
|
);
|
||||||
|
stats.count += 1;
|
||||||
|
if (_isBetterDisplay(cleaned, stats.display)) {
|
||||||
|
stats.display = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsByKey.isEmpty) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cleanedQuery = normalizeDisplay(query);
|
||||||
|
final queryKey = normalizeKey(cleanedQuery);
|
||||||
|
|
||||||
|
final scored =
|
||||||
|
statsByKey.entries
|
||||||
|
.map((entry) {
|
||||||
|
final value = entry.value;
|
||||||
|
final score = _score(
|
||||||
|
candidateKey: entry.key,
|
||||||
|
candidateDisplay: value.display,
|
||||||
|
count: value.count,
|
||||||
|
queryKey: queryKey,
|
||||||
|
);
|
||||||
|
return _ScoredSubject(subject: value.display, score: score);
|
||||||
|
})
|
||||||
|
.where((entry) => entry.score > 0)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final byScore = b.score.compareTo(a.score);
|
||||||
|
if (byScore != 0) {
|
||||||
|
return byScore;
|
||||||
|
}
|
||||||
|
return a.subject.toLowerCase().compareTo(b.subject.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
return scored.take(limit).map((entry) => entry.subject).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeDisplay(String input) {
|
||||||
|
final trimmed = input.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final compactWhitespace = trimmed.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
final punctuationSpacing = compactWhitespace
|
||||||
|
.replaceAll(RegExp(r'\s+([,.;:!?])'), r'$1')
|
||||||
|
.replaceAll(RegExp(r'([,.;:!?])(\S)'), r'$1 $2')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
final words = punctuationSpacing.split(' ');
|
||||||
|
final correctedWords = words.map(_correctWord).toList(growable: false);
|
||||||
|
final sentence = correctedWords.join(' ').trim();
|
||||||
|
|
||||||
|
if (sentence.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentence[0].toUpperCase() + sentence.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeKey(String input) {
|
||||||
|
final lowered = input.toLowerCase();
|
||||||
|
return lowered
|
||||||
|
.replaceAll(RegExp(r'[^a-z0-9\s]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _score({
|
||||||
|
required String candidateKey,
|
||||||
|
required String candidateDisplay,
|
||||||
|
required int count,
|
||||||
|
required String queryKey,
|
||||||
|
}) {
|
||||||
|
final popularity = math.log(count + 1) * 0.1;
|
||||||
|
|
||||||
|
if (queryKey.isEmpty) {
|
||||||
|
return 0.5 + popularity;
|
||||||
|
}
|
||||||
|
|
||||||
|
final startsWith = candidateKey.startsWith(queryKey) ? 1.2 : 0.0;
|
||||||
|
final contains =
|
||||||
|
!candidateKey.startsWith(queryKey) && candidateKey.contains(queryKey)
|
||||||
|
? 0.5
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
final vectorSimilarity = _cosineSimilarity(
|
||||||
|
_tokenVector(candidateKey),
|
||||||
|
_tokenVector(queryKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
final displayLower = candidateDisplay.toLowerCase();
|
||||||
|
final queryLower = queryKey.toLowerCase();
|
||||||
|
final editLikeBoost = displayLower.contains(queryLower) ? 0.25 : 0.0;
|
||||||
|
|
||||||
|
return (vectorSimilarity * 2.0) +
|
||||||
|
startsWith +
|
||||||
|
contains +
|
||||||
|
editLikeBoost +
|
||||||
|
popularity;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, int> _tokenVector(String input) {
|
||||||
|
final tokens = input
|
||||||
|
.split(' ')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
final vector = <String, int>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
vector[token] = (vector[token] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return vector;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _cosineSimilarity(Map<String, int> a, Map<String, int> b) {
|
||||||
|
if (a.isEmpty || b.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dot = 0.0;
|
||||||
|
var normA = 0.0;
|
||||||
|
var normB = 0.0;
|
||||||
|
|
||||||
|
for (final entry in a.entries) {
|
||||||
|
final av = entry.value.toDouble();
|
||||||
|
normA += av * av;
|
||||||
|
final bv = b[entry.key]?.toDouble() ?? 0.0;
|
||||||
|
dot += av * bv;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in b.entries) {
|
||||||
|
final bv = entry.value.toDouble();
|
||||||
|
normB += bv * bv;
|
||||||
|
}
|
||||||
|
|
||||||
|
final denominator = math.sqrt(normA) * math.sqrt(normB);
|
||||||
|
if (denominator == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dot / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _correctWord(String rawWord) {
|
||||||
|
if (rawWord.isEmpty) {
|
||||||
|
return rawWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
final punctuationMatch = RegExp(
|
||||||
|
r'^([^a-zA-Z0-9]*)(.*?)([^a-zA-Z0-9]*)$',
|
||||||
|
).firstMatch(rawWord);
|
||||||
|
if (punctuationMatch == null) {
|
||||||
|
return rawWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
final leading = punctuationMatch.group(1) ?? '';
|
||||||
|
final core = punctuationMatch.group(2) ?? '';
|
||||||
|
final trailing = punctuationMatch.group(3) ?? '';
|
||||||
|
|
||||||
|
if (core.isEmpty) {
|
||||||
|
return rawWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isAcronym = core.length > 1 && core == core.toUpperCase();
|
||||||
|
final correctedCore = isAcronym
|
||||||
|
? core
|
||||||
|
: core[0].toUpperCase() + core.substring(1).toLowerCase();
|
||||||
|
|
||||||
|
return '$leading$correctedCore$trailing';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isBetterDisplay(String candidate, String current) {
|
||||||
|
if (candidate == current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidatePenalty = _displayPenalty(candidate);
|
||||||
|
final currentPenalty = _displayPenalty(current);
|
||||||
|
if (candidatePenalty != currentPenalty) {
|
||||||
|
return candidatePenalty < currentPenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.length < current.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _displayPenalty(String value) {
|
||||||
|
var penalty = 0;
|
||||||
|
if (value.contains(RegExp(r'\s{2,}'))) {
|
||||||
|
penalty += 2;
|
||||||
|
}
|
||||||
|
if (value == value.toUpperCase()) {
|
||||||
|
penalty += 1;
|
||||||
|
}
|
||||||
|
return penalty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubjectStats {
|
||||||
|
_SubjectStats({required this.display});
|
||||||
|
|
||||||
|
String display;
|
||||||
|
int count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScoredSubject {
|
||||||
|
_ScoredSubject({required this.subject, required this.score});
|
||||||
|
|
||||||
|
final String subject;
|
||||||
|
final double score;
|
||||||
|
}
|
||||||
280
lib/widgets/gemini_animated_text_field.dart
Normal file
280
lib/widgets/gemini_animated_text_field.dart
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// How far the glow bleeds outside the child's bounds on each side.
|
||||||
|
const _kGlowSpread = 12.0;
|
||||||
|
|
||||||
|
/// Wraps any widget with a soft outward Gemini-colored glow that animates
|
||||||
|
/// while [isProcessing] is true and disappears when false.
|
||||||
|
/// The glow paints *outside* the child's bounds without affecting layout.
|
||||||
|
class GeminiAnimatedBorder extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final bool isProcessing;
|
||||||
|
|
||||||
|
/// Must match the border-radius of the wrapped widget so the glow follows its shape.
|
||||||
|
final double borderRadius;
|
||||||
|
|
||||||
|
/// When true the glow uses DeepSeek blue tones instead of Gemini colours.
|
||||||
|
final bool useDeepSeekColors;
|
||||||
|
|
||||||
|
const GeminiAnimatedBorder({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.isProcessing,
|
||||||
|
this.borderRadius = 8,
|
||||||
|
this.useDeepSeekColors = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeminiAnimatedBorder> createState() => _GeminiAnimatedBorderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeminiAnimatedBorderState extends State<GeminiAnimatedBorder>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
if (widget.isProcessing) _controller.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(GeminiAnimatedBorder old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.isProcessing && !old.isProcessing) {
|
||||||
|
_controller.repeat();
|
||||||
|
} else if (!widget.isProcessing && old.isProcessing) {
|
||||||
|
_controller.stop();
|
||||||
|
_controller.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!widget.isProcessing) return widget.child;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
child: widget.child,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
// Glow layer positioned to bleed outside the child on all sides.
|
||||||
|
Positioned(
|
||||||
|
left: -_kGlowSpread,
|
||||||
|
right: -_kGlowSpread,
|
||||||
|
top: -_kGlowSpread,
|
||||||
|
bottom: -_kGlowSpread,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _GeminiGlowPainter(
|
||||||
|
rotation: _controller.value,
|
||||||
|
borderRadius: widget.borderRadius,
|
||||||
|
glowSpread: _kGlowSpread,
|
||||||
|
deepSeekMode: widget.useDeepSeekColors,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child!,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a TextField with a rotating gradient border that animates with Gemini colors
|
||||||
|
/// when processing is active. Shows normal border appearance when not processing.
|
||||||
|
class GeminiAnimatedTextField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? labelText;
|
||||||
|
final int? maxLines;
|
||||||
|
final bool enabled;
|
||||||
|
final bool isProcessing;
|
||||||
|
final InputDecoration? decoration;
|
||||||
|
|
||||||
|
/// When true the glow uses DeepSeek blue tones instead of Gemini colours.
|
||||||
|
final bool useDeepSeekColors;
|
||||||
|
|
||||||
|
const GeminiAnimatedTextField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.labelText,
|
||||||
|
this.maxLines,
|
||||||
|
this.enabled = true,
|
||||||
|
this.isProcessing = false,
|
||||||
|
this.decoration,
|
||||||
|
this.useDeepSeekColors = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeminiAnimatedTextField> createState() =>
|
||||||
|
_GeminiAnimatedTextFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeminiAnimatedTextFieldState extends State<GeminiAnimatedTextField>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
if (widget.isProcessing) _controller.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(GeminiAnimatedTextField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.isProcessing && !oldWidget.isProcessing) {
|
||||||
|
_controller.repeat();
|
||||||
|
} else if (!widget.isProcessing && oldWidget.isProcessing) {
|
||||||
|
_controller.stop();
|
||||||
|
_controller.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final field = TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
enabled: widget.enabled && !widget.isProcessing,
|
||||||
|
maxLines: widget.maxLines,
|
||||||
|
decoration:
|
||||||
|
widget.decoration ?? InputDecoration(labelText: widget.labelText),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!widget.isProcessing) return field;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
child: field,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: -_kGlowSpread,
|
||||||
|
right: -_kGlowSpread,
|
||||||
|
top: -_kGlowSpread,
|
||||||
|
bottom: -_kGlowSpread,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _GeminiGlowPainter(
|
||||||
|
rotation: _controller.value,
|
||||||
|
borderRadius: 4,
|
||||||
|
glowSpread: _kGlowSpread,
|
||||||
|
deepSeekMode: widget.useDeepSeekColors,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child!,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints a soft outward glow using layered blurred strokes.
|
||||||
|
/// The [size] passed to [paint] is LARGER than the child by [glowSpread] on
|
||||||
|
/// every side, so the rrect is inset by [glowSpread] to sit exactly on the
|
||||||
|
/// child's border, and the blur bleeds outward.
|
||||||
|
class _GeminiGlowPainter extends CustomPainter {
|
||||||
|
final double rotation;
|
||||||
|
final double borderRadius;
|
||||||
|
final double glowSpread;
|
||||||
|
final bool deepSeekMode;
|
||||||
|
|
||||||
|
// Gemini brand colors — closed loop for a seamless sweep.
|
||||||
|
static const _geminiColors = [
|
||||||
|
Color(0xFF4285F4), // Blue
|
||||||
|
Color(0xFFEA4335), // Red
|
||||||
|
Color(0xFFFBBC04), // Yellow
|
||||||
|
Color(0xFF34A853), // Green
|
||||||
|
Color(0xFF4285F4), // Blue (close loop)
|
||||||
|
];
|
||||||
|
static const _geminiStops = [0.0, 0.25, 0.5, 0.75, 1.0];
|
||||||
|
|
||||||
|
// DeepSeek brand colors — pure blue closed loop.
|
||||||
|
static const _deepSeekColors = [
|
||||||
|
Color(0xFF4D9BFF), // Sky blue
|
||||||
|
Color(0xFF1A56DB), // Deep blue
|
||||||
|
Color(0xFF00CFFF), // Cyan
|
||||||
|
Color(0xFF4D9BFF), // Sky blue (close loop)
|
||||||
|
];
|
||||||
|
static const _deepSeekStops = [0.0, 0.33, 0.66, 1.0];
|
||||||
|
|
||||||
|
const _GeminiGlowPainter({
|
||||||
|
required this.rotation,
|
||||||
|
required this.borderRadius,
|
||||||
|
required this.glowSpread,
|
||||||
|
this.deepSeekMode = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
// The rect that coincides with the actual child's outline.
|
||||||
|
final childRect = Rect.fromLTWH(
|
||||||
|
glowSpread,
|
||||||
|
glowSpread,
|
||||||
|
size.width - glowSpread * 2,
|
||||||
|
size.height - glowSpread * 2,
|
||||||
|
);
|
||||||
|
final rrect = RRect.fromRectAndRadius(
|
||||||
|
childRect,
|
||||||
|
Radius.circular(borderRadius),
|
||||||
|
);
|
||||||
|
|
||||||
|
final colors = deepSeekMode ? _deepSeekColors : _geminiColors;
|
||||||
|
final stops = deepSeekMode ? _deepSeekStops : _geminiStops;
|
||||||
|
final shader = SweepGradient(
|
||||||
|
colors: colors,
|
||||||
|
stops: stops,
|
||||||
|
transform: GradientRotation(rotation * 2 * math.pi),
|
||||||
|
).createShader(childRect);
|
||||||
|
|
||||||
|
// Outer glow — wide stroke + strong blur spreads the color outward.
|
||||||
|
canvas.drawRRect(
|
||||||
|
rrect,
|
||||||
|
Paint()
|
||||||
|
..shader = shader
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = glowSpread * 1.6
|
||||||
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inner glow — narrower, less blurred for a crisper halo near the border.
|
||||||
|
canvas.drawRRect(
|
||||||
|
rrect,
|
||||||
|
Paint()
|
||||||
|
..shader = shader
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = glowSpread * 0.7
|
||||||
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread * 0.4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_GeminiGlowPainter old) =>
|
||||||
|
old.rotation != rotation || old.deepSeekMode != deepSeekMode;
|
||||||
|
}
|
||||||
120
lib/widgets/gemini_button.dart
Normal file
120
lib/widgets/gemini_button.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/ai_service.dart';
|
||||||
|
|
||||||
|
typedef TextUpdateCallback = void Function(String updatedText);
|
||||||
|
typedef ProcessingStateCallback = void Function(bool isProcessing);
|
||||||
|
|
||||||
|
/// An AI icon button that immediately enhances the text in [textController]
|
||||||
|
/// when pressed — no dialog, no language detection.
|
||||||
|
///
|
||||||
|
/// Provide [promptBuilder] to give the AI field-specific context. It receives
|
||||||
|
/// the current (trimmed) text and must return the prompt instruction string.
|
||||||
|
class GeminiButton extends StatefulWidget {
|
||||||
|
final TextEditingController textController;
|
||||||
|
final TextUpdateCallback onTextUpdated;
|
||||||
|
final ProcessingStateCallback? onProcessingStateChanged;
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
|
/// Optional callback that builds the Gemini prompt instruction from the
|
||||||
|
/// current field text (called at press time, so captures live context).
|
||||||
|
final String Function(String text)? promptBuilder;
|
||||||
|
|
||||||
|
/// Called when the active AI provider changes.
|
||||||
|
/// [isDeepSeek] is true once Gemini fails and DeepSeek takes over.
|
||||||
|
/// Called with false again when processing finishes.
|
||||||
|
final void Function(bool isDeepSeek)? onProviderChanged;
|
||||||
|
|
||||||
|
const GeminiButton({
|
||||||
|
super.key,
|
||||||
|
required this.textController,
|
||||||
|
required this.onTextUpdated,
|
||||||
|
this.onProcessingStateChanged,
|
||||||
|
this.tooltip,
|
||||||
|
this.promptBuilder,
|
||||||
|
this.onProviderChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeminiButton> createState() => _GeminiButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeminiButtonState extends State<GeminiButton> {
|
||||||
|
bool _isProcessing = false;
|
||||||
|
late final AiService _aiService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_aiService = AiService();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enhance(BuildContext context) async {
|
||||||
|
final text = widget.textController.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please enter some text first')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isProcessing = true;
|
||||||
|
});
|
||||||
|
widget.onProcessingStateChanged?.call(true);
|
||||||
|
widget.onProviderChanged?.call(false);
|
||||||
|
try {
|
||||||
|
final instruction = widget.promptBuilder?.call(text);
|
||||||
|
final improvedText = await _aiService.enhanceText(
|
||||||
|
text,
|
||||||
|
promptInstruction: instruction,
|
||||||
|
onFallbackToDeepSeek: () {
|
||||||
|
if (mounted) {
|
||||||
|
widget.onProviderChanged?.call(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final trimmed = improvedText.trim();
|
||||||
|
widget.textController.text = trimmed;
|
||||||
|
widget.onTextUpdated(trimmed);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Text improved successfully')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isProcessing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
widget.onProcessingStateChanged?.call(false);
|
||||||
|
widget.onProviderChanged?.call(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: widget.tooltip ?? 'Improve with Gemini',
|
||||||
|
icon: _isProcessing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
'assets/gemini_icon.png',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
const Icon(Icons.auto_awesome),
|
||||||
|
),
|
||||||
|
onPressed: _isProcessing ? null : () => _enhance(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
pubspec.lock
10
pubspec.lock
|
|
@ -709,6 +709,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "6.3.3"
|
||||||
|
google_generative_ai:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_generative_ai
|
||||||
|
sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.7"
|
||||||
gotrue:
|
gotrue:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -742,7 +750,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ dependencies:
|
||||||
uuid: ^4.1.0
|
uuid: ^4.1.0
|
||||||
skeletonizer: ^2.1.3
|
skeletonizer: ^2.1.3
|
||||||
fl_chart: ^0.70.2
|
fl_chart: ^0.70.2
|
||||||
|
google_generative_ai: ^0.4.0
|
||||||
|
http: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user