Compare commits

..

3 Commits

Author SHA1 Message Date
f64bff7933 PDF Testing & Q&A 2025-11-16 19:16:16 +07:00
0c04473b43 PDF Testing 2025-11-14 22:02:22 +07:00
524f1c1885 Revert "PDF Testing"
This reverts commit 30298ac46eb71830a59cba5ae494fb12e3c2121d.
2025-11-14 22:01:51 +07:00
12 changed files with 644 additions and 96 deletions

View File

@ -68,7 +68,7 @@ dependencies {
debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest)
// Room Database
// Room Database - TAMBAHKAN INI SEMUA
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
@ -92,6 +92,6 @@ dependencies {
// Gson
implementation("com.google.code.gson:gson:2.10.1")
// PDF Support
// PDFBox untuk extract text dari PDF
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
}

View File

@ -18,6 +18,17 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Notebook">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -40,13 +40,23 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize PDFBox
com.example.notebook.utils.PdfHelper.initialize(this)
setContent {
NotebookTheme {
var isDarkMode by remember { mutableStateOf(false) }
NotebookTheme(darkTheme = isDarkMode) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NotebookApp(viewModel = viewModel)
NotebookApp(
viewModel = viewModel,
isDarkMode = isDarkMode,
onThemeChange = { isDarkMode = it }
)
}
}
}
@ -55,7 +65,7 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotebookApp(viewModel: NotebookViewModel) {
fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange: (Boolean) -> Unit) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Studio", "Chat", "Sources")
var showGoogleAppsMenu by remember { mutableStateOf(false) }
@ -64,12 +74,21 @@ fun NotebookApp(viewModel: NotebookViewModel) {
var chatInput by remember { mutableStateOf("") }
var selectedNotebookId by remember { mutableIntStateOf(-1) }
// Log setiap kali selectedNotebookId berubah
LaunchedEffect(selectedNotebookId) {
println("🎯 selectedNotebookId berubah menjadi: $selectedNotebookId")
}
// Kalau ada notebook yang dipilih, tampilkan detail screen
if (selectedNotebookId != -1) {
println("✨ Menampilkan NotebookDetailScreen untuk ID: $selectedNotebookId")
com.example.notebook.ui.screens.NotebookDetailScreen(
viewModel = viewModel,
notebookId = selectedNotebookId,
onBack = { selectedNotebookId = -1 }
onBack = {
println("⬅️ Kembali dari detail screen")
selectedNotebookId = -1
}
)
return
}
@ -87,13 +106,12 @@ fun NotebookApp(viewModel: NotebookViewModel) {
IconButton(onClick = { showSettingsMenu = true }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
SettingsMenu(expanded = showSettingsMenu, onDismiss = { showSettingsMenu = false })
}
Box {
IconButton(onClick = { showGoogleAppsMenu = true }) {
Icon(Icons.Filled.Apps, contentDescription = "Google Apps")
}
GoogleAppsMenu(expanded = showGoogleAppsMenu, onDismiss = { showGoogleAppsMenu = false })
SettingsMenu(
expanded = showSettingsMenu,
onDismiss = { showSettingsMenu = false },
isDarkMode = isDarkMode,
onThemeChange = onThemeChange
)
}
Box(
modifier = Modifier
@ -149,7 +167,13 @@ fun NotebookApp(viewModel: NotebookViewModel) {
}
}
when (selectedTabIndex) {
0 -> StudioScreen(viewModel)
0 -> StudioScreen(
viewModel = viewModel,
onNotebookClick = { notebookId ->
println("📱 Navigasi ke notebook ID: $notebookId")
selectedNotebookId = notebookId
}
)
1 -> ChatScreen(viewModel)
2 -> SourcesScreen(viewModel)
}
@ -159,7 +183,7 @@ fun NotebookApp(viewModel: NotebookViewModel) {
// === STUDIO SCREEN (UPDATED) ===
@Composable
fun StudioScreen(viewModel: NotebookViewModel) {
fun StudioScreen(viewModel: NotebookViewModel, onNotebookClick: (Int) -> Unit) {
val notebooks by viewModel.notebooks.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
@ -199,8 +223,14 @@ fun StudioScreen(viewModel: NotebookViewModel) {
items(notebooks) { notebook ->
NotebookCard(
notebook = notebook,
onClick = { /* TODO: Buka notebook */ },
onDelete = { viewModel.deleteNotebook(notebook) }
onClick = {
println("🟢 onClick triggered untuk notebook ID: ${notebook.id}")
onNotebookClick(notebook.id)
},
onDelete = {
println("🔴 Delete triggered untuk notebook ID: ${notebook.id}")
viewModel.deleteNotebook(notebook)
}
)
}
}
@ -246,11 +276,13 @@ fun NotebookCard(
val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault())
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)),
onClick = {
println("🔵 Card onClick: ID=${notebook.id}, Title=${notebook.title}")
onClick()
}
) {
Row(
modifier = Modifier
@ -435,30 +467,44 @@ fun AccountScreen(onDismiss: () -> Unit) {
}
@Composable
fun SettingsMenu(expanded: Boolean, onDismiss: () -> Unit) {
fun SettingsMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkMode: Boolean,
onThemeChange: (Boolean) -> Unit
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
// Dark Mode Toggle
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(if (isDarkMode) "Mode Gelap" else "Mode Terang")
}
Switch(
checked = isDarkMode,
onCheckedChange = onThemeChange
)
}
},
onClick = { onThemeChange(!isDarkMode) }
)
Divider()
DropdownMenuItem(
text = { Text("NotebookLM Help") },
onClick = { },
leadingIcon = { Icon(Icons.Default.HelpOutline, contentDescription = null) }
)
}
}
@Composable
fun GoogleAppsMenu(expanded: Boolean, onDismiss: () -> Unit) {
val apps = listOf(
"Account" to Icons.Default.AccountCircle,
"Gmail" to Icons.Default.Mail,
"Drive" to Icons.Default.Cloud
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
apps.forEach { (name, icon) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { },
leadingIcon = { Icon(icon, contentDescription = name) }
)
}
}
}

View File

@ -15,7 +15,7 @@ object ApiConstants {
const val BASE_URL = "https://generativelanguage.googleapis.com/"
// Model yang digunakan (Flash = gratis & cepat)
const val MODEL_NAME = "gemini-2.0-flash"
const val MODEL_NAME = "gemini-2.5-flash"
// System instruction untuk AI
const val SYSTEM_INSTRUCTION = """

View File

@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit
*/
interface GeminiApiService {
@POST("v1beta/models/gemini-2.0-flash:generateContent")
@POST("v1beta/models/gemini-2.5-flash:generateContent")
suspend fun generateContent(
@Query("key") apiKey: String,
@Body request: GeminiRequest

View File

@ -26,7 +26,18 @@ data class Content(
data class Part(
@SerializedName("text")
val text: String
val text: String? = null,
@SerializedName("inlineData")
val inlineData: InlineData? = null
)
data class InlineData(
@SerializedName("mimeType")
val mimeType: String,
@SerializedName("data")
val data: String // Base64 encoded
)
data class GenerationConfig(

View File

@ -8,7 +8,7 @@ class GeminiRepository {
private val apiService = GeminiApiService.create()
/**
* Generate summary dari text
* Generate summary dari text atau file PDF
*/
suspend fun generateSummary(text: String): Result<String> {
return try {
@ -43,6 +43,93 @@ class GeminiRepository {
}
}
/**
* Generate summary dari file PDF (binary)
* Untuk PDF yang image-based atau sulit di-extract
* Optimized untuk mencegah ANR
*/
suspend fun generateSummaryFromPdfFile(
filePath: String,
fileName: String
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
println("📄 Reading PDF file: $filePath")
val file = java.io.File(filePath)
// Check file size (max 20MB untuk Gemini)
val fileSizeMB = file.length() / (1024.0 * 1024.0)
if (fileSizeMB > 20) {
return@withContext Result.failure(Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB"))
}
println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)")
// Read & encode in background
val bytes = file.readBytes()
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
println("📄 Base64 encoded, length: ${base64.length}")
val prompt = """
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
Ringkasan harus:
- Mencakup semua poin-poin utama
- Mudah dipahami
- Panjang sekitar 3-5 paragraf
File: $fileName
""".trimIndent()
val request = GeminiRequest(
contents = listOf(
Content(
parts = listOf(
Part(text = prompt),
Part(
inlineData = InlineData(
mimeType = "application/pdf",
data = base64
)
)
),
role = "user"
)
),
generationConfig = GenerationConfig(
temperature = 0.7,
maxOutputTokens = 2048,
topP = 0.95
),
systemInstruction = SystemInstruction(
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
)
)
println("📡 Sending request to Gemini API...")
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
if (textResponse != null) {
println("✅ Gemini response received: ${textResponse.length} chars")
Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
}
} else {
val errorBody = response.errorBody()?.string()
println("❌ API Error: ${response.code()} - $errorBody")
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
println("❌ Exception in generateSummaryFromPdfFile: ${e.message}")
e.printStackTrace()
Result.failure(e)
}
}
/**
* Chat dengan context dokumen
*/
@ -88,6 +175,101 @@ class GeminiRepository {
}
}
/**
* Chat dengan PDF file langsung (untuk scan/image PDF)
* Digunakan ketika text extraction gagal
*/
suspend fun chatWithPdfFile(
userMessage: String,
pdfFilePath: String,
pdfFileName: String,
chatHistory: List<Pair<String, String>> = emptyList()
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
println("📄 Chat with PDF file: $pdfFilePath")
val file = java.io.File(pdfFilePath)
// Check file size (max 20MB)
val fileSizeMB = file.length() / (1024.0 * 1024.0)
if (fileSizeMB > 20) {
return@withContext Result.failure(
Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
)
}
println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)")
// Read & encode in background
val bytes = file.readBytes()
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
println("📄 Base64 encoded, length: ${base64.length}")
// Build prompt dengan chat history
val promptBuilder = StringBuilder()
promptBuilder.append("Dokumen PDF: $pdfFileName\n\n")
if (chatHistory.isNotEmpty()) {
promptBuilder.append("Riwayat percakapan sebelumnya:\n")
chatHistory.takeLast(5).forEach { (user, ai) ->
promptBuilder.append("User: $user\n")
promptBuilder.append("Assistant: $ai\n\n")
}
}
promptBuilder.append("Pertanyaan saat ini: $userMessage\n\n")
promptBuilder.append("Jawab pertanyaan berdasarkan isi dokumen PDF di atas. Gunakan bahasa Indonesia yang jelas dan mudah dipahami.")
val request = GeminiRequest(
contents = listOf(
Content(
parts = listOf(
Part(
inlineData = InlineData(
mimeType = "application/pdf",
data = base64
)
),
Part(text = promptBuilder.toString())
),
role = "user"
)
),
generationConfig = GenerationConfig(
temperature = 0.7,
maxOutputTokens = 2048,
topP = 0.95
),
systemInstruction = SystemInstruction(
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
)
)
println("📡 Sending chat request to Gemini API...")
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
if (textResponse != null) {
println("✅ Chat response received: ${textResponse.length} chars")
Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
}
} else {
val errorBody = response.errorBody()?.string()
println("❌ API Error: ${response.code()} - $errorBody")
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
println("❌ Exception in chatWithPdfFile: ${e.message}")
e.printStackTrace()
Result.failure(e)
}
}
/**
* Create request object dengan system instruction
*/
@ -105,7 +287,7 @@ class GeminiRepository {
topP = 0.95
),
systemInstruction = SystemInstruction(
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION.trimIndent()))
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION))
)
)
}

View File

@ -128,4 +128,13 @@ class NotebookRepository(private val dao: NotebookDao) {
suspend fun clearChatHistory(notebookId: Int) {
dao.clearChatHistory(notebookId)
}
/**
* Hapus pesan AI terakhir (untuk replace dengan hasil sebenarnya)
*/
suspend fun clearLastAIMessage(notebookId: Int) {
// Get last AI message
// Note: Ini simplified, di production sebaiknya pakai query langsung
// Untuk sekarang kita skip dulu, cukup pakai pesan "thinking" yang nanti di-override
}
}

View File

@ -1,5 +1,7 @@
package com.example.notebook.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -26,13 +28,72 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.example.notebook.data.ChatMessageEntity
import com.example.notebook.data.NotebookEntity
import com.example.notebook.data.SourceEntity
import com.example.notebook.viewmodel.NotebookViewModel
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Fungsi untuk buka file dengan aplikasi default
*/
fun openFile(context: Context, source: SourceEntity) {
try {
val file = File(source.filePath)
if (!file.exists()) {
android.widget.Toast.makeText(context, "File tidak ditemukan", android.widget.Toast.LENGTH_SHORT).show()
return
}
// Tentukan MIME type
val mimeType = when (source.fileType) {
"PDF" -> "application/pdf"
"Image" -> "image/*"
"Text" -> "text/plain"
"Markdown" -> "text/markdown"
"Word" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"PowerPoint" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
"Audio" -> "audio/*"
"Video" -> "video/*"
else -> "*/*"
}
// Gunakan FileProvider untuk file di internal storage
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Cek apakah ada aplikasi yang bisa handle
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
// Fallback: buka dengan chooser
val chooser = Intent.createChooser(intent, "Buka dengan")
context.startActivity(chooser)
}
} catch (e: Exception) {
android.widget.Toast.makeText(
context,
"Tidak bisa membuka file: ${e.message}",
android.widget.Toast.LENGTH_LONG
).show()
e.printStackTrace()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotebookDetailScreen(
@ -44,10 +105,76 @@ fun NotebookDetailScreen(
val notebook by viewModel.currentNotebook.collectAsState()
val sources by viewModel.sources.collectAsState()
val chatMessages by viewModel.chatMessages.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Chat", "Sources")
var chatInput by remember { mutableStateOf("") }
var showUploadMenu by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var sourceToDelete by remember { mutableStateOf<SourceEntity?>(null) }
// Loading overlay
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(enabled = false) { },
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier.padding(32.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
"Processing...",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"AI sedang membaca dokumen",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog && sourceToDelete != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Hapus File?") },
text = { Text("File \"${sourceToDelete?.fileName}\" akan dihapus permanen. Lanjutkan?") },
confirmButton = {
Button(
onClick = {
sourceToDelete?.let { viewModel.deleteSource(it) }
showDeleteDialog = false
sourceToDelete = null
},
colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
) {
Text("Hapus")
}
},
dismissButton = {
TextButton(onClick = {
showDeleteDialog = false
sourceToDelete = null
}) {
Text("Batal")
}
}
)
}
// File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult(
@ -125,6 +252,43 @@ fun NotebookDetailScreen(
},
leadingIcon = { Icon(Icons.Default.Summarize, null) }
)
// Debug: Test PDF extraction
Divider()
DropdownMenuItem(
text = { Text("🔍 Test PDF Extract") },
onClick = {
showUploadMenu = false
val pdfSource = sources.firstOrNull { it.fileType == "PDF" }
if (pdfSource != null) {
println("🔍 Testing PDF: ${pdfSource.fileName}")
val result = com.example.notebook.utils.PdfHelper.extractTextFromPdf(pdfSource.filePath)
if (result != null) {
println("📊 Full text length: ${result.length} karakter")
println("📝 First 500 chars: ${result.take(500)}")
println("📝 Last 500 chars: ${result.takeLast(500)}")
android.widget.Toast.makeText(
context,
"✅ Extracted ${result.length} chars\nPreview: ${result.take(100)}...",
android.widget.Toast.LENGTH_LONG
).show()
} else {
android.widget.Toast.makeText(
context,
"❌ PDF extraction returned null!",
android.widget.Toast.LENGTH_LONG
).show()
}
} else {
android.widget.Toast.makeText(
context,
"⚠️ Tidak ada PDF yang diupload",
android.widget.Toast.LENGTH_SHORT
).show()
}
},
leadingIcon = { Icon(Icons.Default.BugReport, null) }
)
}
}
}
@ -182,7 +346,11 @@ fun NotebookDetailScreen(
sources = sources,
onUploadClick = { filePickerLauncher.launch("*/*") },
onDeleteSource = { source ->
// TODO: Implement delete
sourceToDelete = source
showDeleteDialog = true
},
onOpenSource = { source ->
openFile(context, source)
}
)
}
@ -319,7 +487,8 @@ fun ChatBubble(message: ChatMessageEntity) {
fun SourcesTab(
sources: List<SourceEntity>,
onUploadClick: () -> Unit,
onDeleteSource: (SourceEntity) -> Unit
onDeleteSource: (SourceEntity) -> Unit,
onOpenSource: (SourceEntity) -> Unit
) {
if (sources.isEmpty()) {
Column(
@ -364,7 +533,8 @@ fun SourcesTab(
items(sources) { source ->
SourceCard(
source = source,
onDelete = { onDeleteSource(source) }
onDelete = { onDeleteSource(source) },
onOpen = { onOpenSource(source) }
)
}
}
@ -372,11 +542,17 @@ fun SourcesTab(
}
@Composable
fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
fun SourceCard(
source: SourceEntity,
onDelete: () -> Unit,
onOpen: () -> Unit
) {
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen() },
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
) {
Row(
@ -396,6 +572,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Color(0xFF43A047)
"Text", "Markdown" -> Color(0xFF1E88E5)
"Audio" -> Color(0xFFFF6F00)
"Word" -> Color(0xFF2196F3)
"PowerPoint" -> Color(0xFFFF6D00)
else -> Color.Gray
}.copy(alpha = 0.1f)
),
@ -407,6 +585,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Icons.Default.Image
"Text", "Markdown" -> Icons.Default.Description
"Audio" -> Icons.Default.AudioFile
"Word" -> Icons.Default.Article
"PowerPoint" -> Icons.Default.Slideshow
else -> Icons.Default.InsertDriveFile
},
contentDescription = null,
@ -415,6 +595,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Color(0xFF43A047)
"Text", "Markdown" -> Color(0xFF1E88E5)
"Audio" -> Color(0xFFFF6F00)
"Word" -> Color(0xFF2196F3)
"PowerPoint" -> Color(0xFFFF6D00)
else -> Color.Gray
}
)

View File

@ -64,15 +64,19 @@ object FileHelper {
* Deteksi tipe file dari extension
*/
fun getFileType(fileName: String): String {
return when (fileName.substringAfterLast('.').lowercase()) {
val type = when (fileName.substringAfterLast('.').lowercase()) {
"pdf" -> "PDF"
"txt" -> "Text"
"md", "markdown" -> "Markdown"
"jpg", "jpeg", "png", "gif" -> "Image"
"mp3", "wav", "m4a" -> "Audio"
"mp4", "avi", "mkv" -> "Video"
"doc", "docx" -> "Word"
"ppt", "pptx" -> "PowerPoint"
else -> "Unknown"
}
println("🔍 File: $fileName → Type: $type")
return type
}
/**

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* ViewModel untuk manage state aplikasi
@ -185,47 +186,81 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch {
_isLoading.value = true
try {
// Simpan pesan user
repository.sendMessage(notebookId, message, isUserMessage = true)
val documentContext = buildDocumentContext()
// Cek apakah ada PDF
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
if (documentContext.isEmpty()) {
if (pdfSources.isEmpty() && textSources.isEmpty()) {
val reply = "Maaf, belum ada dokumen yang diupload. Silakan upload dokumen terlebih dahulu untuk bisa bertanya."
repository.sendMessage(notebookId, reply, isUserMessage = false)
} else {
val chatHistory = _chatMessages.value
.takeLast(10)
.filter { it.isUserMessage }
.mapNotNull { userMsg ->
val aiMsg = _chatMessages.value.find {
!it.isUserMessage && it.timestamp > userMsg.timestamp
}
if (aiMsg != null) {
Pair(userMsg.message, aiMsg.message)
} else null
return@launch
}
println("📊 Q&A - PDF: ${pdfSources.size}, Text: ${textSources.size}")
// Build chat history
val chatHistory = _chatMessages.value
.takeLast(10)
.windowed(2, 2, partialWindows = false)
.mapNotNull { messages ->
if (messages.size == 2 && messages[0].isUserMessage && !messages[1].isUserMessage) {
Pair(messages[0].message, messages[1].message)
} else null
}
// PATTERN SAMA dengan generateSummary
if (pdfSources.isNotEmpty()) {
val pdfSource = pdfSources.first()
println("📄 Q&A Processing PDF: ${pdfSource.fileName}")
// Extract dengan Dispatchers.IO
val extractedText = withContext(kotlinx.coroutines.Dispatchers.IO) {
com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
}
if (extractedText != null && extractedText.length > 100) {
println("✅ Q&A: Using extracted text (${extractedText.length} chars)")
// Build context DENGAN Dispatchers.IO
val documentContext = withContext(kotlinx.coroutines.Dispatchers.IO) {
buildDocumentContext()
}
val result = geminiRepository.chatWithDocument(
userMessage = message,
documentContext = documentContext,
chatHistory = chatHistory
)
handleChatResult(notebookId, result)
} else {
println("⚠️ Using Gemini Vision (${extractedText?.length ?: 0} chars)")
val result = geminiRepository.chatWithPdfFile(
userMessage = message,
pdfFilePath = pdfSource.filePath,
pdfFileName = pdfSource.fileName,
chatHistory = chatHistory
)
handleChatResult(notebookId, result)
}
} else {
println("📝 Q&A using text files only")
val documentContext = buildDocumentContext()
val result = geminiRepository.chatWithDocument(
userMessage = message,
documentContext = documentContext,
chatHistory = chatHistory
)
result.fold(
onSuccess = { aiResponse ->
repository.sendMessage(notebookId, aiResponse, isUserMessage = false)
println("✅ AI response berhasil: ${aiResponse.take(50)}...")
},
onFailure = { error ->
val errorMsg = "Maaf, terjadi error: ${error.message}"
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
println("❌ Error dari Gemini: ${error.message}")
_errorMessage.value = error.message
}
)
handleChatResult(notebookId, result)
}
} catch (e: Exception) {
println("❌ Error mengirim pesan: ${e.message}")
println("❌ Error Q&A: ${e.message}")
e.printStackTrace()
val errorMsg = "Maaf, terjadi error: ${e.message}"
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
_errorMessage.value = e.message
} finally {
_isLoading.value = false
@ -233,6 +268,21 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
}
}
private suspend fun handleChatResult(notebookId: Int, result: Result<String>) {
result.fold(
onSuccess = { aiResponse ->
repository.sendMessage(notebookId, aiResponse, isUserMessage = false)
println("✅ AI response: ${aiResponse.take(50)}...")
},
onFailure = { error ->
val errorMsg = "Maaf, terjadi error: ${error.message}"
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
println("❌ Error: ${error.message}")
_errorMessage.value = error.message
}
)
}
fun clearChatHistory(notebookId: Int) {
viewModelScope.launch {
repository.clearChatHistory(notebookId)
@ -244,23 +294,32 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
private fun buildDocumentContext(): String {
val context = StringBuilder()
println("🔍 Building document context dari ${_sources.value.size} sources")
_sources.value.forEach { source ->
println("📋 Source: ${source.fileName} | Type: ${source.fileType}")
when (source.fileType) {
"Text", "Markdown" -> {
"Text", "Markdown", "PDF" -> { // ← TAMBAH "PDF" DI SINI!
val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath)
if (content != null) {
if (content != null && content.isNotBlank()) {
context.append("=== ${source.fileName} ===\n")
context.append(content)
context.append("\n\n")
println("✅ Extracted ${content.length} chars from ${source.fileName}")
} else {
println("⚠️ Failed to extract from ${source.fileName}")
}
}
else -> {
println("⚠️ File type ${source.fileType} not supported")
context.append("=== ${source.fileName} ===\n")
context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n")
}
}
}
println("📦 Total context length: ${context.length} chars")
return context.toString()
}
@ -268,29 +327,45 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch {
_isLoading.value = true
try {
val documentContext = buildDocumentContext()
// Cek apakah ada PDF
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
if (documentContext.isEmpty()) {
if (pdfSources.isEmpty() && textSources.isEmpty()) {
_errorMessage.value = "Tidak ada dokumen untuk diringkas"
return@launch
}
val result = geminiRepository.generateSummary(documentContext)
// Untuk PDF, coba extract dulu
if (pdfSources.isNotEmpty()) {
val pdfSource = pdfSources.first()
println("📄 Processing PDF: ${pdfSource.fileName}")
result.fold(
onSuccess = { summary ->
repository.sendMessage(
notebookId,
"📝 Ringkasan Dokumen:\n\n$summary",
isUserMessage = false
)
println("✅ Summary berhasil: ${summary.take(100)}...")
},
onFailure = { error ->
_errorMessage.value = "Error generate summary: ${error.message}"
println("❌ Error generate summary: ${error.message}")
// val extractedText = com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
val extractedText = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
}
)
if (extractedText != null && extractedText.length > 100) {
// Text extraction berhasil
println("✅ Using extracted text (${extractedText.length} chars)")
val result = geminiRepository.generateSummary(extractedText)
handleSummaryResult(notebookId, result)
} else {
// Text extraction gagal/terlalu pendek, pakai Gemini Vision
println("⚠️ Extracted text too short (${extractedText?.length ?: 0} chars), using Gemini Vision")
val result = geminiRepository.generateSummaryFromPdfFile(
pdfSource.filePath,
pdfSource.fileName
)
handleSummaryResult(notebookId, result)
}
} else {
// Hanya text files
val documentContext = buildDocumentContext()
val result = geminiRepository.generateSummary(documentContext)
handleSummaryResult(notebookId, result)
}
} catch (e: Exception) {
_errorMessage.value = e.message
println("❌ Error: ${e.message}")
@ -300,6 +375,23 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
}
}
private suspend fun handleSummaryResult(notebookId: Int, result: Result<String>) {
result.fold(
onSuccess = { summary ->
repository.sendMessage(
notebookId,
"📝 Ringkasan Dokumen:\n\n$summary",
isUserMessage = false
)
println("✅ Summary berhasil: ${summary.take(100)}...")
},
onFailure = { error ->
_errorMessage.value = "Error generate summary: ${error.message}"
println("❌ Error generate summary: ${error.message}")
}
)
}
fun clearError() {
_errorMessage.value = null
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internal storage files -->
<files-path name="internal_files" path="." />
<!-- Notebook files -->
<files-path name="notebooks" path="notebooks/" />
<!-- Cache -->
<cache-path name="cache" path="." />
</paths>