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