diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 47f6b97..c23b69a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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"
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aa1157a..75c0e60 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,8 +3,17 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
): List {
+ return categories
+ .filter { !it.isDeleted }
+ .sortedWith(
+ compareByDescending { it.isPinned } // Pinned dulu
+ .thenByDescending { it.timestamp } // Lalu timestamp
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesApp() {
diff --git a/app/src/main/java/com/example/notesai/NotesAIApplication.kt b/app/src/main/java/com/example/notesai/NotesAIApplication.kt
new file mode 100644
index 0000000..9549cd5
--- /dev/null
+++ b/app/src/main/java/com/example/notesai/NotesAIApplication.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notesai/data/local/DataStoreManager.kt b/app/src/main/java/com/example/notesai/data/local/DataStoreManager.kt
index b1b50d9..99d0742 100644
--- a/app/src/main/java/com/example/notesai/data/local/DataStoreManager.kt
+++ b/app/src/main/java/com/example/notesai/data/local/DataStoreManager.kt
@@ -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)
diff --git a/app/src/main/java/com/example/notesai/data/model/Category.kt b/app/src/main/java/com/example/notesai/data/model/Category.kt
index 2cbcec3..e513d94 100644
--- a/app/src/main/java/com/example/notesai/data/model/Category.kt
+++ b/app/src/main/java/com/example/notesai/data/model/Category.kt
@@ -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
)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notesai/data/model/SerializableModels.kt b/app/src/main/java/com/example/notesai/data/model/SerializableModels.kt
index 3eafc63..36c7df2 100644
--- a/app/src/main/java/com/example/notesai/data/model/SerializableModels.kt
+++ b/app/src/main/java/com/example/notesai/data/model/SerializableModels.kt
@@ -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(
diff --git a/app/src/main/java/com/example/notesai/presentation/screens/ai/AIHelperScreen.kt b/app/src/main/java/com/example/notesai/presentation/screens/ai/AIHelperScreen.kt
index 1cb08f4..3ea5221 100644
--- a/app/src/main/java/com/example/notesai/presentation/screens/ai/AIHelperScreen.kt
+++ b/app/src/main/java/com/example/notesai/presentation/screens/ai/AIHelperScreen.kt
@@ -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(null) }
+ // NEW: File Upload States
+ var uploadedFile by remember { mutableStateOf(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 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"
+ 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()
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)
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/example/notesai/presentation/screens/ai/components/FileUploadComponent.kt b/app/src/main/java/com/example/notesai/presentation/screens/ai/components/FileUploadComponent.kt
new file mode 100644
index 0000000..8fdb883
--- /dev/null
+++ b/app/src/main/java/com/example/notesai/presentation/screens/ai/components/FileUploadComponent.kt
@@ -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(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
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/notesai/util/FileParser.kt b/app/src/main/java/com/example/notesai/util/FileParser.kt
new file mode 100644
index 0000000..04eded7
--- /dev/null
+++ b/app/src/main/java/com/example/notesai/util/FileParser.kt
@@ -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: text here
+ val textPattern = Regex("]*>([^<]+)")
+ textPattern.findAll(xmlContent).forEach { match ->
+ text.append(match.groupValues[1])
+ text.append(" ")
+ }
+
+ // Extract text dari paragraph tags
+ val paraPattern = Regex("]*>(.*?)", RegexOption.DOT_MATCHES_ALL)
+ paraPattern.findAll(xmlContent).forEach { match ->
+ val paraContent = match.groupValues[1]
+ val textInPara = Regex("]*>([^<]+)")
+ 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()
+}
\ No newline at end of file