PDF Testing & Q&A

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-11-16 19:16:16 +07:00
parent 0c04473b43
commit f64bff7933
13 changed files with 724 additions and 98 deletions

View File

@ -91,4 +91,7 @@ dependencies {
// Gson // Gson
implementation("com.google.code.gson:gson:2.10.1") 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")
} }

View File

@ -18,6 +18,17 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Notebook"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -40,6 +40,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Initialize PDFBox
com.example.notebook.utils.PdfHelper.initialize(this)
setContent { setContent {
var isDarkMode by remember { mutableStateOf(false) } var isDarkMode by remember { mutableStateOf(false) }
@ -61,24 +65,30 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotebookApp( fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange: (Boolean) -> Unit) {
viewModel: NotebookViewModel,
isDarkMode: Boolean,
onThemeChange: (Boolean) -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Studio", "Chat", "Sources") val tabs = listOf("Studio", "Chat", "Sources")
var showGoogleAppsMenu by remember { mutableStateOf(false) }
var showSettingsMenu by remember { mutableStateOf(false) } var showSettingsMenu by remember { mutableStateOf(false) }
var showAccountScreen by remember { mutableStateOf(false) } var showAccountScreen by remember { mutableStateOf(false) }
var chatInput by remember { mutableStateOf("") } var chatInput by remember { mutableStateOf("") }
var selectedNotebookId by remember { mutableIntStateOf(-1) } 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 // Kalau ada notebook yang dipilih, tampilkan detail screen
if (selectedNotebookId != -1) { if (selectedNotebookId != -1) {
println("✨ Menampilkan NotebookDetailScreen untuk ID: $selectedNotebookId")
com.example.notebook.ui.screens.NotebookDetailScreen( com.example.notebook.ui.screens.NotebookDetailScreen(
viewModel = viewModel, viewModel = viewModel,
notebookId = selectedNotebookId, notebookId = selectedNotebookId,
onBack = { selectedNotebookId = -1 } onBack = {
println("⬅️ Kembali dari detail screen")
selectedNotebookId = -1
}
) )
return return
} }
@ -157,7 +167,13 @@ fun NotebookApp(
} }
} }
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> StudioScreen(viewModel) 0 -> StudioScreen(
viewModel = viewModel,
onNotebookClick = { notebookId ->
println("📱 Navigasi ke notebook ID: $notebookId")
selectedNotebookId = notebookId
}
)
1 -> ChatScreen(viewModel) 1 -> ChatScreen(viewModel)
2 -> SourcesScreen(viewModel) 2 -> SourcesScreen(viewModel)
} }
@ -167,7 +183,7 @@ fun NotebookApp(
// === STUDIO SCREEN (UPDATED) === // === STUDIO SCREEN (UPDATED) ===
@Composable @Composable
fun StudioScreen(viewModel: NotebookViewModel) { fun StudioScreen(viewModel: NotebookViewModel, onNotebookClick: (Int) -> Unit) {
val notebooks by viewModel.notebooks.collectAsState() val notebooks by viewModel.notebooks.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
@ -184,14 +200,14 @@ fun StudioScreen(viewModel: NotebookViewModel) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(Color.White)
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
"Notebook terbaru", "Notebook terbaru",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground color = Color.Black
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -207,8 +223,14 @@ fun StudioScreen(viewModel: NotebookViewModel) {
items(notebooks) { notebook -> items(notebooks) { notebook ->
NotebookCard( NotebookCard(
notebook = notebook, notebook = notebook,
onClick = { /* TODO: Buka notebook */ }, onClick = {
onDelete = { viewModel.deleteNotebook(notebook) } 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) .height(120.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color(0xFFF0F4F7))
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -236,20 +256,13 @@ fun NewNotebookCard(onClick: () -> Unit) {
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer), .background(Color(0xFFE1E3E6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(Icons.Default.Add, contentDescription = "Buat notebook baru", tint = Color.Black)
Icons.Default.Add,
contentDescription = "Buat notebook baru",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text("Buat notebook baru", color = Color.Black)
"Buat notebook baru",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }
@ -263,13 +276,13 @@ fun NotebookCard(
val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault()) val dateFormat = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault())
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)),
containerColor = MaterialTheme.colorScheme.surface onClick = {
) println("🔵 Card onClick: ID=${notebook.id}, Title=${notebook.title}")
onClick()
}
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -282,13 +295,13 @@ fun NotebookCard(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer), .background(Color(0xFFE8EAF6)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Default.Description, Icons.Default.Description,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = Color(0xFF5C6BC0)
) )
} }
@ -301,15 +314,14 @@ fun NotebookCard(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis
color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = if (notebook.description.isNotBlank()) notebook.description text = if (notebook.description.isNotBlank()) notebook.description
else "Belum ada deskripsi", else "Belum ada deskripsi",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = Color.Gray,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -317,7 +329,7 @@ fun NotebookCard(
Text( Text(
text = dateFormat.format(Date(notebook.updatedAt)), text = dateFormat.format(Date(notebook.updatedAt)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = Color.Gray
) )
} }
@ -326,7 +338,7 @@ fun NotebookCard(
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = "Hapus", contentDescription = "Hapus",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = Color.Gray
) )
} }
} }
@ -404,7 +416,7 @@ fun SourcesScreen(viewModel: NotebookViewModel) {
} }
} }
// === MENU COMPONENTS === // === MENU COMPONENTS (Tetap sama) ===
@Composable @Composable
fun AccountScreen(onDismiss: () -> Unit) { fun AccountScreen(onDismiss: () -> Unit) {
Dialog( Dialog(
@ -475,7 +487,7 @@ fun SettingsMenu(
if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode, if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = null contentDescription = null
) )
Spacer(modifier = Modifier.width(1.dp)) Spacer(modifier = Modifier.width(12.dp))
Text(if (isDarkMode) "Mode Gelap" else "Mode Terang") Text(if (isDarkMode) "Mode Gelap" else "Mode Terang")
} }
Switch( Switch(

View File

@ -15,7 +15,7 @@ object ApiConstants {
const val BASE_URL = "https://generativelanguage.googleapis.com/" const val BASE_URL = "https://generativelanguage.googleapis.com/"
// Model yang digunakan (Flash = gratis & cepat) // 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 // System instruction untuk AI
const val SYSTEM_INSTRUCTION = """ const val SYSTEM_INSTRUCTION = """

View File

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

View File

@ -26,7 +26,18 @@ data class Content(
data class Part( data class Part(
@SerializedName("text") @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( data class GenerationConfig(

View File

@ -8,7 +8,7 @@ class GeminiRepository {
private val apiService = GeminiApiService.create() private val apiService = GeminiApiService.create()
/** /**
* Generate summary dari text * Generate summary dari text atau file PDF
*/ */
suspend fun generateSummary(text: String): Result<String> { suspend fun generateSummary(text: String): Result<String> {
return try { 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 * 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 * Create request object dengan system instruction
*/ */
@ -105,7 +287,7 @@ class GeminiRepository {
topP = 0.95 topP = 0.95
), ),
systemInstruction = SystemInstruction( 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) { suspend fun clearChatHistory(notebookId: Int) {
dao.clearChatHistory(notebookId) 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 package com.example.notebook.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.example.notebook.data.ChatMessageEntity import com.example.notebook.data.ChatMessageEntity
import com.example.notebook.data.NotebookEntity import com.example.notebook.data.NotebookEntity
import com.example.notebook.data.SourceEntity import com.example.notebook.data.SourceEntity
import com.example.notebook.viewmodel.NotebookViewModel import com.example.notebook.viewmodel.NotebookViewModel
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotebookDetailScreen( fun NotebookDetailScreen(
@ -44,10 +105,76 @@ fun NotebookDetailScreen(
val notebook by viewModel.currentNotebook.collectAsState() val notebook by viewModel.currentNotebook.collectAsState()
val sources by viewModel.sources.collectAsState() val sources by viewModel.sources.collectAsState()
val chatMessages by viewModel.chatMessages.collectAsState() val chatMessages by viewModel.chatMessages.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Chat", "Sources") val tabs = listOf("Chat", "Sources")
var chatInput by remember { mutableStateOf("") } var chatInput by remember { mutableStateOf("") }
var showUploadMenu by remember { mutableStateOf(false) } 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 // File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
@ -125,6 +252,43 @@ fun NotebookDetailScreen(
}, },
leadingIcon = { Icon(Icons.Default.Summarize, null) } 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, sources = sources,
onUploadClick = { filePickerLauncher.launch("*/*") }, onUploadClick = { filePickerLauncher.launch("*/*") },
onDeleteSource = { source -> onDeleteSource = { source ->
// TODO: Implement delete sourceToDelete = source
showDeleteDialog = true
},
onOpenSource = { source ->
openFile(context, source)
} }
) )
} }
@ -319,7 +487,8 @@ fun ChatBubble(message: ChatMessageEntity) {
fun SourcesTab( fun SourcesTab(
sources: List<SourceEntity>, sources: List<SourceEntity>,
onUploadClick: () -> Unit, onUploadClick: () -> Unit,
onDeleteSource: (SourceEntity) -> Unit onDeleteSource: (SourceEntity) -> Unit,
onOpenSource: (SourceEntity) -> Unit
) { ) {
if (sources.isEmpty()) { if (sources.isEmpty()) {
Column( Column(
@ -364,7 +533,8 @@ fun SourcesTab(
items(sources) { source -> items(sources) { source ->
SourceCard( SourceCard(
source = source, source = source,
onDelete = { onDeleteSource(source) } onDelete = { onDeleteSource(source) },
onOpen = { onOpenSource(source) }
) )
} }
} }
@ -372,11 +542,17 @@ fun SourcesTab(
} }
@Composable @Composable
fun SourceCard(source: SourceEntity, onDelete: () -> Unit) { fun SourceCard(
source: SourceEntity,
onDelete: () -> Unit,
onOpen: () -> Unit
) {
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clickable { onOpen() },
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
) { ) {
Row( Row(
@ -396,6 +572,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Color(0xFF43A047) "Image" -> Color(0xFF43A047)
"Text", "Markdown" -> Color(0xFF1E88E5) "Text", "Markdown" -> Color(0xFF1E88E5)
"Audio" -> Color(0xFFFF6F00) "Audio" -> Color(0xFFFF6F00)
"Word" -> Color(0xFF2196F3)
"PowerPoint" -> Color(0xFFFF6D00)
else -> Color.Gray else -> Color.Gray
}.copy(alpha = 0.1f) }.copy(alpha = 0.1f)
), ),
@ -407,6 +585,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Icons.Default.Image "Image" -> Icons.Default.Image
"Text", "Markdown" -> Icons.Default.Description "Text", "Markdown" -> Icons.Default.Description
"Audio" -> Icons.Default.AudioFile "Audio" -> Icons.Default.AudioFile
"Word" -> Icons.Default.Article
"PowerPoint" -> Icons.Default.Slideshow
else -> Icons.Default.InsertDriveFile else -> Icons.Default.InsertDriveFile
}, },
contentDescription = null, contentDescription = null,
@ -415,6 +595,8 @@ fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
"Image" -> Color(0xFF43A047) "Image" -> Color(0xFF43A047)
"Text", "Markdown" -> Color(0xFF1E88E5) "Text", "Markdown" -> Color(0xFF1E88E5)
"Audio" -> Color(0xFFFF6F00) "Audio" -> Color(0xFFFF6F00)
"Word" -> Color(0xFF2196F3)
"PowerPoint" -> Color(0xFFFF6D00)
else -> Color.Gray else -> Color.Gray
} }
) )

View File

@ -64,23 +64,43 @@ object FileHelper {
* Deteksi tipe file dari extension * Deteksi tipe file dari extension
*/ */
fun getFileType(fileName: String): String { fun getFileType(fileName: String): String {
return when (fileName.substringAfterLast('.').lowercase()) { val type = when (fileName.substringAfterLast('.').lowercase()) {
"pdf" -> "PDF" "pdf" -> "PDF"
"txt" -> "Text" "txt" -> "Text"
"md", "markdown" -> "Markdown" "md", "markdown" -> "Markdown"
"jpg", "jpeg", "png", "gif" -> "Image" "jpg", "jpeg", "png", "gif" -> "Image"
"mp3", "wav", "m4a" -> "Audio" "mp3", "wav", "m4a" -> "Audio"
"mp4", "avi", "mkv" -> "Video" "mp4", "avi", "mkv" -> "Video"
"doc", "docx" -> "Word"
"ppt", "pptx" -> "PowerPoint"
else -> "Unknown" 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? { fun readTextFromFile(filePath: String): String? {
return try { 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null

View File

@ -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
)

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* ViewModel untuk manage state aplikasi * ViewModel untuk manage state aplikasi
@ -185,47 +186,81 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
try { try {
// Simpan pesan user
repository.sendMessage(notebookId, message, isUserMessage = true) 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." val reply = "Maaf, belum ada dokumen yang diupload. Silakan upload dokumen terlebih dahulu untuk bisa bertanya."
repository.sendMessage(notebookId, reply, isUserMessage = false) repository.sendMessage(notebookId, reply, isUserMessage = false)
} else { return@launch
val chatHistory = _chatMessages.value }
.takeLast(10)
.filter { it.isUserMessage } println("📊 Q&A - PDF: ${pdfSources.size}, Text: ${textSources.size}")
.mapNotNull { userMsg ->
val aiMsg = _chatMessages.value.find { // Build chat history
!it.isUserMessage && it.timestamp > userMsg.timestamp val chatHistory = _chatMessages.value
} .takeLast(10)
if (aiMsg != null) { .windowed(2, 2, partialWindows = false)
Pair(userMsg.message, aiMsg.message) .mapNotNull { messages ->
} else null 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( val result = geminiRepository.chatWithDocument(
userMessage = message, userMessage = message,
documentContext = documentContext, documentContext = documentContext,
chatHistory = chatHistory chatHistory = chatHistory
) )
handleChatResult(notebookId, result)
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
}
)
} }
} catch (e: Exception) { } 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 _errorMessage.value = e.message
} finally { } finally {
_isLoading.value = false _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) { fun clearChatHistory(notebookId: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.clearChatHistory(notebookId) repository.clearChatHistory(notebookId)
@ -244,23 +294,32 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
private fun buildDocumentContext(): String { private fun buildDocumentContext(): String {
val context = StringBuilder() val context = StringBuilder()
println("🔍 Building document context dari ${_sources.value.size} sources")
_sources.value.forEach { source -> _sources.value.forEach { source ->
println("📋 Source: ${source.fileName} | Type: ${source.fileType}")
when (source.fileType) { when (source.fileType) {
"Text", "Markdown" -> { "Text", "Markdown", "PDF" -> { // ← TAMBAH "PDF" DI SINI!
val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath) 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("=== ${source.fileName} ===\n")
context.append(content) context.append(content)
context.append("\n\n") context.append("\n\n")
println("✅ Extracted ${content.length} chars from ${source.fileName}")
} else {
println("⚠️ Failed to extract from ${source.fileName}")
} }
} }
else -> { else -> {
println("⚠️ File type ${source.fileType} not supported")
context.append("=== ${source.fileName} ===\n") context.append("=== ${source.fileName} ===\n")
context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n") context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n")
} }
} }
} }
println("📦 Total context length: ${context.length} chars")
return context.toString() return context.toString()
} }
@ -268,29 +327,45 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
try { 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" _errorMessage.value = "Tidak ada dokumen untuk diringkas"
return@launch 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( // val extractedText = com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
onSuccess = { summary -> val extractedText = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
repository.sendMessage( com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath)
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}")
} }
) 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) { } catch (e: Exception) {
_errorMessage.value = e.message _errorMessage.value = e.message
println("❌ Error: ${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() { fun clearError() {
_errorMessage.value = null _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>