PDF Testing & Q&A
This commit is contained in:
parent
0c04473b43
commit
f64bff7933
@ -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")
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 = """
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
93
app/src/main/java/com/example/notebook/utils/PdfHelper.kt
Normal file
93
app/src/main/java/com/example/notebook/utils/PdfHelper.kt
Normal 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
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
println("📊 Q&A - PDF: ${pdfSources.size}, Text: ${textSources.size}")
|
||||||
|
|
||||||
|
// Build chat history
|
||||||
val chatHistory = _chatMessages.value
|
val chatHistory = _chatMessages.value
|
||||||
.takeLast(10)
|
.takeLast(10)
|
||||||
.filter { it.isUserMessage }
|
.windowed(2, 2, partialWindows = false)
|
||||||
.mapNotNull { userMsg ->
|
.mapNotNull { messages ->
|
||||||
val aiMsg = _chatMessages.value.find {
|
if (messages.size == 2 && messages[0].isUserMessage && !messages[1].isUserMessage) {
|
||||||
!it.isUserMessage && it.timestamp > userMsg.timestamp
|
Pair(messages[0].message, messages[1].message)
|
||||||
}
|
|
||||||
if (aiMsg != null) {
|
|
||||||
Pair(userMsg.message, aiMsg.message)
|
|
||||||
} else null
|
} 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(
|
val result = geminiRepository.chatWithDocument(
|
||||||
userMessage = message,
|
userMessage = message,
|
||||||
documentContext = documentContext,
|
documentContext = documentContext,
|
||||||
chatHistory = chatHistory
|
chatHistory = chatHistory
|
||||||
)
|
)
|
||||||
|
handleChatResult(notebookId, result)
|
||||||
result.fold(
|
} else {
|
||||||
onSuccess = { aiResponse ->
|
println("⚠️ Using Gemini Vision (${extractedText?.length ?: 0} chars)")
|
||||||
repository.sendMessage(notebookId, aiResponse, isUserMessage = false)
|
val result = geminiRepository.chatWithPdfFile(
|
||||||
println("✅ AI response berhasil: ${aiResponse.take(50)}...")
|
userMessage = message,
|
||||||
},
|
pdfFilePath = pdfSource.filePath,
|
||||||
onFailure = { error ->
|
pdfFileName = pdfSource.fileName,
|
||||||
val errorMsg = "Maaf, terjadi error: ${error.message}"
|
chatHistory = chatHistory
|
||||||
repository.sendMessage(notebookId, errorMsg, isUserMessage = false)
|
|
||||||
println("❌ Error dari Gemini: ${error.message}")
|
|
||||||
_errorMessage.value = error.message
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
handleChatResult(notebookId, result)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
println("📝 Q&A using text files only")
|
||||||
|
val documentContext = buildDocumentContext()
|
||||||
|
val result = geminiRepository.chatWithDocument(
|
||||||
|
userMessage = message,
|
||||||
|
documentContext = documentContext,
|
||||||
|
chatHistory = chatHistory
|
||||||
|
)
|
||||||
|
handleChatResult(notebookId, result)
|
||||||
|
}
|
||||||
|
|
||||||
} 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,15 +327,55 @@ 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}")
|
||||||
|
|
||||||
|
// 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}")
|
||||||
|
} finally {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSummaryResult(notebookId: Int, result: Result<String>) {
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { summary ->
|
onSuccess = { summary ->
|
||||||
repository.sendMessage(
|
repository.sendMessage(
|
||||||
@ -291,13 +390,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application
|
|||||||
println("❌ Error generate summary: ${error.message}")
|
println("❌ Error generate summary: ${error.message}")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
|
||||||
_errorMessage.value = e.message
|
|
||||||
println("❌ Error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
_isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
|
|||||||
11
app/src/main/res/xml/file_paths.xml
Normal file
11
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user