Compare commits

..

No commits in common. "f64bff7933c3b7c9e84bbbca5b33a60d66d19894" and "2ac039384738750d31e200ecaab902719e4393f4" have entirely different histories.

12 changed files with 95 additions and 643 deletions

View File

@ -68,7 +68,7 @@ dependencies {
debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest) debugImplementation(libs.ui.test.manifest)
// Room Database - TAMBAHKAN INI SEMUA // Room Database
val roomVersion = "2.6.1" val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion")
@ -92,6 +92,6 @@ 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 // PDF Support
implementation("com.tom-roush:pdfbox-android:2.0.27.0") implementation("com.tom-roush:pdfbox-android:2.0.27.0")
} }

View File

@ -18,17 +18,6 @@
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,23 +40,13 @@ 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) } NotebookTheme {
NotebookTheme(darkTheme = isDarkMode) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
NotebookApp( NotebookApp(viewModel = viewModel)
viewModel = viewModel,
isDarkMode = isDarkMode,
onThemeChange = { isDarkMode = it }
)
} }
} }
} }
@ -65,7 +55,7 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange: (Boolean) -> Unit) { fun NotebookApp(viewModel: NotebookViewModel) {
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 showGoogleAppsMenu by remember { mutableStateOf(false) }
@ -74,21 +64,12 @@ fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange
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 = { onBack = { selectedNotebookId = -1 }
println("⬅️ Kembali dari detail screen")
selectedNotebookId = -1
}
) )
return return
} }
@ -106,12 +87,13 @@ fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange
IconButton(onClick = { showSettingsMenu = true }) { IconButton(onClick = { showSettingsMenu = true }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings") Icon(Icons.Filled.Settings, contentDescription = "Settings")
} }
SettingsMenu( SettingsMenu(expanded = showSettingsMenu, onDismiss = { showSettingsMenu = false })
expanded = showSettingsMenu, }
onDismiss = { showSettingsMenu = false }, Box {
isDarkMode = isDarkMode, IconButton(onClick = { showGoogleAppsMenu = true }) {
onThemeChange = onThemeChange Icon(Icons.Filled.Apps, contentDescription = "Google Apps")
) }
GoogleAppsMenu(expanded = showGoogleAppsMenu, onDismiss = { showGoogleAppsMenu = false })
} }
Box( Box(
modifier = Modifier modifier = Modifier
@ -167,13 +149,7 @@ fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange
} }
} }
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> StudioScreen( 0 -> StudioScreen(viewModel)
viewModel = viewModel,
onNotebookClick = { notebookId ->
println("📱 Navigasi ke notebook ID: $notebookId")
selectedNotebookId = notebookId
}
)
1 -> ChatScreen(viewModel) 1 -> ChatScreen(viewModel)
2 -> SourcesScreen(viewModel) 2 -> SourcesScreen(viewModel)
} }
@ -183,7 +159,7 @@ fun NotebookApp(viewModel: NotebookViewModel, isDarkMode: Boolean, onThemeChange
// === STUDIO SCREEN (UPDATED) === // === STUDIO SCREEN (UPDATED) ===
@Composable @Composable
fun StudioScreen(viewModel: NotebookViewModel, onNotebookClick: (Int) -> Unit) { fun StudioScreen(viewModel: NotebookViewModel) {
val notebooks by viewModel.notebooks.collectAsState() val notebooks by viewModel.notebooks.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
@ -223,14 +199,8 @@ fun StudioScreen(viewModel: NotebookViewModel, onNotebookClick: (Int) -> Unit) {
items(notebooks) { notebook -> items(notebooks) { notebook ->
NotebookCard( NotebookCard(
notebook = notebook, notebook = notebook,
onClick = { onClick = { /* TODO: Buka notebook */ },
println("🟢 onClick triggered untuk notebook ID: ${notebook.id}") onDelete = { viewModel.deleteNotebook(notebook) }
onNotebookClick(notebook.id)
},
onDelete = {
println("🔴 Delete triggered untuk notebook ID: ${notebook.id}")
viewModel.deleteNotebook(notebook)
}
) )
} }
} }
@ -276,13 +246,11 @@ 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.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)), colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
onClick = {
println("🔵 Card onClick: ID=${notebook.id}, Title=${notebook.title}")
onClick()
}
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -467,44 +435,30 @@ fun AccountScreen(onDismiss: () -> Unit) {
} }
@Composable @Composable
fun SettingsMenu( fun SettingsMenu(expanded: Boolean, onDismiss: () -> Unit) {
expanded: Boolean,
onDismiss: () -> Unit,
isDarkMode: Boolean,
onThemeChange: (Boolean) -> Unit
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
// Dark Mode Toggle
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Text(if (isDarkMode) "Mode Gelap" else "Mode Terang")
}
Switch(
checked = isDarkMode,
onCheckedChange = onThemeChange
)
}
},
onClick = { onThemeChange(!isDarkMode) }
)
Divider()
DropdownMenuItem( DropdownMenuItem(
text = { Text("NotebookLM Help") }, text = { Text("NotebookLM Help") },
onClick = { }, onClick = { },
leadingIcon = { Icon(Icons.Default.HelpOutline, contentDescription = null) } leadingIcon = { Icon(Icons.Default.HelpOutline, contentDescription = null) }
) )
} }
}
@Composable
fun GoogleAppsMenu(expanded: Boolean, onDismiss: () -> Unit) {
val apps = listOf(
"Account" to Icons.Default.AccountCircle,
"Gmail" to Icons.Default.Mail,
"Drive" to Icons.Default.Cloud
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
apps.forEach { (name, icon) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { },
leadingIcon = { Icon(icon, contentDescription = name) }
)
}
}
} }

View File

@ -15,7 +15,7 @@ object ApiConstants {
const val BASE_URL = "https://generativelanguage.googleapis.com/" 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.5-flash" const val MODEL_NAME = "gemini-2.0-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-2.5-flash:generateContent") @POST("v1beta/models/gemini-2.0-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,18 +26,7 @@ data class Content(
data class Part( data class Part(
@SerializedName("text") @SerializedName("text")
val text: String? = null, val text: String
@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 atau file PDF * Generate summary dari text
*/ */
suspend fun generateSummary(text: String): Result<String> { suspend fun generateSummary(text: String): Result<String> {
return try { return try {
@ -43,93 +43,6 @@ 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
*/ */
@ -175,101 +88,6 @@ 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
*/ */
@ -287,7 +105,7 @@ class GeminiRepository {
topP = 0.95 topP = 0.95
), ),
systemInstruction = SystemInstruction( systemInstruction = SystemInstruction(
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION)) parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION.trimIndent()))
) )
) )
} }

View File

@ -128,13 +128,4 @@ 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,7 +1,5 @@
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
@ -28,72 +26,13 @@ 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(
@ -105,76 +44,10 @@ 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(
@ -252,43 +125,6 @@ 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) }
)
} }
} }
} }
@ -346,11 +182,7 @@ fun NotebookDetailScreen(
sources = sources, sources = sources,
onUploadClick = { filePickerLauncher.launch("*/*") }, onUploadClick = { filePickerLauncher.launch("*/*") },
onDeleteSource = { source -> onDeleteSource = { source ->
sourceToDelete = source // TODO: Implement delete
showDeleteDialog = true
},
onOpenSource = { source ->
openFile(context, source)
} }
) )
} }
@ -487,8 +319,7 @@ 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(
@ -533,8 +364,7 @@ fun SourcesTab(
items(sources) { source -> items(sources) { source ->
SourceCard( SourceCard(
source = source, source = source,
onDelete = { onDeleteSource(source) }, onDelete = { onDeleteSource(source) }
onOpen = { onOpenSource(source) }
) )
} }
} }
@ -542,17 +372,11 @@ fun SourcesTab(
} }
@Composable @Composable
fun SourceCard( fun SourceCard(source: SourceEntity, onDelete: () -> Unit) {
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 modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.clickable { onOpen() },
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F9FA))
) { ) {
Row( Row(
@ -572,8 +396,6 @@ fun SourceCard(
"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)
), ),
@ -585,8 +407,6 @@ fun SourceCard(
"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,
@ -595,8 +415,6 @@ fun SourceCard(
"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,19 +64,15 @@ object FileHelper {
* Deteksi tipe file dari extension * Deteksi tipe file dari extension
*/ */
fun getFileType(fileName: String): String { fun getFileType(fileName: String): String {
val type = when (fileName.substringAfterLast('.').lowercase()) { return 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
} }
/** /**

View File

@ -13,7 +13,6 @@ 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
@ -186,81 +185,47 @@ 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)
// Cek apakah ada PDF val documentContext = buildDocumentContext()
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
if (pdfSources.isEmpty() && textSources.isEmpty()) { if (documentContext.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)
return@launch } else {
} val chatHistory = _chatMessages.value
.takeLast(10)
println("📊 Q&A - PDF: ${pdfSources.size}, Text: ${textSources.size}") .filter { it.isUserMessage }
.mapNotNull { userMsg ->
// Build chat history val aiMsg = _chatMessages.value.find {
val chatHistory = _chatMessages.value !it.isUserMessage && it.timestamp > userMsg.timestamp
.takeLast(10) }
.windowed(2, 2, partialWindows = false) if (aiMsg != null) {
.mapNotNull { messages -> Pair(userMsg.message, aiMsg.message)
if (messages.size == 2 && messages[0].isUserMessage && !messages[1].isUserMessage) { } else null
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 Q&A: ${e.message}") println("❌ Error mengirim pesan: ${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
@ -268,21 +233,6 @@ 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)
@ -294,32 +244,23 @@ 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", "PDF" -> { // ← TAMBAH "PDF" DI SINI! "Text", "Markdown" -> {
val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath) val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath)
if (content != null && content.isNotBlank()) { if (content != null) {
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()
} }
@ -327,45 +268,29 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
try { try {
// Cek apakah ada PDF val documentContext = buildDocumentContext()
val pdfSources = _sources.value.filter { it.fileType == "PDF" }
val textSources = _sources.value.filter { it.fileType in listOf("Text", "Markdown") }
if (pdfSources.isEmpty() && textSources.isEmpty()) { if (documentContext.isEmpty()) {
_errorMessage.value = "Tidak ada dokumen untuk diringkas" _errorMessage.value = "Tidak ada dokumen untuk diringkas"
return@launch return@launch
} }
// Untuk PDF, coba extract dulu val result = geminiRepository.generateSummary(documentContext)
if (pdfSources.isNotEmpty()) {
val pdfSource = pdfSources.first()
println("📄 Processing PDF: ${pdfSource.fileName}")
// val extractedText = com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath) result.fold(
val extractedText = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { onSuccess = { summary ->
com.example.notebook.utils.FileHelper.readTextFromFile(pdfSource.filePath) repository.sendMessage(
} notebookId,
if (extractedText != null && extractedText.length > 100) { "📝 Ringkasan Dokumen:\n\n$summary",
// Text extraction berhasil isUserMessage = false
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) println("✅ Summary berhasil: ${summary.take(100)}...")
},
onFailure = { error ->
_errorMessage.value = "Error generate summary: ${error.message}"
println("❌ Error generate summary: ${error.message}")
} }
} 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}")
@ -375,23 +300,6 @@ 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

@ -1,11 +0,0 @@
<?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>