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