diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30b5cf0..88563af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,4 +91,7 @@ dependencies { // Gson implementation("com.google.code.gson:gson:2.10.1") + + // PDFBox untuk extract text dari PDF + implementation("com.tom-roush:pdfbox-android:2.0.27.0") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ea171b..02cfd07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,17 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Notebook"> + + + + + Unit -) { +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) } var showSettingsMenu by remember { mutableStateOf(false) } var showAccountScreen by remember { mutableStateOf(false) } 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 } @@ -157,7 +167,13 @@ fun NotebookApp( } } 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) } @@ -167,7 +183,7 @@ fun NotebookApp( // === 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) } @@ -184,14 +200,14 @@ fun StudioScreen(viewModel: NotebookViewModel) { Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + .background(Color.White) .padding(16.dp) ) { Text( "Notebook terbaru", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground + color = Color.Black ) Spacer(modifier = Modifier.height(16.dp)) @@ -207,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) + } ) } } @@ -223,9 +245,7 @@ fun NewNotebookCard(onClick: () -> Unit) { .height(120.dp) .clickable(onClick = onClick), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = CardDefaults.cardColors(containerColor = Color(0xFFF0F4F7)) ) { Column( modifier = Modifier.fillMaxSize(), @@ -236,20 +256,13 @@ fun NewNotebookCard(onClick: () -> Unit) { modifier = Modifier .size(40.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), + .background(Color(0xFFE1E3E6)), contentAlignment = Alignment.Center ) { - Icon( - Icons.Default.Add, - contentDescription = "Buat notebook baru", - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) + Icon(Icons.Default.Add, contentDescription = "Buat notebook baru", tint = Color.Black) } Spacer(modifier = Modifier.height(8.dp)) - Text( - "Buat notebook baru", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text("Buat notebook baru", color = Color.Black) } } } @@ -263,13 +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 = MaterialTheme.colorScheme.surface - ) + colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)), + onClick = { + println("🔵 Card onClick: ID=${notebook.id}, Title=${notebook.title}") + onClick() + } ) { Row( modifier = Modifier @@ -282,13 +295,13 @@ fun NotebookCard( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), + .background(Color(0xFFE8EAF6)), contentAlignment = Alignment.Center ) { Icon( Icons.Default.Description, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer + tint = Color(0xFF5C6BC0) ) } @@ -301,15 +314,14 @@ fun NotebookCard( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) Text( text = if (notebook.description.isNotBlank()) notebook.description else "Belum ada deskripsi", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = Color.Gray, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -317,7 +329,7 @@ fun NotebookCard( Text( text = dateFormat.format(Date(notebook.updatedAt)), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = Color.Gray ) } @@ -326,7 +338,7 @@ fun NotebookCard( Icon( Icons.Default.Delete, contentDescription = "Hapus", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = Color.Gray ) } } @@ -404,7 +416,7 @@ fun SourcesScreen(viewModel: NotebookViewModel) { } } -// === MENU COMPONENTS === +// === MENU COMPONENTS (Tetap sama) === @Composable fun AccountScreen(onDismiss: () -> Unit) { Dialog( @@ -475,7 +487,7 @@ fun SettingsMenu( if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode, contentDescription = null ) - Spacer(modifier = Modifier.width(1.dp)) + Spacer(modifier = Modifier.width(12.dp)) Text(if (isDarkMode) "Mode Gelap" else "Mode Terang") } Switch( diff --git a/app/src/main/java/com/example/notebook/api/ApiConstants.kt b/app/src/main/java/com/example/notebook/api/ApiConstants.kt index 3fa1e29..cbbdfef 100644 --- a/app/src/main/java/com/example/notebook/api/ApiConstants.kt +++ b/app/src/main/java/com/example/notebook/api/ApiConstants.kt @@ -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 = """ diff --git a/app/src/main/java/com/example/notebook/api/GeminiApiService.kt b/app/src/main/java/com/example/notebook/api/GeminiApiService.kt index 1e57b01..23c0bc6 100644 --- a/app/src/main/java/com/example/notebook/api/GeminiApiService.kt +++ b/app/src/main/java/com/example/notebook/api/GeminiApiService.kt @@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit */ interface GeminiApiService { - @POST("v1beta/models/gemini-1.5-flash:generateContent") + @POST("v1beta/models/gemini-2.5-flash:generateContent") suspend fun generateContent( @Query("key") apiKey: String, @Body request: GeminiRequest diff --git a/app/src/main/java/com/example/notebook/api/GeminiModels.kt b/app/src/main/java/com/example/notebook/api/GeminiModels.kt index cbbdc62..772f65d 100644 --- a/app/src/main/java/com/example/notebook/api/GeminiModels.kt +++ b/app/src/main/java/com/example/notebook/api/GeminiModels.kt @@ -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( diff --git a/app/src/main/java/com/example/notebook/api/GeminiRepository.kt b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt index b3cc9cb..da6c209 100644 --- a/app/src/main/java/com/example/notebook/api/GeminiRepository.kt +++ b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt @@ -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 { 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 = 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> = emptyList() + ): Result = 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)) ) ) } diff --git a/app/src/main/java/com/example/notebook/data/NotebookRepository.kt b/app/src/main/java/com/example/notebook/data/NotebookRepository.kt index eb269c4..e5a11cb 100644 --- a/app/src/main/java/com/example/notebook/data/NotebookRepository.kt +++ b/app/src/main/java/com/example/notebook/data/NotebookRepository.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt b/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt index 8571e7a..ac7d4e0 100644 --- a/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt +++ b/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt @@ -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(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, 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 } ) diff --git a/app/src/main/java/com/example/notebook/utils/FileHelper.kt b/app/src/main/java/com/example/notebook/utils/FileHelper.kt index 1c97023..d985cef 100644 --- a/app/src/main/java/com/example/notebook/utils/FileHelper.kt +++ b/app/src/main/java/com/example/notebook/utils/FileHelper.kt @@ -64,23 +64,43 @@ 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 } /** - * Baca text dari file + * Baca text dari file (support Text, Markdown, dan PDF) */ fun readTextFromFile(filePath: String): String? { return try { - File(filePath).readText() + val file = File(filePath) + val extension = file.extension.lowercase() + + when (extension) { + "pdf" -> { + // Extract text dari PDF + PdfHelper.extractTextFromPdf(filePath) + } + "txt", "md", "markdown" -> { + // Baca text biasa + file.readText() + } + else -> { + println("⚠️ Format file tidak didukung untuk ekstraksi teks: $extension") + null + } + } } catch (e: Exception) { e.printStackTrace() null diff --git a/app/src/main/java/com/example/notebook/utils/PdfHelper.kt b/app/src/main/java/com/example/notebook/utils/PdfHelper.kt new file mode 100644 index 0000000..c2fc1bf --- /dev/null +++ b/app/src/main/java/com/example/notebook/utils/PdfHelper.kt @@ -0,0 +1,93 @@ +package com.example.notebook.utils + +import android.content.Context +import android.net.Uri +import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.text.PDFTextStripper +import java.io.File + +/** + * Helper untuk extract text dari PDF + */ +object PdfHelper { + + /** + * Initialize PDFBox (panggil sekali saat app start) + */ + fun initialize(context: Context) { + PDFBoxResourceLoader.init(context) + } + + /** + * Extract text dari PDF file + * Return: Text content atau null jika error + */ + fun extractTextFromPdf(filePath: String): String? { + return try { + val file = File(filePath) + val document = PDDocument.load(file) + + val stripper = PDFTextStripper() + val text = stripper.getText(document) + + document.close() + + // Cleanup whitespace + text.trim() + } catch (e: Exception) { + println("❌ Error extract PDF: ${e.message}") + e.printStackTrace() + null + } + } + + /** + * Extract text dari PDF URI (sebelum disimpan) + */ + fun extractTextFromPdfUri(context: Context, uri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) + val document = PDDocument.load(inputStream) + + val stripper = PDFTextStripper() + val text = stripper.getText(document) + + document.close() + inputStream?.close() + + text.trim() + } catch (e: Exception) { + println("❌ Error extract PDF from URI: ${e.message}") + e.printStackTrace() + null + } + } + + /** + * Get info PDF (jumlah halaman, dll) + */ + fun getPdfInfo(filePath: String): PdfInfo? { + return try { + val file = File(filePath) + val document = PDDocument.load(file) + + val info = PdfInfo( + pageCount = document.numberOfPages, + title = document.documentInformation.title ?: "Unknown", + author = document.documentInformation.author ?: "Unknown" + ) + + document.close() + info + } catch (e: Exception) { + null + } + } +} + +data class PdfInfo( + val pageCount: Int, + val title: String, + val author: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt index d9ea9a1..86d7de0 100644 --- a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt +++ b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt @@ -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) { + 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) { + 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 } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..219ce6a --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file