Upload File dan Summary
This commit is contained in:
parent
978b4285bb
commit
2ee345e250
@ -81,9 +81,29 @@ dependencies {
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
// Gemini AI
|
||||
implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
|
||||
// File picker
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// JSON parsing (untuk tool calls)
|
||||
implementation("org.json:json:20230227")
|
||||
// PDF Parser (ONLY THIS ONE!)
|
||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
||||
|
||||
// File operations
|
||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
||||
}
|
||||
|
||||
android {
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
excludes += "/META-INF/DEPENDENCIES"
|
||||
excludes += "/META-INF/LICENSE"
|
||||
excludes += "/META-INF/LICENSE.txt"
|
||||
excludes += "/META-INF/license.txt"
|
||||
excludes += "/META-INF/NOTICE"
|
||||
excludes += "/META-INF/NOTICE.txt"
|
||||
excludes += "/META-INF/notice.txt"
|
||||
excludes += "/META-INF/ASL2.0"
|
||||
excludes += "/META-INF/*.kotlin_module"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,17 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read files -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<!-- For Android 13+ -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".NotesAIApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
||||
@ -90,6 +90,15 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sortCategories(categories: List<Category>): List<Category> {
|
||||
return categories
|
||||
.filter { !it.isDeleted }
|
||||
.sortedWith(
|
||||
compareByDescending<Category> { it.isPinned } // Pinned dulu
|
||||
.thenByDescending { it.timestamp } // Lalu timestamp
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotesApp() {
|
||||
|
||||
13
app/src/main/java/com/example/notesai/NotesAIApplication.kt
Normal file
13
app/src/main/java/com/example/notesai/NotesAIApplication.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.example.notesai
|
||||
|
||||
import android.app.Application
|
||||
import com.example.notesai.util.FileParser
|
||||
|
||||
class NotesAIApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize PDFBox
|
||||
FileParser.initPDFBox(this)
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,8 @@ data class SerializableCategory(
|
||||
val gradientStart: Long,
|
||||
val gradientEnd: Long,
|
||||
val timestamp: Long,
|
||||
val isDeleted: Boolean = false
|
||||
val isDeleted: Boolean = false,
|
||||
val isPinned: Boolean = false // NEW
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@ -77,7 +78,8 @@ class DataStoreManager(private val context: Context) {
|
||||
gradientStart = it.gradientStart,
|
||||
gradientEnd = it.gradientEnd,
|
||||
timestamp = it.timestamp,
|
||||
isDeleted = it.isDeleted
|
||||
isDeleted = it.isDeleted,
|
||||
isPinned = it.isPinned // NEW
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -147,7 +149,8 @@ class DataStoreManager(private val context: Context) {
|
||||
gradientStart = it.gradientStart,
|
||||
gradientEnd = it.gradientEnd,
|
||||
timestamp = it.timestamp,
|
||||
isDeleted = it.isDeleted
|
||||
isDeleted = it.isDeleted,
|
||||
isPinned = it.isPinned // NEW
|
||||
)
|
||||
}
|
||||
preferences[CATEGORIES_KEY] = json.encodeToString(serializable)
|
||||
|
||||
@ -10,5 +10,6 @@ data class Category(
|
||||
val gradientStart: Long,
|
||||
val gradientEnd: Long,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val isDeleted: Boolean = false // Support soft delete
|
||||
val isDeleted: Boolean = false,
|
||||
val isPinned: Boolean = false // NEW: Tambahkan ini
|
||||
)
|
||||
@ -3,7 +3,6 @@ package com.example.notesai.data.model
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class SerializableCategory(
|
||||
val id: String,
|
||||
@ -11,7 +10,8 @@ data class SerializableCategory(
|
||||
val gradientStart: Long,
|
||||
val gradientEnd: Long,
|
||||
val timestamp: Long,
|
||||
val isDeleted: Boolean = false // TAMBAHKAN INI
|
||||
val isDeleted: Boolean = false,
|
||||
val isPinned: Boolean = false // NEW: Tambahkan ini
|
||||
)
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@ -34,7 +34,8 @@ fun Category.toSerializable() = SerializableCategory(
|
||||
gradientStart = gradientStart,
|
||||
gradientEnd = gradientEnd,
|
||||
timestamp = timestamp,
|
||||
isDeleted = isDeleted // TAMBAHKAN INI
|
||||
isDeleted = isDeleted,
|
||||
isPinned = isPinned // NEW: Tambahkan ini
|
||||
)
|
||||
|
||||
fun SerializableCategory.toCategory() = Category(
|
||||
@ -43,7 +44,8 @@ fun SerializableCategory.toCategory() = Category(
|
||||
gradientStart = gradientStart,
|
||||
gradientEnd = gradientEnd,
|
||||
timestamp = timestamp,
|
||||
isDeleted = isDeleted // TAMBAHKAN INI
|
||||
isDeleted = isDeleted,
|
||||
isPinned = isPinned // NEW: Tambahkan ini
|
||||
)
|
||||
|
||||
fun Note.toSerializable() = SerializableNote(
|
||||
|
||||
@ -14,7 +14,6 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@ -32,11 +31,9 @@ import com.google.ai.client.generativeai.type.generationConfig
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import com.example.notesai.presentation.screens.ai.components.ChatBubble
|
||||
import com.example.notesai.presentation.screens.ai.components.ChatHistoryDrawer
|
||||
import com.example.notesai.presentation.screens.ai.components.CompactStatItem
|
||||
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
|
||||
import com.example.notesai.presentation.screens.ai.components.*
|
||||
import com.example.notesai.util.AppColors
|
||||
import com.example.notesai.util.FileParseResult
|
||||
|
||||
private const val MAX_CHAT_TITLE_LENGTH = 30
|
||||
|
||||
@ -67,6 +64,10 @@ fun AIHelperScreen(
|
||||
var showHistoryDrawer by remember { mutableStateOf(false) }
|
||||
var currentChatId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// NEW: File Upload States
|
||||
var uploadedFile by remember { mutableStateOf<FileParseResult.Success?>(null) }
|
||||
var isGeneratingSummary by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scrollState = rememberScrollState()
|
||||
@ -134,6 +135,8 @@ fun AIHelperScreen(
|
||||
currentChatId = null
|
||||
errorMessage = ""
|
||||
showHistoryDrawer = false
|
||||
uploadedFile = null
|
||||
isGeneratingSummary = false
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@ -277,7 +280,8 @@ fun AIHelperScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
.padding(32.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
@ -333,6 +337,70 @@ fun AIHelperScreen(
|
||||
SuggestionChip("Buat ringkasan") { prompt = it }
|
||||
SuggestionChip("Berikan saran organisasi") { prompt = it }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// NEW: File Upload Button
|
||||
FileUploadButton(
|
||||
onFileSelected = { fileResult ->
|
||||
uploadedFile = fileResult
|
||||
|
||||
// Auto-generate summary
|
||||
scope.launch {
|
||||
isGeneratingSummary = true
|
||||
|
||||
try {
|
||||
val summaryPrompt = """
|
||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
||||
|
||||
Judul File: ${fileResult.fileName}
|
||||
Tipe: ${fileResult.fileType}
|
||||
Jumlah Kata: ${fileResult.wordCount}
|
||||
|
||||
Konten:
|
||||
${fileResult.content}
|
||||
|
||||
Buat ringkasan yang:
|
||||
1. Mencakup poin-poin utama
|
||||
2. Terstruktur dengan baik (gunakan markdown)
|
||||
3. Mudah dipahami
|
||||
4. Maksimal 300 kata
|
||||
""".trimIndent()
|
||||
|
||||
// Add user message
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
||||
isUser = true
|
||||
)
|
||||
|
||||
// Scroll to bottom to show loading
|
||||
delay(100)
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
|
||||
// Generate summary dengan Gemini
|
||||
val response = generativeModel.generateContent(summaryPrompt)
|
||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
||||
|
||||
// Add AI response
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = summary,
|
||||
isUser = false
|
||||
)
|
||||
|
||||
saveChatHistory()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
||||
} finally {
|
||||
isGeneratingSummary = false
|
||||
uploadedFile = null
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
errorMessage = error
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Chat Messages
|
||||
@ -345,7 +413,7 @@ fun AIHelperScreen(
|
||||
chatMessages.forEach { message ->
|
||||
ChatBubble(
|
||||
message = message,
|
||||
onCopy = { textToCopy -> // CHANGED: Sekarang terima parameter text
|
||||
onCopy = { textToCopy ->
|
||||
clipboardManager.setText(AnnotatedString(textToCopy))
|
||||
copiedMessageId = message.id
|
||||
showCopiedMessage = true
|
||||
@ -359,8 +427,8 @@ fun AIHelperScreen(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Loading Indicator
|
||||
if (isLoading) {
|
||||
// Loading Indicator (untuk chat biasa DAN file summary)
|
||||
if (isLoading || isGeneratingSummary) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -378,11 +446,11 @@ fun AIHelperScreen(
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = AppColors.Primary,
|
||||
color = if (isGeneratingSummary) AppColors.Secondary else AppColors.Primary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Text(
|
||||
"AI sedang berpikir...",
|
||||
if (isGeneratingSummary) "Membuat ringkasan..." else "AI sedang berpikir...",
|
||||
color = AppColors.OnSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
@ -427,118 +495,182 @@ fun AIHelperScreen(
|
||||
shadowElevation = 8.dp,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = prompt,
|
||||
onValueChange = { prompt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
"Ketik pesan...",
|
||||
color = AppColors.OnSurfaceTertiary
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp, max = 120.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = AppColors.OnBackground,
|
||||
unfocusedTextColor = AppColors.OnSurface,
|
||||
focusedContainerColor = AppColors.SurfaceVariant,
|
||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||
cursorColor = AppColors.Primary,
|
||||
focusedBorderColor = AppColors.Primary,
|
||||
unfocusedBorderColor = Color.Transparent
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
maxLines = 4
|
||||
)
|
||||
// NEW: Upload File Button (di atas input text)
|
||||
if (chatMessages.isNotEmpty() && !isGeneratingSummary && !isLoading) {
|
||||
FileUploadButton(
|
||||
onFileSelected = { fileResult ->
|
||||
uploadedFile = fileResult
|
||||
|
||||
// Send Button
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (prompt.isNotBlank() && !isLoading) {
|
||||
scope.launch {
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = prompt,
|
||||
isUser = true
|
||||
)
|
||||
|
||||
val userPrompt = prompt
|
||||
prompt = ""
|
||||
isLoading = true
|
||||
errorMessage = ""
|
||||
isGeneratingSummary = true
|
||||
|
||||
try {
|
||||
val filteredNotes = if (selectedCategory != null) {
|
||||
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
|
||||
} else {
|
||||
notes.filter { !it.isArchived }
|
||||
}
|
||||
val summaryPrompt = """
|
||||
Buatkan ringkasan dari dokumen berikut dalam bahasa Indonesia:
|
||||
|
||||
val notesContext = buildString {
|
||||
appendLine("Data catatan pengguna:")
|
||||
appendLine("Total catatan: ${filteredNotes.size}")
|
||||
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
|
||||
appendLine()
|
||||
appendLine("Daftar catatan:")
|
||||
filteredNotes.take(10).forEach { note ->
|
||||
appendLine("- Judul: ${note.title}")
|
||||
appendLine(" Isi: ${note.content.take(100)}")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
Judul File: ${fileResult.fileName}
|
||||
Tipe: ${fileResult.fileType}
|
||||
Jumlah Kata: ${fileResult.wordCount}
|
||||
|
||||
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
||||
val result = generativeModel.generateContent(fullPrompt)
|
||||
val response = result.text ?: "Tidak ada respons dari AI"
|
||||
Konten:
|
||||
${fileResult.content}
|
||||
|
||||
Buat ringkasan yang:
|
||||
1. Mencakup poin-poin utama
|
||||
2. Terstruktur dengan baik (gunakan markdown)
|
||||
3. Mudah dipahami
|
||||
4. Maksimal 300 kata
|
||||
""".trimIndent()
|
||||
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = response,
|
||||
message = "📄 Upload file: ${fileResult.fileName}\n\nMohon buatkan ringkasan dari file ini.",
|
||||
isUser = true
|
||||
)
|
||||
|
||||
// Scroll to show loading
|
||||
delay(100)
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
|
||||
val response = generativeModel.generateContent(summaryPrompt)
|
||||
val summary = response.text ?: "Gagal membuat ringkasan"
|
||||
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = summary,
|
||||
isUser = false
|
||||
)
|
||||
|
||||
// Auto-save chat history
|
||||
saveChatHistory()
|
||||
} catch (e: Exception) {
|
||||
// Better error handling
|
||||
errorMessage = when {
|
||||
e.message?.contains("quota", ignoreCase = true) == true ->
|
||||
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
|
||||
e.message?.contains("404", ignoreCase = true) == true ||
|
||||
e.message?.contains("not found", ignoreCase = true) == true ->
|
||||
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
|
||||
e.message?.contains("401", ignoreCase = true) == true ||
|
||||
e.message?.contains("API key", ignoreCase = true) == true ->
|
||||
"⚠️ API key tidak valid. Silakan hubungi developer."
|
||||
e.message?.contains("timeout", ignoreCase = true) == true ->
|
||||
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
|
||||
e.message?.contains("network", ignoreCase = true) == true ->
|
||||
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
|
||||
else ->
|
||||
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
|
||||
}
|
||||
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
isGeneratingSummary = false
|
||||
uploadedFile = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = AppColors.Primary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
},
|
||||
onError = { error ->
|
||||
errorMessage = error
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Text Input & Send Button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = prompt,
|
||||
onValueChange = { prompt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
"Ketik pesan...",
|
||||
color = AppColors.OnSurfaceTertiary
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp, max = 120.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = AppColors.OnBackground,
|
||||
unfocusedTextColor = AppColors.OnSurface,
|
||||
focusedContainerColor = AppColors.SurfaceVariant,
|
||||
unfocusedContainerColor = AppColors.SurfaceVariant,
|
||||
cursorColor = AppColors.Primary,
|
||||
focusedBorderColor = AppColors.Primary,
|
||||
unfocusedBorderColor = Color.Transparent
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Send Button
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (prompt.isNotBlank() && !isLoading) {
|
||||
scope.launch {
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = prompt,
|
||||
isUser = true
|
||||
)
|
||||
|
||||
val userPrompt = prompt
|
||||
prompt = ""
|
||||
isLoading = true
|
||||
errorMessage = ""
|
||||
|
||||
try {
|
||||
val filteredNotes = if (selectedCategory != null) {
|
||||
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
|
||||
} else {
|
||||
notes.filter { !it.isArchived }
|
||||
}
|
||||
|
||||
val notesContext = buildString {
|
||||
appendLine("Data catatan pengguna:")
|
||||
appendLine("Total catatan: ${filteredNotes.size}")
|
||||
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
|
||||
appendLine()
|
||||
appendLine("Daftar catatan:")
|
||||
filteredNotes.take(10).forEach { note ->
|
||||
appendLine("- Judul: ${note.title}")
|
||||
appendLine(" Isi: ${note.content.take(100)}")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
|
||||
val result = generativeModel.generateContent(fullPrompt)
|
||||
val response = result.text ?: "Tidak ada respons dari AI"
|
||||
|
||||
chatMessages = chatMessages + ChatMessage(
|
||||
message = response,
|
||||
isUser = false
|
||||
)
|
||||
|
||||
saveChatHistory()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = when {
|
||||
e.message?.contains("quota", ignoreCase = true) == true ->
|
||||
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
|
||||
e.message?.contains("404", ignoreCase = true) == true ||
|
||||
e.message?.contains("not found", ignoreCase = true) == true ->
|
||||
"⚠️ Model AI tidak ditemukan. Silakan hubungi developer."
|
||||
e.message?.contains("401", ignoreCase = true) == true ||
|
||||
e.message?.contains("API key", ignoreCase = true) == true ->
|
||||
"⚠️ API key tidak valid. Silakan hubungi developer."
|
||||
e.message?.contains("timeout", ignoreCase = true) == true ->
|
||||
"⚠️ Koneksi timeout. Periksa koneksi internet Anda."
|
||||
e.message?.contains("network", ignoreCase = true) == true ->
|
||||
"⚠️ Tidak ada koneksi internet. Silakan periksa koneksi Anda."
|
||||
else ->
|
||||
"⚠️ Terjadi kesalahan: ${e.message?.take(100) ?: "Unknown error"}"
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = AppColors.Primary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,206 @@
|
||||
package com.example.notesai.presentation.screens.ai.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.notesai.util.AppColors
|
||||
import com.example.notesai.util.Constants
|
||||
import com.example.notesai.util.FileParser
|
||||
import com.example.notesai.util.FileParseResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun FileUploadButton(
|
||||
onFileSelected: (FileParseResult.Success) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var isProcessing by remember { mutableStateOf(false) }
|
||||
var selectedFileName by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// File picker launcher
|
||||
val filePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
scope.launch {
|
||||
isProcessing = true
|
||||
try {
|
||||
// Get file info
|
||||
val fileSize = FileParser.getFileSize(context, uri)
|
||||
|
||||
// Check file size (max 10MB)
|
||||
if (fileSize > 10 * 1024 * 1024) {
|
||||
onError("File terlalu besar. Maksimal 10MB")
|
||||
isProcessing = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Parse file
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
FileParser.parseFile(context, uri)
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is FileParseResult.Success -> {
|
||||
selectedFileName = result.fileName
|
||||
onFileSelected(result)
|
||||
}
|
||||
is FileParseResult.Error -> {
|
||||
onError(result.message)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Gagal memproses file: ${e.message}")
|
||||
} finally {
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// Launch file picker untuk PDF, TXT, DOCX
|
||||
filePicker.launch("*/*")
|
||||
},
|
||||
enabled = !isProcessing,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AppColors.Secondary.copy(alpha = 0.15f),
|
||||
contentColor = AppColors.Secondary,
|
||||
disabledContainerColor = AppColors.SurfaceVariant,
|
||||
disabledContentColor = AppColors.OnSurfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = Constants.Spacing.Medium.dp,
|
||||
vertical = Constants.Spacing.Small.dp
|
||||
)
|
||||
) {
|
||||
if (isProcessing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = AppColors.Secondary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Memproses...")
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Upload File",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Upload File",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FilePreviewCard(
|
||||
fileResult: FileParseResult.Success,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = AppColors.SurfaceVariant,
|
||||
shape = RoundedCornerShape(Constants.Radius.Large.dp),
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
when (fileResult.fileType) {
|
||||
"PDF" -> Icons.Default.PictureAsPdf
|
||||
"Word" -> Icons.Default.Description
|
||||
else -> Icons.Default.TextSnippet
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = AppColors.Secondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
fileResult.fileName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.OnSurface
|
||||
)
|
||||
Text(
|
||||
"${fileResult.fileType} • ${fileResult.wordCount} kata",
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.OnSurfaceTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = AppColors.OnSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Content Preview
|
||||
Surface(
|
||||
color = AppColors.Surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
fileResult.content.take(200) + if (fileResult.content.length > 200) "..." else "",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.OnSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
app/src/main/java/com/example/notesai/util/FileParser.kt
Normal file
221
app/src/main/java/com/example/notesai/util/FileParser.kt
Normal file
@ -0,0 +1,221 @@
|
||||
package com.example.notesai.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
object FileParser {
|
||||
|
||||
private const val TAG = "FileParser"
|
||||
|
||||
/**
|
||||
* Initialize PDFBox (call this in Application.onCreate or before first use)
|
||||
*/
|
||||
fun initPDFBox(context: Context) {
|
||||
try {
|
||||
PDFBoxResourceLoader.init(context)
|
||||
Log.d(TAG, "PDFBox initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize PDFBox", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file berdasarkan tipe
|
||||
*/
|
||||
suspend fun parseFile(context: Context, uri: Uri): FileParseResult {
|
||||
return try {
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val fileName = getFileName(context, uri)
|
||||
|
||||
Log.d(TAG, "Parsing file: $fileName, type: $mimeType")
|
||||
|
||||
val content = when {
|
||||
mimeType == "application/pdf" || fileName.endsWith(".pdf", ignoreCase = true) -> {
|
||||
parsePDF(context, uri)
|
||||
}
|
||||
mimeType == "text/plain" || fileName.endsWith(".txt", ignoreCase = true) -> {
|
||||
parseTXT(context, uri)
|
||||
}
|
||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|| fileName.endsWith(".docx", ignoreCase = true) -> {
|
||||
parseDOCX(context, uri)
|
||||
}
|
||||
else -> {
|
||||
return FileParseResult.Error("Format file tidak didukung: $mimeType")
|
||||
}
|
||||
}
|
||||
|
||||
if (content.isBlank()) {
|
||||
FileParseResult.Error("File kosong atau tidak dapat dibaca")
|
||||
} else {
|
||||
FileParseResult.Success(
|
||||
content = content,
|
||||
fileName = fileName,
|
||||
fileType = getFileType(fileName),
|
||||
wordCount = content.split(Regex("\\s+")).size
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing file", e)
|
||||
FileParseResult.Error("Gagal membaca file: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PDF file
|
||||
*/
|
||||
private fun parsePDF(context: Context, uri: Uri): String {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw Exception("Cannot open file")
|
||||
|
||||
return inputStream.use { stream ->
|
||||
val document = PDDocument.load(stream)
|
||||
val stripper = PDFTextStripper()
|
||||
val text = stripper.getText(document)
|
||||
document.close()
|
||||
text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TXT file
|
||||
*/
|
||||
private fun parseTXT(context: Context, uri: Uri): String {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw Exception("Cannot open file")
|
||||
|
||||
return inputStream.use { stream ->
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8))
|
||||
.readText()
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DOCX file - SIMPLIFIED VERSION
|
||||
* Hanya extract text mentah dari XML
|
||||
*/
|
||||
private fun parseDOCX(context: Context, uri: Uri): String {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw Exception("Cannot open file")
|
||||
|
||||
return inputStream.use { stream ->
|
||||
try {
|
||||
// DOCX adalah ZIP file, kita extract document.xml
|
||||
val zipInputStream = java.util.zip.ZipInputStream(stream)
|
||||
val text = StringBuilder()
|
||||
|
||||
var entry = zipInputStream.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == "word/document.xml") {
|
||||
val xmlContent = zipInputStream.bufferedReader().readText()
|
||||
|
||||
// Extract text dari XML tags
|
||||
// Format: <w:t>text here</w:t>
|
||||
val textPattern = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
||||
textPattern.findAll(xmlContent).forEach { match ->
|
||||
text.append(match.groupValues[1])
|
||||
text.append(" ")
|
||||
}
|
||||
|
||||
// Extract text dari paragraph tags
|
||||
val paraPattern = Regex("<w:p[^>]*>(.*?)</w:p>", RegexOption.DOT_MATCHES_ALL)
|
||||
paraPattern.findAll(xmlContent).forEach { match ->
|
||||
val paraContent = match.groupValues[1]
|
||||
val textInPara = Regex("<w:t[^>]*>([^<]+)</w:t>")
|
||||
textInPara.findAll(paraContent).forEach { textMatch ->
|
||||
text.append(textMatch.groupValues[1])
|
||||
text.append(" ")
|
||||
}
|
||||
text.append("\n")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
entry = zipInputStream.nextEntry
|
||||
}
|
||||
|
||||
zipInputStream.close()
|
||||
text.toString().trim()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing DOCX", e)
|
||||
throw Exception("Gagal membaca file DOCX: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file name from URI
|
||||
*/
|
||||
private fun getFileName(context: Context, uri: Uri): String {
|
||||
var fileName = "unknown"
|
||||
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (cursor.moveToFirst() && nameIndex != -1) {
|
||||
fileName = cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type display name
|
||||
*/
|
||||
private fun getFileType(fileName: String): String {
|
||||
return when {
|
||||
fileName.endsWith(".pdf", ignoreCase = true) -> "PDF"
|
||||
fileName.endsWith(".txt", ignoreCase = true) -> "Text"
|
||||
fileName.endsWith(".docx", ignoreCase = true) -> "Word"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size
|
||||
*/
|
||||
fun getFileSize(context: Context, uri: Uri): Long {
|
||||
var size = 0L
|
||||
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (cursor.moveToFirst() && sizeIndex != -1) {
|
||||
size = cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size untuk display
|
||||
*/
|
||||
fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "${bytes / (1024 * 1024)} MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result dari parsing file
|
||||
*/
|
||||
sealed class FileParseResult {
|
||||
data class Success(
|
||||
val content: String,
|
||||
val fileName: String,
|
||||
val fileType: String,
|
||||
val wordCount: Int
|
||||
) : FileParseResult()
|
||||
|
||||
data class Error(val message: String) : FileParseResult()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user