1234 lines
47 KiB
Dart
1234 lines
47 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../models/user_model.dart';
|
|
import '../models/recommendation_model.dart';
|
|
import '../models/daily_question_model.dart';
|
|
import '../services/ocr_service.dart';
|
|
import '../core/config/app_config.dart';
|
|
import 'dart:convert';
|
|
import '../services/firebase_service.dart';
|
|
|
|
class GeminiService {
|
|
static final GeminiService _instance = GeminiService._internal();
|
|
factory GeminiService() => _instance;
|
|
GeminiService._internal();
|
|
|
|
late final GenerativeModel _model;
|
|
late final GenerativeModel _chatModel;
|
|
|
|
bool _isInitialized = false;
|
|
String? _currentApiKey;
|
|
static const String _apiKeyStorageKey = 'user_gemini_api_key';
|
|
|
|
// Load saved user API key
|
|
Future<String?> _loadSavedApiKey() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString(_apiKeyStorageKey);
|
|
} catch (e) {
|
|
debugPrint('Error loading saved API key: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Save user API key
|
|
Future<void> _saveApiKey(String apiKey) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_apiKeyStorageKey, apiKey);
|
|
} catch (e) {
|
|
debugPrint('Error saving API key: $e');
|
|
}
|
|
}
|
|
|
|
// Remove saved API key
|
|
Future<void> _removeSavedApiKey() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_apiKeyStorageKey);
|
|
} catch (e) {
|
|
debugPrint('Error removing saved API key: $e');
|
|
}
|
|
}
|
|
|
|
// Initialize Gemini service with user's API key or fallback
|
|
Future<void> initialize({String? userApiKey}) async {
|
|
try {
|
|
// Priority: userApiKey parameter > saved user API key > app default API key
|
|
String? apiKey =
|
|
userApiKey ?? await _loadSavedApiKey() ?? AppConfig.geminiApiKey;
|
|
_currentApiKey = apiKey;
|
|
|
|
if (apiKey.isEmpty) {
|
|
throw Exception(
|
|
'No Gemini API key available. Please provide your own API key.',
|
|
);
|
|
}
|
|
|
|
debugPrint('🔄 Initializing with Gemini 2.0 Flash...');
|
|
|
|
_model = GenerativeModel(
|
|
model: 'gemini-3-flash-preview',
|
|
apiKey: apiKey,
|
|
generationConfig: GenerationConfig(
|
|
temperature: AppConfig.aiTemperature,
|
|
topK: 64,
|
|
topP: 0.95,
|
|
maxOutputTokens: AppConfig.maxTokensPerRequest,
|
|
),
|
|
safetySettings: [
|
|
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.medium),
|
|
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.medium),
|
|
SafetySetting(
|
|
HarmCategory.sexuallyExplicit,
|
|
HarmBlockThreshold.medium,
|
|
),
|
|
SafetySetting(
|
|
HarmCategory.dangerousContent,
|
|
HarmBlockThreshold.medium,
|
|
),
|
|
],
|
|
);
|
|
|
|
_chatModel = GenerativeModel(
|
|
model: 'gemini-3-flash-preview',
|
|
apiKey: apiKey,
|
|
generationConfig: GenerationConfig(
|
|
temperature: 0.8,
|
|
topK: 40,
|
|
topP: 0.95,
|
|
maxOutputTokens: 2048,
|
|
),
|
|
tools: [
|
|
Tool(
|
|
functionDeclarations: [
|
|
FunctionDeclaration(
|
|
'toggle_menu_favorite',
|
|
'Tambahkan atau hapus item menu dari daftar favorit berdasarkan namanya.',
|
|
Schema.object(
|
|
properties: {
|
|
'menuName': Schema.string(
|
|
description:
|
|
'Nama menu yang ingin diubah status favoritnya.',
|
|
),
|
|
'isFavorite': Schema.boolean(
|
|
description:
|
|
'Status favorit baru (true untuk favorit, false untuk tidak).',
|
|
),
|
|
},
|
|
requiredProperties: ['menuName', 'isFavorite'],
|
|
),
|
|
),
|
|
FunctionDeclaration(
|
|
'recommend_specific_drink',
|
|
'Memberikan rekomendasi minuman spesifik berdasarkan mood atau cuaca.',
|
|
Schema.object(
|
|
properties: {
|
|
'drinkType': Schema.string(
|
|
description: 'Jenis minuman (kopi, teh, non-kopi).',
|
|
),
|
|
'isIce': Schema.boolean(
|
|
description: 'Apakah harus disajikan dingin.',
|
|
),
|
|
},
|
|
requiredProperties: ['drinkType'],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
|
|
_isInitialized = true;
|
|
debugPrint('✅ Gemini 3 Flash Preview initialized successfully');
|
|
} catch (e) {
|
|
debugPrint('❌ Gemini initialization error: $e');
|
|
throw Exception('Failed to initialize Gemini service: $e');
|
|
}
|
|
}
|
|
|
|
/// Standalone chat tentang menu Amor Coffee — digunakan oleh AmorChatbotScreen.
|
|
/// Tidak memerlukan RecommendationModel, menggunakan _chatModel yang sudah terinisialisasi.
|
|
Future<String> chatWithMenu({
|
|
required String message,
|
|
required String menuContext,
|
|
List<Map<String, String>> history = const [],
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
throw Exception('Gemini service not initialized');
|
|
}
|
|
|
|
// Bangun riwayat percakapan agar AI ingat konteks sebelumnya
|
|
final historyContent = history.map((h) {
|
|
if (h['role'] == 'user') {
|
|
return Content.text(h['text'] ?? '');
|
|
} else {
|
|
return Content.model([TextPart(h['text'] ?? '')]);
|
|
}
|
|
}).toList();
|
|
|
|
final systemPrompt = '''
|
|
Kamu adalah Amori, asisten virtual yang ramah dan ahli tentang kafe Amor Coffee.
|
|
Tugasmu adalah membantu pelanggan mengetahui informasi seputar menu Amor Coffee.
|
|
|
|
Berikut adalah daftar menu lengkap Amor Coffee yang kamu ketahui:
|
|
$menuContext
|
|
|
|
PANDUAN MENJAWAB:
|
|
1. Jawab HANYA pertanyaan yang berkaitan dengan menu, harga, rekomendasi, atau hal umum tentang Amor Coffee.
|
|
2. Jika ditanya soal rekomendasi, tanyakan dulu preferensi (suka manis/gurih? mau makan/minum? budget berapa?) sebelum merekomendasikan.
|
|
3. Gunakan bahasa Indonesia yang ramah, kasual, dan bersahabat. Boleh pakai emoji sesekali.
|
|
4. Jika ada pertanyaan di luar konteks menu Amor Coffee, jawab dengan sopan bahwa kamu hanya bisa membantu seputar menu Amor Coffee.
|
|
5. Jika ada yang bertanya tentang bundling/paket, sarankan kombinasi makanan + minuman yang cocok beserta total harganya.
|
|
6. Selalu sebutkan harga dengan format "Rp XX.000".
|
|
|
|
Pertanyaan dari pelanggan: $message''';
|
|
|
|
try {
|
|
final chat = _chatModel.startChat(history: historyContent);
|
|
final response = await chat.sendMessage(Content.text(systemPrompt));
|
|
if (response.text == null || response.text!.isEmpty) {
|
|
throw Exception('Empty response from AI');
|
|
}
|
|
return response.text!;
|
|
} catch (e) {
|
|
debugPrint('chatWithMenu error: $e');
|
|
throw Exception('Failed to get AI response: $e');
|
|
}
|
|
}
|
|
|
|
|
|
Future<RecommendationModel> generateRecommendations({
|
|
required List<MenuItemOCR> menuItems,
|
|
required UserPreferences userPreferences,
|
|
required String userId,
|
|
String? menuImageUrl,
|
|
String? originalMenuText,
|
|
UserModel? userModel,
|
|
List<String>? menuImagePaths,
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
throw Exception('Gemini service not initialized');
|
|
}
|
|
|
|
try {
|
|
final prompt = _buildRecommendationPrompt(
|
|
menuItems,
|
|
userPreferences,
|
|
userModel,
|
|
originalMenuText,
|
|
);
|
|
|
|
// --- CACHE CHECK (KONSISTENSI HASIL SCAN) ---
|
|
// Jika menu yang sama discan pada hari yang sama dengan mood/cuaca/budget yang sama persis,
|
|
// maka berikan rekomendasi yang sama tanpa memanggil AI.
|
|
try {
|
|
final firebaseService = FirebaseService();
|
|
final history = await firebaseService.getUserRecommendations(userId, limit: 10);
|
|
final now = DateTime.now();
|
|
for (final rec in history) {
|
|
if (rec.createdAt.year == now.year &&
|
|
rec.createdAt.month == now.month &&
|
|
rec.createdAt.day == now.day) {
|
|
|
|
// Skip cache jika rekomendasi lama adalah fallback
|
|
final isFallback = rec.recommendations.any(
|
|
(r) => r.tags.contains('fallback') || r.menuName == 'Menu Rekomendasi',
|
|
);
|
|
if (isFallback) {
|
|
debugPrint('⏭️ CACHE SKIP: Rekomendasi sebelumnya adalah fallback, generate ulang.');
|
|
continue;
|
|
}
|
|
|
|
if (rec.prompt == prompt) {
|
|
debugPrint('🔄 CACHE HIT: Menu dan preferensi harian sama, menggunakan riwayat sebelumnya.');
|
|
return RecommendationModel(
|
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
userId: rec.userId,
|
|
recommendations: rec.recommendations,
|
|
originalMenuText: rec.originalMenuText,
|
|
menuImageUrl: rec.menuImageUrl,
|
|
menuImagePaths: rec.menuImagePaths,
|
|
prompt: rec.prompt,
|
|
aiResponse: rec.aiResponse,
|
|
confidence: rec.confidence,
|
|
createdAt: DateTime.now(),
|
|
isFavorite: false,
|
|
sessionId: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
selectedMenuName: null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error checking recommendation cache: $e');
|
|
}
|
|
// ---------------------------------------------
|
|
|
|
debugPrint('=== GEMINI GENERATE ===');
|
|
debugPrint('menuItems count: ${menuItems.length}');
|
|
debugPrint('originalMenuText length: ${originalMenuText?.length ?? 0}');
|
|
debugPrint('prompt length: ${prompt.length} chars');
|
|
|
|
final response = await _model.generateContent([Content.text(prompt)]);
|
|
|
|
if (response.text == null || response.text!.isEmpty) {
|
|
throw Exception('Empty response from Gemini');
|
|
}
|
|
|
|
debugPrint('AI response length: ${response.text!.length} chars');
|
|
debugPrint('AI response preview: ${response.text!.substring(0, response.text!.length.clamp(0, 300))}');
|
|
Map<String, dynamic> jsonResponse;
|
|
try {
|
|
String responseText = response.text!.trim();
|
|
|
|
// 1. Hapus markdown code block jika ada
|
|
if (responseText.contains('```')) {
|
|
responseText = responseText
|
|
.replaceAll(RegExp(r'```json\s*'), '')
|
|
.replaceAll(RegExp(r'```\s*'), '')
|
|
.trim();
|
|
}
|
|
|
|
// 2. Coba parse langsung
|
|
try {
|
|
jsonResponse = json.decode(responseText);
|
|
debugPrint('✅ Direct JSON parse OK');
|
|
} catch (_) {
|
|
// 3. Coba ekstrak dari blok { ... } terluar
|
|
final int jsonStart = responseText.indexOf('{');
|
|
final int jsonEnd = responseText.lastIndexOf('}') + 1;
|
|
|
|
if (jsonStart != -1 && jsonEnd > jsonStart) {
|
|
try {
|
|
jsonResponse = json.decode(responseText.substring(jsonStart, jsonEnd));
|
|
debugPrint('✅ Extracted JSON block OK');
|
|
} catch (_) {
|
|
// 4. JSON terpotong → ekstrak objek rekomendasi yang SUDAH LENGKAP saja
|
|
debugPrint('⚠️ JSON truncated — extracting complete objects...');
|
|
jsonResponse = _extractPartialRecommendations(responseText);
|
|
}
|
|
} else {
|
|
jsonResponse = _extractPartialRecommendations(responseText);
|
|
}
|
|
}
|
|
|
|
debugPrint('✅ Parsed ${(jsonResponse['recommendations'] as List?)?.length ?? 0} recommendation(s)');
|
|
} catch (e) {
|
|
debugPrint('❌ JSON parsing fatal error: $e');
|
|
debugPrint('❌ Full raw response: ${response.text}');
|
|
jsonResponse = _buildFallbackJson();
|
|
}
|
|
|
|
|
|
final recommendations = _parseRecommendations(jsonResponse, menuItems);
|
|
|
|
// Create recommendation model
|
|
final recommendationModel = RecommendationModel(
|
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
userId: userId,
|
|
recommendations: recommendations,
|
|
originalMenuText: originalMenuText ?? _menuItemsToText(menuItems),
|
|
menuImageUrl: menuImageUrl,
|
|
menuImagePaths: menuImagePaths,
|
|
prompt: prompt,
|
|
aiResponse: response.text!,
|
|
confidence: _calculateRecommendationConfidence(recommendations),
|
|
createdAt: DateTime.now(),
|
|
sessionId: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
);
|
|
|
|
return recommendationModel;
|
|
} catch (e) {
|
|
debugPrint('Generate recommendations error: $e');
|
|
throw Exception('Failed to generate recommendations: $e');
|
|
}
|
|
}
|
|
|
|
// Build recommendation prompt
|
|
String _buildRecommendationPrompt(
|
|
List<MenuItemOCR> menuItems,
|
|
UserPreferences prefs,
|
|
UserModel? userModel,
|
|
String? originalMenuText,
|
|
) {
|
|
final daily = userModel?.dailyPreferences;
|
|
|
|
// ── Profil ───────────────────────────────────────────────────────────────
|
|
final nickname =
|
|
userModel?.nickname ?? userModel?.displayName ?? 'Pelanggan';
|
|
final gender = userModel?.gender ?? '-';
|
|
final ageRange = prefs.ageRange ?? '-';
|
|
|
|
// ── Preferensi Profil (setup awal, bersifat permanen) ────────────────────
|
|
final allergies = prefs.allergies.isEmpty
|
|
? 'Tidak ada'
|
|
: prefs.allergies.join(', ');
|
|
final dietaryRestriction = prefs.dietaryRestriction;
|
|
final spiceLevel = _translateSpiceLevel(prefs.spiceLevel);
|
|
final favCategories = prefs.favoriteCategories.isEmpty
|
|
? 'Tidak ditentukan'
|
|
: prefs.favoriteCategories.join(', ');
|
|
|
|
// ── Preferensi Harian (check-in hari ini — PRIORITAS UTAMA) ─────────────
|
|
final rawBudget = daily?['budget'];
|
|
final int budget = rawBudget is int
|
|
? rawBudget
|
|
: rawBudget is double
|
|
? rawBudget.toInt()
|
|
: prefs.budgetRange;
|
|
|
|
final String mood = (daily?['mood'] as String?)?.isNotEmpty == true
|
|
? daily!['mood'] as String
|
|
: prefs.mood;
|
|
final String cuaca = (daily?['cuaca'] as String?) ?? '-';
|
|
final String suasana = (daily?['suasana'] as String?) ?? '-';
|
|
final String waktu = (daily?['waktu'] as String?) ?? '-';
|
|
|
|
final String keinginan = (daily?['keinginan'] as String?) ?? '';
|
|
String keinginanLabel = favCategories;
|
|
if (keinginan == 'minuman') keinginanLabel = 'Minuman saja';
|
|
if (keinginan == 'makanan') keinginanLabel = 'Makanan saja';
|
|
if (keinginan == 'keduanya') keinginanLabel = 'Minuman & Makanan';
|
|
|
|
final String budgetStr = budget.toString();
|
|
final nameRef = nickname;
|
|
|
|
|
|
// ── Terjemahkan kondisi ke aturan konkret untuk AI ───────────────────────
|
|
final lowerKeinginan = keinginanLabel.toLowerCase();
|
|
|
|
// Perbaikan Logika Deteksi Preferensi (Strict Mode)
|
|
final bool isExplicitlyDrinks = lowerKeinginan.contains('minuman') || lowerKeinginan.contains('kopi') || lowerKeinginan.contains('teh') || lowerKeinginan.contains('jus');
|
|
final bool isExplicitlyFood = lowerKeinginan.contains('makanan') || lowerKeinginan.contains('snack') || lowerKeinginan.contains('dessert') || lowerKeinginan.contains('pastry') || lowerKeinginan.contains('kue') || lowerKeinginan.contains('sarapan') || lowerKeinginan.contains('cemilan');
|
|
|
|
// Jika user explicitly memilih "keduanya" (misal dari daily check-in)
|
|
final bool isBoth = lowerKeinginan.contains('keduanya') || lowerKeinginan.contains('minuman & makanan');
|
|
|
|
bool wantsDrinks = true;
|
|
bool wantsFood = true;
|
|
|
|
if (isBoth) {
|
|
wantsDrinks = true;
|
|
wantsFood = true;
|
|
} else if (isExplicitlyDrinks && !isExplicitlyFood) {
|
|
wantsDrinks = true;
|
|
wantsFood = false;
|
|
} else if (isExplicitlyFood && !isExplicitlyDrinks) {
|
|
wantsDrinks = false;
|
|
wantsFood = true;
|
|
} else if (isExplicitlyDrinks && isExplicitlyFood) {
|
|
wantsDrinks = true;
|
|
wantsFood = true;
|
|
} else {
|
|
// Jika label hanya berisi tag generik seperti "Vegetarian" atau "Pedas"
|
|
// Kita asumsikan user bisa makan apa saja (keduanya)
|
|
wantsDrinks = true;
|
|
wantsFood = true;
|
|
}
|
|
|
|
// HARD FILTER: Sembunyikan item yang tidak sesuai preferensi dari mata AI
|
|
// Kirim seluruh raw text ke AI — jangan filter per baris karena bisa salah
|
|
// (contoh: "Roti Kopi" mengandung "kopi" tapi bukan minuman).
|
|
// Pembatasan kategori diserahkan ke instruksi prompt yang sudah ketat.
|
|
final String menuText = (originalMenuText != null && originalMenuText.trim().isNotEmpty)
|
|
? originalMenuText
|
|
: _menuItemsToText(menuItems);
|
|
|
|
|
|
final String waktuAturan = _buildWaktuRule(waktu, wantsDrinks, wantsFood);
|
|
final String cuacaAturan = _buildCuacaRule(cuaca, wantsDrinks, wantsFood);
|
|
final String moodAturan = _buildMoodRule(mood, wantsDrinks, wantsFood);
|
|
final String keinginanAturan = _buildKeinginanRule(keinginanLabel, wantsDrinks, wantsFood);
|
|
|
|
return 'Kamu adalah Amori, AI rekomendasi menu untuk kafe Amor Coffee.\n\n'
|
|
'MENU YANG TERSEDIA:\n$menuText\n\n'
|
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
|
|
'DATA PELANGGAN (gunakan SEMUA informasi ini untuk rekomendasi personal)\n'
|
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
|
|
'📋 PROFIL:\n'
|
|
'- Nama: $nameRef\n'
|
|
'- Gender: $gender\n'
|
|
'- Usia: $ageRange\n\n'
|
|
'🔒 PREFERENSI TETAP (dari setup profil — bersifat permanen):\n'
|
|
'- Alergi: $allergies ← WAJIB DIHINDARI\n'
|
|
'- Pembatasan diet: $dietaryRestriction\n'
|
|
'- Level pedas: $spiceLevel\n'
|
|
'- Kategori favorit: $favCategories\n\n'
|
|
'☀️ KONDISI HARI INI (check-in harian — PRIORITAS TERTINGGI):\n'
|
|
'- Mood saat ini: $mood\n'
|
|
'- Cuaca hari ini: $cuaca\n'
|
|
'- Situasi / suasana: $suasana\n'
|
|
'- Yang diinginkan: $keinginanLabel\n'
|
|
'- Waktu kunjungan: $waktu\n'
|
|
'- ⚠️ BUDGET HARI INI: Rp $budgetStr (WAJIB DIPATUHI)\n\n'
|
|
'🚨 ATURAN KERAS — HARUS DIPATUHI TANPA PENGECUALIAN:\n\n'
|
|
'1. KATEGORI UTAMA "$keinginanLabel" — terapkan aturan ini SEBELUM yang lain:\n'
|
|
'$keinginanAturan\n\n'
|
|
'2. BUDGET: JANGAN rekomendasikan menu lebih mahal dari Rp $budgetStr\n'
|
|
'3. ALERGI: JANGAN rekomendasikan menu yang mengandung: $allergies\n'
|
|
'4. DIET: ${_buildDietRule(dietaryRestriction)}\n'
|
|
'5. PEDAS: Pilih menu sesuai level pedas "$spiceLevel"\n\n'
|
|
'6. WAKTU KUNJUNGAN "$waktu" — terapkan aturan berikut:\n'
|
|
'$waktuAturan\n\n'
|
|
'7. CUACA "$cuaca" — terapkan aturan berikut:\n'
|
|
'$cuacaAturan\n\n'
|
|
'8. MOOD "$mood" — terapkan aturan berikut:\n'
|
|
'$moodAturan\n\n'
|
|
'TUGAS — BUAT TEPAT 3 REKOMENDASI TERSTRUKTUR:\n'
|
|
'Pilih menu HANYA DARI "MENU YANG TERSEDIA" di atas. DILARANG MENGARANG.\n\n'
|
|
'${_buildSlotInstruction(keinginanLabel, wantsDrinks, wantsFood)}\n\n'
|
|
'Untuk setiap slot, tulis JSON SINGKAT:\n'
|
|
'1. menuName: nama PERSIS dari daftar menu\n'
|
|
'2. description: 5-8 kata saja\n'
|
|
'3. price: harga numerik\n'
|
|
'4. reason: TEPAT 1 kalimat singkat — sebutkan 1 alasan utama cocok untuk $nameRef ($mood, $cuaca, $waktu)\n'
|
|
'5. score: 0.0-1.0\n'
|
|
'6. tags: maksimal 2 tag singkat\n'
|
|
'7. category: kategori\n'
|
|
'8. slotType: "minuman", "makanan", atau "bundling"\n'
|
|
'9. isRecommended: true\n\n'
|
|
'HANYA JSON. TIDAK ADA TEKS LAIN. OUTPUT HARUS SELESAI LENGKAP.\n\n'
|
|
'FORMAT RESPONSE (JSON ONLY — TEPAT 3 ITEM):\n'
|
|
'{\n'
|
|
' "recommendations": [\n'
|
|
' {\n'
|
|
' "menuName": "nama menu exact",\n'
|
|
' "description": "deskripsi singkat",\n'
|
|
' "price": harga_numerik,\n'
|
|
' "reason": "alasan personal",\n'
|
|
' "score": 0.9,\n'
|
|
' "tags": ["tag1", "tag2"],\n'
|
|
' "category": "kategori",\n'
|
|
' "slotType": "makanan/minuman/bundling",\n'
|
|
' "isRecommended": true\n'
|
|
' }\n'
|
|
' ],\n'
|
|
' "summary": "ringkasan rekomendasi",\n'
|
|
' "additionalNotes": "catatan tambahan"\n'
|
|
'}\n\n'
|
|
'HANYA BERIKAN JSON, TIDAK ADA TEXT LAIN!';
|
|
}
|
|
|
|
// ── Helper: instruksi struktur slot 3 rekomendasi ─────────────────────────
|
|
String _buildSlotInstruction(String label, bool wantsDrinks, bool wantsFood) {
|
|
if (wantsDrinks && !wantsFood) {
|
|
return 'STRUKTUR 3 SLOT REKOMENDASI (MINUMAN SAJA):\n'
|
|
' Slot 1 — Pilihan Terbaik : 1 MINUMAN terbaik sesuai kondisi\n'
|
|
' Slot 2 — Pilihan Alternatif: 1 MINUMAN berbeda sebagai alternatif\n'
|
|
' Slot 3 — Pilihan Hemat : 1 MINUMAN dengan harga paling terjangkau\n'
|
|
' ⛔ DILARANG memasukkan makanan/cemilan di slot manapun.';
|
|
} else if (wantsFood && !wantsDrinks) {
|
|
return 'STRUKTUR 3 SLOT REKOMENDASI (MAKANAN SAJA):\n'
|
|
' Slot 1 — Pilihan Terbaik : 1 MAKANAN terbaik sesuai kondisi\n'
|
|
' Slot 2 — Pilihan Alternatif: 1 MAKANAN berbeda sebagai alternatif\n'
|
|
' Slot 3 — Pilihan Hemat : 1 MAKANAN dengan harga paling terjangkau\n'
|
|
' ⛔ DILARANG memasukkan minuman di slot manapun.';
|
|
} else {
|
|
return 'STRUKTUR 3 SLOT REKOMENDASI (BUNDLING MAKANAN & MINUMAN):\n'
|
|
' Slot 1 — Paket Utama: 1 MAKANAN + 1 MINUMAN terbaik (Tulis di menuName: "Paket Utama (Nama Makanan & Nama Minuman)")\n'
|
|
' Slot 2 — Kombinasi Alternatif: 1 MAKANAN + 1 MINUMAN lain (Tulis di menuName: "Kombinasi Alternatif (Nama Makanan & Nama Minuman)")\n'
|
|
' Slot 3 — Paket Hemat: 1 MAKANAN + 1 MINUMAN termurah (Tulis di menuName: "Paket Hemat (Nama Makanan & Nama Minuman)")\n'
|
|
' ⛔ SANGAT DILARANG menggabungkan 2 minuman (Contoh Salah: Kopi & Teh).\n'
|
|
' ⛔ SANGAT DILARANG menggabungkan 2 makanan (Contoh Salah: Roti & Nasi).\n'
|
|
' ✅ HARUS 1 Makanan/Snack/Cemilan + 1 Minuman di setiap slot.\n'
|
|
' ✅ FIELD price WAJIB BERISI PENJUMLAHAN TOTAL HARGA (Harga Makanan + Harga Minuman). Contoh: Jika Makanan 12000 dan Minuman 14000, isi price dengan 26000.\n'
|
|
' ✅ KHUSUS BUNDLING: WAJIB tulis rincian harga di field description dengan format seperti: "(Makanan: Rp X, Minuman: Rp Y, Total: Rp Z)".\n'
|
|
' ✅ WAJIB set slotType dengan "bundling".';
|
|
}
|
|
}
|
|
|
|
// ── Helper: terjemahkan keinginan ke instruksi konkret ───────────────────────
|
|
String _buildKeinginanRule(String keinginanLabel, bool wantsDrinks, bool wantsFood) {
|
|
if (wantsDrinks && !wantsFood) {
|
|
return ' - WAJIB HANYA merekomendasikan MINUMAN (seperti Kopi, Teh, Jus, Susu, dll). SANGAT DILARANG merekomendasikan makanan, camilan, atau dessert apa pun.';
|
|
} else if (wantsFood && !wantsDrinks) {
|
|
return ' - WAJIB HANYA merekomendasikan MAKANAN (Makanan Utama, Snack, Dessert, Pastry). SANGAT DILARANG merekomendasikan minuman apa pun.';
|
|
} else if (wantsDrinks && wantsFood) {
|
|
return ' - WAJIB buat 3 BUNDLING (Paket). Setiap slot harus berisi gabungan PASTI 1 MAKANAN (Roti/Nasi/Kue/Snack) dan 1 MINUMAN (Kopi/Teh/Jus/Susu) yang serasi. JANGAN PERNAH MENGGABUNGKAN 2 MINUMAN.';
|
|
} else {
|
|
return ' - Prioritaskan kategori berikut jika tersedia di menu: $keinginanLabel';
|
|
}
|
|
}
|
|
|
|
// ── Helper: terjemahkan waktu ke instruksi konkret ───────────────────────
|
|
String _buildWaktuRule(String waktu, bool wantsDrinks, bool wantsFood) {
|
|
switch (waktu.toLowerCase().trim()) {
|
|
case 'pagi':
|
|
final recs = [
|
|
if (wantsDrinks) 'kopi panas, teh hangat',
|
|
if (wantsFood) 'sarapan, roti, makanan ringan'
|
|
].join(', ');
|
|
return ' - PRIORITASKAN: $recs\n - HINDARI: makanan terlalu berat atau pedas berlebihan';
|
|
case 'siang':
|
|
final recs = [
|
|
if (wantsDrinks) 'minuman dingin (iced coffee, iced tea, cold brew, smoothie, jus)',
|
|
if (wantsFood) 'makanan utama segar'
|
|
].join(', ');
|
|
return ' - PRIORITASKAN: $recs\n - HINDARI: sup panas, minuman panas (karena cuaca umumnya terik)';
|
|
case 'sore':
|
|
final recs = [
|
|
if (wantsFood) 'camilan sore (pastry, kue, snack)',
|
|
if (wantsDrinks) 'kopi sore, teh santai'
|
|
].join(', ');
|
|
return ' - PRIORITASKAN: $recs\n - Sore hari cocok untuk bersantai';
|
|
case 'malam':
|
|
final recs = [
|
|
if (wantsDrinks) 'minuman hangat (kopi panas, teh panas, coklat panas)',
|
|
if (wantsFood) 'comfort food, makan malam'
|
|
].join(', ');
|
|
return ' - PRIORITASKAN: $recs\n - Malam hari cocok untuk menu yang menghangatkan';
|
|
default:
|
|
return ' - Sesuaikan pilihan dengan waktu kunjungan';
|
|
}
|
|
}
|
|
|
|
// ── Helper: terjemahkan cuaca ke instruksi konkret ───────────────────────
|
|
String _buildCuacaRule(String cuaca, bool wantsDrinks, bool wantsFood) {
|
|
final c = cuaca.toLowerCase();
|
|
if (c.contains('panas') || c.contains('cerah') || c.contains('terik') ||
|
|
c.contains('hot') || c.contains('sunny')) {
|
|
final recs = [
|
|
if (wantsDrinks) 'minuman dingin, iced',
|
|
if (wantsFood) 'dessert dingin, makanan menyegarkan'
|
|
].join(', ');
|
|
return ' - Cuaca panas: PRIORITASKAN $recs';
|
|
} else if (c.contains('hujan') || c.contains('dingin') ||
|
|
c.contains('mendung') || c.contains('rain') || c.contains('cold') || c.contains('cloudy')) {
|
|
final recs = [
|
|
if (wantsDrinks) 'minuman panas (hot coffee/tea)',
|
|
if (wantsFood) 'makanan berkuah hangat, comfort food'
|
|
].join(', ');
|
|
return ' - Cuaca dingin: PRIORITASKAN $recs';
|
|
}
|
|
return ' - Sesuaikan dengan kondisi cuaca yang ada';
|
|
}
|
|
|
|
// ── Helper: terjemahkan mood ke instruksi konkret ────────────────────────
|
|
String _buildMoodRule(String mood, bool wantsDrinks, bool wantsFood) {
|
|
final m = mood.toLowerCase();
|
|
if (m.contains('senang') || m.contains('bahagia') || m.contains('happy') ||
|
|
m.contains('energik') || m.contains('semangat')) {
|
|
final recs = [
|
|
if (wantsDrinks) 'signature drink',
|
|
if (wantsFood) 'dessert spesial'
|
|
].join(' atau ');
|
|
return ' - Mood positif: rekomendasikan menu celebratory ($recs)';
|
|
} else if (m.contains('lelah') || m.contains('capek') ||
|
|
m.contains('tired') || m.contains('ngantuk')) {
|
|
final recs = wantsDrinks ? 'minuman berkafein (kopi, espresso) untuk energi' : 'menu pengembali energi';
|
|
return ' - Mood lelah: PRIORITASKAN $recs';
|
|
} else if (m.contains('santai') || m.contains('relax') || m.contains('tenang')) {
|
|
final recs = [
|
|
if (wantsDrinks) 'teh, kopi santai',
|
|
if (wantsFood) 'camilan, dessert'
|
|
].join(', ');
|
|
return ' - Mood santai: rekomendasikan menu yang bisa dinikmati pelan ($recs)';
|
|
} else if (m.contains('stres') || m.contains('stress') ||
|
|
m.contains('sedih') || m.contains('bete')) {
|
|
final recs = [
|
|
if (wantsFood) 'comfort food',
|
|
if (wantsDrinks) 'minuman manis/cokelat untuk mood booster'
|
|
].join(' dan ');
|
|
return ' - Mood tidak baik: rekomendasikan $recs';
|
|
} else if (m.contains('fokus') || m.contains('kerja') ||
|
|
m.contains('belajar') || m.contains('produktif')) {
|
|
final recs = wantsDrinks ? 'kopi atau minuman berkafein untuk konsentrasi' : 'menu ringan agar tidak ngantuk';
|
|
return ' - Butuh fokus: PRIORITASKAN $recs';
|
|
}
|
|
return ' - Sesuaikan pilihan dengan mood yang ada';
|
|
}
|
|
|
|
// ── Helper: terjemahkan diet ke aturan ──────────────────────────────────
|
|
String _buildDietRule(String diet) {
|
|
switch (diet.toLowerCase().trim()) {
|
|
case 'vegetarian':
|
|
return 'JANGAN rekomendasikan menu yang mengandung daging (ayam, sapi, babi, seafood).';
|
|
case 'vegan':
|
|
return 'JANGAN rekomendasikan produk hewani apapun (daging, susu, telur, madu).';
|
|
case 'halal':
|
|
return 'JANGAN rekomendasikan menu mengandung babi atau alkohol.';
|
|
case 'keto':
|
|
return 'PRIORITASKAN menu rendah karbo/tinggi protein. HINDARI nasi, roti, pasta, gula tinggi.';
|
|
case 'low carb':
|
|
return 'PRIORITASKAN menu rendah karbohidrat. HINDARI menu dengan banyak gula atau tepung.';
|
|
default:
|
|
return 'Tidak ada pembatasan diet khusus.';
|
|
}
|
|
}
|
|
|
|
// ── Helper: terjemahkan spice level ─────────────────────────────────────
|
|
String _translateSpiceLevel(String level) {
|
|
switch (level.toLowerCase().trim()) {
|
|
case 'mild': return 'Tidak Pedas (mild)';
|
|
case 'medium': return 'Sedang (medium)';
|
|
case 'hot': return 'Pedas (hot)';
|
|
case 'very hot': return 'Sangat Pedas (very hot)';
|
|
default: return level;
|
|
}
|
|
}
|
|
|
|
// Parse recommendations from JSON response
|
|
|
|
List<MenuRecommendation> _parseRecommendations(
|
|
Map<String, dynamic> jsonResponse,
|
|
List<MenuItemOCR> menuItems,
|
|
) {
|
|
final recommendationsList =
|
|
jsonResponse['recommendations'] as List<dynamic>;
|
|
|
|
return recommendationsList.map((item) {
|
|
final recommendation = item as Map<String, dynamic>;
|
|
|
|
// Find matching menu item for accurate price
|
|
final menuName = recommendation['menuName'] as String;
|
|
final matchingItem = menuItems.firstWhere(
|
|
(menu) =>
|
|
menu.name.toLowerCase().contains(menuName.toLowerCase()) ||
|
|
menuName.toLowerCase().contains(menu.name.toLowerCase()),
|
|
orElse: () => MenuItemOCR(
|
|
name: menuName,
|
|
price: 0,
|
|
description: '',
|
|
category: 'Lainnya',
|
|
),
|
|
);
|
|
|
|
final String description = recommendation['description'] as String? ?? '';
|
|
final String slotType = recommendation['slotType'] as String? ?? '';
|
|
final bool isBundling = slotType.toLowerCase().contains('bundling');
|
|
|
|
// Hitung ulang harga bundling dari deskripsi — jangan andalkan AI untuk aritmetika
|
|
double finalPrice;
|
|
if (isBundling) {
|
|
finalPrice = _extractBundlingTotalPrice(description);
|
|
// Jika gagal ekstrak dari deskripsi, fallback ke harga AI
|
|
if (finalPrice <= 0) {
|
|
finalPrice = (recommendation['price'] as num?)?.toDouble() ?? 0.0;
|
|
}
|
|
} else {
|
|
// Untuk non-bundling: utamakan harga OCR yang akurat
|
|
finalPrice = matchingItem.price > 0
|
|
? matchingItem.price
|
|
: (recommendation['price'] as num?)?.toDouble() ?? 0.0;
|
|
}
|
|
|
|
return MenuRecommendation(
|
|
menuName: menuName,
|
|
description: description,
|
|
price: finalPrice,
|
|
reason: recommendation['reason'] as String? ?? '',
|
|
score: (recommendation['score'] as num?)?.toDouble() ?? 0.5,
|
|
tags: List<String>.from(recommendation['tags'] ?? []),
|
|
category:
|
|
recommendation['category'] as String? ?? matchingItem.category,
|
|
isRecommended: recommendation['isRecommended'] as bool? ?? true,
|
|
slotType: slotType.isNotEmpty ? slotType : null,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
/// Ekstrak total harga bundling dari teks deskripsi.
|
|
/// Mencari pola "Total: Rp X" terlebih dahulu,
|
|
/// lalu fallback ke menjumlahkan "Makanan: Rp X" + "Minuman: Rp Y".
|
|
double _extractBundlingTotalPrice(String description) {
|
|
// Bersihkan titik pemisah ribuan: "14.000" → "14000"
|
|
final cleaned = description.replaceAll('.', '');
|
|
|
|
// Coba cari "Total: Rp XXXX"
|
|
final totalRegex = RegExp(r'Total:\s*Rp\s*(\d+)', caseSensitive: false);
|
|
final totalMatch = totalRegex.firstMatch(cleaned);
|
|
if (totalMatch != null) {
|
|
final total = double.tryParse(totalMatch.group(1) ?? '');
|
|
if (total != null && total > 0) {
|
|
debugPrint('💰 Bundling total extracted from description: $total');
|
|
return total;
|
|
}
|
|
}
|
|
|
|
// Fallback: jumlahkan "Makanan: Rp X" + "Minuman: Rp Y"
|
|
final makanRegex = RegExp(r'Makanan:\s*Rp\s*(\d+)', caseSensitive: false);
|
|
final minumRegex = RegExp(r'Minuman:\s*Rp\s*(\d+)', caseSensitive: false);
|
|
final makanMatch = makanRegex.firstMatch(cleaned);
|
|
final minumMatch = minumRegex.firstMatch(cleaned);
|
|
if (makanMatch != null && minumMatch != null) {
|
|
final food = double.tryParse(makanMatch.group(1) ?? '') ?? 0;
|
|
final drink = double.tryParse(minumMatch.group(1) ?? '') ?? 0;
|
|
if (food > 0 && drink > 0) {
|
|
final sum = food + drink;
|
|
debugPrint('💰 Bundling price summed from parts: $food + $drink = $sum');
|
|
return sum;
|
|
}
|
|
}
|
|
|
|
return 0; // Gagal ekstrak — caller akan fallback ke harga AI
|
|
}
|
|
|
|
|
|
Map<String, dynamic> _extractPartialRecommendations(String raw) {
|
|
final List<Map<String, dynamic>> complete = [];
|
|
|
|
// Cari posisi array "recommendations": [
|
|
final arrIdx = raw.indexOf('"recommendations"');
|
|
if (arrIdx == -1) return _buildFallbackJson();
|
|
|
|
final arrStart = raw.indexOf('[', arrIdx);
|
|
if (arrStart == -1) return _buildFallbackJson();
|
|
|
|
// Telusuri karakter satu per satu, ekstrak setiap objek {} yang LENGKAP
|
|
int depth = 0;
|
|
int objStart = -1;
|
|
for (int i = arrStart; i < raw.length; i++) {
|
|
final ch = raw[i];
|
|
if (ch == '{') {
|
|
if (depth == 0) objStart = i;
|
|
depth++;
|
|
} else if (ch == '}') {
|
|
depth--;
|
|
if (depth == 0 && objStart != -1) {
|
|
final objStr = raw.substring(objStart, i + 1);
|
|
try {
|
|
final obj = json.decode(objStr) as Map<String, dynamic>;
|
|
// Hanya ambil objek yang punya menuName (bukan objek lain)
|
|
if (obj.containsKey('menuName')) complete.add(obj);
|
|
} catch (_) { /* skip invalid object */ }
|
|
objStart = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
debugPrint('⚠️ Recovered ${complete.length} complete recommendation(s) from truncated JSON');
|
|
|
|
if (complete.isEmpty) return _buildFallbackJson();
|
|
|
|
return {
|
|
'recommendations': complete,
|
|
'summary': 'Rekomendasi dari respons parsial',
|
|
'additionalNotes': '',
|
|
};
|
|
}
|
|
|
|
// ── Fallback JSON jika semua metode parse gagal ───────────────────────────
|
|
Map<String, dynamic> _buildFallbackJson() {
|
|
return {
|
|
'recommendations': [
|
|
{
|
|
'menuName': 'Coba Lagi',
|
|
'description': 'Gagal mendapatkan rekomendasi dari AI',
|
|
'price': 0,
|
|
'reason': 'AI tidak dapat memberikan rekomendasi. Silakan scan ulang.',
|
|
'score': 0.0,
|
|
'tags': ['fallback'],
|
|
'category': 'Umum',
|
|
'isRecommended': false,
|
|
},
|
|
],
|
|
'summary': 'Terjadi kesalahan',
|
|
'additionalNotes': 'Coba scan ulang menu',
|
|
};
|
|
}
|
|
|
|
// Convert menu items to text
|
|
|
|
String _menuItemsToText(List<MenuItemOCR> menuItems) {
|
|
return menuItems
|
|
.map((item) {
|
|
String itemText = '${item.name} - ${item.formattedPrice}';
|
|
if (item.description.isNotEmpty) {
|
|
itemText += ' (${item.description})';
|
|
}
|
|
itemText += ' [${item.category}]';
|
|
return itemText;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
// Calculate recommendation confidence
|
|
double _calculateRecommendationConfidence(
|
|
List<MenuRecommendation> recommendations,
|
|
) {
|
|
if (recommendations.isEmpty) return 0.0;
|
|
|
|
double totalScore = recommendations.fold(
|
|
0.0,
|
|
(sum, rec) => sum + rec.score,
|
|
);
|
|
return (totalScore / recommendations.length).clamp(0.0, 1.0);
|
|
}
|
|
|
|
// Chat with AI for follow-up questions
|
|
Future<String> chatWithAI({
|
|
required String message,
|
|
required RecommendationModel context,
|
|
List<String> conversationHistory = const [],
|
|
}) async {
|
|
if (!_isInitialized) {
|
|
throw Exception('Gemini service not initialized');
|
|
}
|
|
|
|
try {
|
|
final chatPrompt = _buildChatPrompt(
|
|
message,
|
|
context,
|
|
conversationHistory,
|
|
);
|
|
|
|
// We use a chat session to handle function calling more easily
|
|
final chat = _chatModel.startChat();
|
|
var response = await chat.sendMessage(Content.text(chatPrompt));
|
|
|
|
// Handle potential function calls
|
|
while (response.functionCalls.isNotEmpty) {
|
|
final responses = <FunctionResponse>[];
|
|
for (final call in response.functionCalls) {
|
|
final result = await _handleFunctionCall(call, context);
|
|
responses.add(FunctionResponse(call.name, result));
|
|
}
|
|
response = await chat.sendMessage(Content.functionResponses(responses));
|
|
}
|
|
|
|
if (response.text == null || response.text!.isEmpty) {
|
|
throw Exception('Empty response from Gemini');
|
|
}
|
|
|
|
return response.text!;
|
|
} catch (e) {
|
|
debugPrint('Chat with AI error: $e');
|
|
throw Exception('Failed to get AI response: $e');
|
|
}
|
|
}
|
|
|
|
// Build chat prompt with context
|
|
String _buildChatPrompt(
|
|
String message,
|
|
RecommendationModel context,
|
|
List<String> history,
|
|
) {
|
|
final recommendationsText = context.recommendations
|
|
.map(
|
|
(rec) =>
|
|
'- ${rec.menuName}: ${rec.reason} (Score: ${rec.scorePercentage})',
|
|
)
|
|
.join('\n');
|
|
|
|
String historyText = '';
|
|
if (history.isNotEmpty) {
|
|
historyText = 'RIWAYAT PERCAKAPAN:\n${history.join('\n')}\n\n';
|
|
}
|
|
|
|
return '''
|
|
Kamu adalah **Amori** — barista virtual cerdas milik Amor Coffee yang siap membantu pelanggan dengan segala hal yang berkaitan dengan Amor Coffee.
|
|
|
|
═══════════════════════════════════════
|
|
☕ AMOR COFFEE ☕
|
|
"Setiap tegukan, cerita baru."
|
|
═══════════════════════════════════════
|
|
|
|
IDENTITASMU:
|
|
- Nama: Amori
|
|
- Peran: Barista virtual eksklusif Amor Coffee
|
|
- Kepribadian: Ramah, hangat, informatif, dan antusias tentang kopi & makanan
|
|
|
|
TOPIK YANG BOLEH KAMU JAWAB (hanya terbatas pada hal-hal ini):
|
|
1. Menu Amor Coffee (kopi, minuman, makanan, snack, dessert)
|
|
2. Rekomendasi menu berdasarkan mood, cuaca, budget, atau selera
|
|
3. Informasi alergi dan kandungan bahan menu
|
|
4. Harga, promo, atau penawaran di Amor Coffee
|
|
5. Suasana, lokasi, atau hal-hal yang berkaitan dengan kafe Amor Coffee
|
|
6. Tips menikmati kopi atau makanan di Amor Coffee
|
|
7. Pertanyaan follow-up tentang rekomendasi yang sudah diberikan
|
|
|
|
LARANGAN KERAS:
|
|
- JANGAN menjawab pertanyaan di luar konteks Amor Coffee
|
|
- JANGAN membahas politik, agama, berita umum, coding, matematika, atau topik lain yang tidak terkait
|
|
- JANGAN berpura-pura jadi AI lain atau mengabaikan identitasmu sebagai Barista AI Amor Coffee
|
|
- JANGAN memberikan informasi menu dari kafe atau restoran lain
|
|
|
|
JIKA PERTANYAAN DI LUAR TOPIK:
|
|
Balas dengan sopan dan redirect, contoh:
|
|
"Hei! Aku Amori, barista virtual Amor Coffee. Aku hanya bisa membantu kamu seputar menu dan pengalaman di Amor Coffee 😊 Ada yang bisa aku bantu soal minuman atau makanan kami?"
|
|
|
|
KONTEKS REKOMENDASI SESI INI:
|
|
$recommendationsText
|
|
|
|
${historyText}PERTANYAAN PELANGGAN:
|
|
$message
|
|
|
|
INSTRUKSI MENJAWAB:
|
|
- Mulai jawaban dengan ramah dan hangat (boleh pakai emoji secukupnya ☕🌟)
|
|
- Jika relevan, referensikan rekomendasi yang sudah diberikan di atas
|
|
- Gunakan bahasa Indonesia santai namun sopan
|
|
- Jika tidak yakin, akui dengan jujur dan sarankan bertanya langsung ke staf Amor Coffee (tapi tetap pakai nama Amori, bukan "AI" atau "saya")
|
|
- Akhiri jawaban dengan ajakan atau pertanyaan balik agar percakapan tetap mengalir
|
|
|
|
AGENTIC TOOLS:
|
|
- Jika user ingin menyimpan menu ke favorit, gunakan tool 'toggle_menu_favorite'
|
|
- Jika user minta rekomendasi minuman spesifik, gunakan tool 'recommend_specific_drink'
|
|
|
|
Jawab sekarang:
|
|
''';
|
|
}
|
|
|
|
// Check if service is ready
|
|
|
|
bool get isReady => _isInitialized;
|
|
|
|
// Get service status
|
|
String get status {
|
|
if (!_isInitialized) return 'Not initialized';
|
|
return 'Ready';
|
|
}
|
|
|
|
// Get current API key info (masked for security)
|
|
String get currentApiKeyInfo {
|
|
if (_currentApiKey == null || _currentApiKey!.isEmpty) {
|
|
return 'No API key set';
|
|
}
|
|
|
|
if (_currentApiKey == AppConfig.geminiApiKey) {
|
|
return 'Using app default API key';
|
|
} else {
|
|
// Mask user's API key for security
|
|
String masked = _currentApiKey!.length > 8
|
|
? '${_currentApiKey!.substring(0, 4)}...${_currentApiKey!.substring(_currentApiKey!.length - 4)}'
|
|
: '****';
|
|
return 'Using user API key: $masked';
|
|
}
|
|
}
|
|
|
|
// Update API key and reinitialize
|
|
Future<void> updateApiKey(String newApiKey) async {
|
|
if (newApiKey.isEmpty) {
|
|
throw Exception('API key cannot be empty');
|
|
}
|
|
|
|
// Save the new API key
|
|
await _saveApiKey(newApiKey);
|
|
|
|
_isInitialized = false;
|
|
await initialize(userApiKey: newApiKey);
|
|
}
|
|
|
|
// Generate daily check-in questions using AI
|
|
Future<List<DailyQuestion>> generateDailyQuestions({
|
|
required String userName,
|
|
required DateTime date,
|
|
}) async {
|
|
if (!_isInitialized) await initialize();
|
|
|
|
try {
|
|
final dayOfWeek = [
|
|
'Minggu',
|
|
'Senin',
|
|
'Selasa',
|
|
'Rabu',
|
|
'Kamis',
|
|
'Jumat',
|
|
'Sabtu',
|
|
][date.weekday % 7];
|
|
|
|
final prompt =
|
|
'''
|
|
Kamu adalah asisten AI yang membantu pengguna menentukan preferensi makanan harian mereka.
|
|
Hari ini adalah $dayOfWeek, ${date.day}/${date.month}/${date.year}.
|
|
Nama pengguna: $userName
|
|
|
|
Buatlah 6 pertanyaan menarik dan dinamis untuk mengetahui preferensi makanan pengguna hari ini.
|
|
Pertanyaan harus:
|
|
1. Menggunakan bahasa yang ramah dan personal (gunakan nama pengguna)
|
|
2. Berbeda setiap hari (kreatif dengan kata-kata)
|
|
3. Mencakup topik: mood, budget, tingkat pedas, jenis makanan, rasa yang diinginkan (cravings), dan gaya makan (dining style).
|
|
|
|
PENTING: Berikan response dalam format JSON yang VALID seperti ini:
|
|
{
|
|
"questions": [
|
|
{
|
|
"id": "mood",
|
|
"question": "Hai $userName! Bagaimana perasaanmu hari ini?",
|
|
"type": "choice",
|
|
"options": ["Energik", "Santai", "Bahagia", "Fokus", "Butuh Comfort Food"]
|
|
},
|
|
{
|
|
"id": "budget",
|
|
"question": "Berapa budget yang kamu siapkan untuk makan hari ini?",
|
|
"type": "slider",
|
|
"minValue": 10000,
|
|
"maxValue": 100000,
|
|
"step": 10000
|
|
},
|
|
{
|
|
"id": "spiceLevel",
|
|
"question": "Seberapa pedas yang kamu mau hari ini?",
|
|
"type": "choice",
|
|
"options": ["Tidak Pedas", "Sedikit Pedas", "Sedang", "Pedas", "Extra Pedas"]
|
|
},
|
|
{
|
|
"id": "mealType",
|
|
"question": "Kamu lagi pengen makan apa nih?",
|
|
"type": "choice",
|
|
"options": ["Nasi", "Mie", "Roti/Burger", "Snack", "Minuman", "Dessert"]
|
|
},
|
|
{
|
|
"id": "cravings",
|
|
"question": "Lagi pengen rasa yang gimana?",
|
|
"type": "choice",
|
|
"options": ["Asin/Gurih", "Manis", "Segar/Asam", "Berkuah", "Gorengan", "Sehat"]
|
|
},
|
|
{
|
|
"id": "diningStyle",
|
|
"question": "Mau makan gimana?",
|
|
"type": "choice",
|
|
"options": ["Makan Cepat", "Santai", "Bawa Pulang (Takeaway)"]
|
|
}
|
|
]
|
|
}
|
|
|
|
Pastikan:
|
|
- Gunakan bahasa Indonesia yang casual dan friendly
|
|
- Variasikan kata-kata pertanyaan agar tidak monoton
|
|
- Jangan gunakan markdown atau code block, langsung JSON saja
|
|
- ID harus tetap sama: mood, budget, spiceLevel, mealType, cravings, diningStyle
|
|
''';
|
|
|
|
final response = await _model.generateContent([Content.text(prompt)]);
|
|
final text = response.text?.trim() ?? '';
|
|
|
|
debugPrint('🤖 AI Response for daily questions: $text');
|
|
|
|
// Parse JSON response
|
|
final jsonResponse = jsonDecode(text) as Map<String, dynamic>;
|
|
final questions = (jsonResponse['questions'] as List)
|
|
.map((q) => DailyQuestion.fromJson(q))
|
|
.toList();
|
|
|
|
return questions;
|
|
} catch (e) {
|
|
debugPrint('❌ Error generating daily questions: $e');
|
|
// Return fallback questions
|
|
return _getFallbackQuestions(userName);
|
|
}
|
|
}
|
|
|
|
// Fallback questions if AI generation fails
|
|
List<DailyQuestion> _getFallbackQuestions(String userName) {
|
|
return [
|
|
DailyQuestion(
|
|
id: 'mood',
|
|
question: 'Hai $userName! Bagaimana perasaanmu hari ini?',
|
|
type: QuestionType.choice,
|
|
options: [
|
|
'Energik',
|
|
'Santai',
|
|
'Bahagia',
|
|
'Fokus',
|
|
'Butuh Comfort Food',
|
|
],
|
|
),
|
|
DailyQuestion(
|
|
id: 'budget',
|
|
question: 'Berapa budget yang kamu siapkan untuk makan hari ini?',
|
|
type: QuestionType.slider,
|
|
minValue: 10000,
|
|
maxValue: 100000,
|
|
step: 10000,
|
|
),
|
|
DailyQuestion(
|
|
id: 'spiceLevel',
|
|
question: 'Seberapa pedas yang kamu mau hari ini?',
|
|
type: QuestionType.choice,
|
|
options: [
|
|
'Tidak Pedas',
|
|
'Sedikit Pedas',
|
|
'Sedang',
|
|
'Pedas',
|
|
'Extra Pedas',
|
|
],
|
|
),
|
|
DailyQuestion(
|
|
id: 'mealType',
|
|
question: 'Kamu lagi pengen makan apa nih?',
|
|
type: QuestionType.choice,
|
|
options: ['Nasi', 'Mie', 'Roti/Burger', 'Snack', 'Minuman', 'Dessert'],
|
|
),
|
|
DailyQuestion(
|
|
id: 'cravings',
|
|
question: 'Lagi pengen rasa yang gimana?',
|
|
type: QuestionType.choice,
|
|
options: [
|
|
'Asin/Gurih',
|
|
'Manis',
|
|
'Segar',
|
|
'Berkuah',
|
|
'Gorengan',
|
|
'Sehat',
|
|
],
|
|
),
|
|
DailyQuestion(
|
|
id: 'diningStyle',
|
|
question: 'Mau makan gimana?',
|
|
type: QuestionType.choice,
|
|
options: ['Makan Cepat', 'Santai', 'Bawa Pulang'],
|
|
),
|
|
];
|
|
}
|
|
|
|
// Reset to default API key
|
|
Future<void> resetToDefaultApiKey() async {
|
|
// Remove saved user API key
|
|
await _removeSavedApiKey();
|
|
|
|
_isInitialized = false;
|
|
await initialize();
|
|
}
|
|
|
|
// Handle AI function calls
|
|
Future<Map<String, dynamic>> _handleFunctionCall(
|
|
FunctionCall call,
|
|
RecommendationModel context,
|
|
) async {
|
|
debugPrint('🤖 AI calling function: ${call.name} with ${call.args}');
|
|
|
|
switch (call.name) {
|
|
case 'toggle_menu_favorite':
|
|
final menuName = call.args['menuName'] as String;
|
|
final isFavorite = call.args['isFavorite'] as bool;
|
|
|
|
// Find the menu item in recommendations
|
|
final recommendation = context.recommendations.firstWhere(
|
|
(r) => r.menuName.toLowerCase().contains(menuName.toLowerCase()),
|
|
orElse: () => context.recommendations.first,
|
|
);
|
|
|
|
try {
|
|
await FirebaseService().toggleRecommendationFavorite(
|
|
context.id,
|
|
isFavorite,
|
|
);
|
|
return {
|
|
'success': true,
|
|
'message':
|
|
'Berhasil mengubah status favorit untuk ${recommendation.menuName}',
|
|
};
|
|
} catch (e) {
|
|
return {'success': false, 'error': e.toString()};
|
|
}
|
|
|
|
case 'recommend_specific_drink':
|
|
final drinkType = call.args['drinkType'] as String;
|
|
final isIce = call.args['isIce'] as bool? ?? true;
|
|
|
|
return {
|
|
'suggestion': isIce
|
|
? 'Es $drinkType adalah pilihan menyegarkan!'
|
|
: '$drinkType hangat sangat cocok untuk hari ini.',
|
|
'context':
|
|
'AI merekomendasikan $drinkType ($isIce ? "Dingin" : "Panas")',
|
|
};
|
|
|
|
default:
|
|
return {'error': 'Function not found'};
|
|
}
|
|
}
|
|
}
|