Compare commits

..

No commits in common. "3692a291c7e906987e7865810c471e28be53d925" and "f4847ced63199721a50268f22659370b3f62fa1b" have entirely different histories.

20 changed files with 270 additions and 911 deletions

View File

@ -199,7 +199,7 @@
* Sistem kategori dengan gradient * Sistem kategori dengan gradient
* Buat/edit/hapus kategori dengan confirmation dialog * Buat/edit/hapus kategori dengan confirmation dialog
* Buat/edit/hapus catatan * Buat/edit/hapus catatan
* Pin catatan penting (Catatan Berbintang) * Pin catatan penting
* Full-screen editor * Full-screen editor
* Search kategori di beranda * Search kategori di beranda
* Search catatan dalam kategori * Search catatan dalam kategori
@ -208,7 +208,7 @@
* AI membaca & menganalisis catatan pengguna * AI membaca & menganalisis catatan pengguna
* Suggestion chips & copy response * Suggestion chips & copy response
* Filter AI berdasarkan kategori * Filter AI berdasarkan kategori
* Dark theme & Light theme * Dark theme modern + gradient
* Animasi smooth * Animasi smooth
* Empty states & error handling * Empty states & error handling
@ -226,6 +226,7 @@
## **Features for Sprint 4 v1.1.0** ## **Features for Sprint 4 v1.1.0**
* Penyesuaian UI/UX History Chat AI (ok) * Penyesuaian UI/UX History Chat AI (ok)
* Rich text editor (ok - Harus Pengembangan Lanjutan) * Rich text editor (ok - Pengembangan Lanjutan)
* Fungsi AI (Upload File) (ok) * AI Agent Catatan
* Fungsi AI (Upload File)
* Fitur Sematkan Category, otomatis paling atas * Fitur Sematkan Category, otomatis paling atas

View File

@ -80,30 +80,4 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
// File picker
implementation("androidx.activity:activity-compose:1.8.2")
// 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"
}
}
} }

View File

@ -3,17 +3,8 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:name=".NotesAIApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"

View File

@ -90,15 +90,6 @@ 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NotesApp() { fun NotesApp() {
@ -106,6 +97,7 @@ fun NotesApp() {
val dataStoreManager = remember { DataStoreManager(context) } val dataStoreManager = remember { DataStoreManager(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
var categories by remember { mutableStateOf(listOf<Category>()) } var categories by remember { mutableStateOf(listOf<Category>()) }
var notes by remember { mutableStateOf(listOf<Note>()) } var notes by remember { mutableStateOf(listOf<Note>()) }
var selectedCategory by remember { mutableStateOf<Category?>(null) } var selectedCategory by remember { mutableStateOf<Category?>(null) }

View File

@ -1,13 +0,0 @@
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)
}
}

View File

@ -30,8 +30,7 @@ data class SerializableCategory(
val gradientStart: Long, val gradientStart: Long,
val gradientEnd: Long, val gradientEnd: Long,
val timestamp: Long, val timestamp: Long,
val isDeleted: Boolean = false, val isDeleted: Boolean = false
val isPinned: Boolean = false // NEW
) )
@Serializable @Serializable
@ -78,8 +77,7 @@ class DataStoreManager(private val context: Context) {
gradientStart = it.gradientStart, gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd, gradientEnd = it.gradientEnd,
timestamp = it.timestamp, timestamp = it.timestamp,
isDeleted = it.isDeleted, isDeleted = it.isDeleted
isPinned = it.isPinned // NEW
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -149,8 +147,7 @@ class DataStoreManager(private val context: Context) {
gradientStart = it.gradientStart, gradientStart = it.gradientStart,
gradientEnd = it.gradientEnd, gradientEnd = it.gradientEnd,
timestamp = it.timestamp, timestamp = it.timestamp,
isDeleted = it.isDeleted, isDeleted = it.isDeleted
isPinned = it.isPinned // NEW
) )
} }
preferences[CATEGORIES_KEY] = json.encodeToString(serializable) preferences[CATEGORIES_KEY] = json.encodeToString(serializable)

View File

@ -10,6 +10,5 @@ data class Category(
val gradientStart: Long, val gradientStart: Long,
val gradientEnd: Long, val gradientEnd: Long,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false, val isDeleted: Boolean = false // Support soft delete
val isPinned: Boolean = false // NEW: Tambahkan ini
) )

View File

@ -3,6 +3,7 @@ package com.example.notesai.data.model
import android.annotation.SuppressLint import android.annotation.SuppressLint
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@Serializable @Serializable
data class SerializableCategory( data class SerializableCategory(
val id: String, val id: String,
@ -10,8 +11,7 @@ data class SerializableCategory(
val gradientStart: Long, val gradientStart: Long,
val gradientEnd: Long, val gradientEnd: Long,
val timestamp: Long, val timestamp: Long,
val isDeleted: Boolean = false, val isDeleted: Boolean = false // TAMBAHKAN INI
val isPinned: Boolean = false // NEW: Tambahkan ini
) )
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@ -34,8 +34,7 @@ fun Category.toSerializable() = SerializableCategory(
gradientStart = gradientStart, gradientStart = gradientStart,
gradientEnd = gradientEnd, gradientEnd = gradientEnd,
timestamp = timestamp, timestamp = timestamp,
isDeleted = isDeleted, isDeleted = isDeleted // TAMBAHKAN INI
isPinned = isPinned // NEW: Tambahkan ini
) )
fun SerializableCategory.toCategory() = Category( fun SerializableCategory.toCategory() = Category(
@ -44,8 +43,7 @@ fun SerializableCategory.toCategory() = Category(
gradientStart = gradientStart, gradientStart = gradientStart,
gradientEnd = gradientEnd, gradientEnd = gradientEnd,
timestamp = timestamp, timestamp = timestamp,
isDeleted = isDeleted, isDeleted = isDeleted // TAMBAHKAN INI
isPinned = isPinned // NEW: Tambahkan ini
) )
fun Note.toSerializable() = SerializableNote( fun Note.toSerializable() = SerializableNote(

View File

@ -14,6 +14,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -31,9 +32,11 @@ import com.google.ai.client.generativeai.type.generationConfig
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
import com.example.notesai.presentation.screens.ai.components.* 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.util.AppColors import com.example.notesai.util.AppColors
import com.example.notesai.util.FileParseResult
private const val MAX_CHAT_TITLE_LENGTH = 30 private const val MAX_CHAT_TITLE_LENGTH = 30
@ -64,10 +67,6 @@ fun AIHelperScreen(
var showHistoryDrawer by remember { mutableStateOf(false) } var showHistoryDrawer by remember { mutableStateOf(false) }
var currentChatId by remember { mutableStateOf<String?>(null) } 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 scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@ -135,8 +134,6 @@ fun AIHelperScreen(
currentChatId = null currentChatId = null
errorMessage = "" errorMessage = ""
showHistoryDrawer = false showHistoryDrawer = false
uploadedFile = null
isGeneratingSummary = false
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@ -280,8 +277,7 @@ fun AIHelperScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp) .padding(32.dp),
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@ -337,70 +333,6 @@ fun AIHelperScreen(
SuggestionChip("Buat ringkasan") { prompt = it } SuggestionChip("Buat ringkasan") { prompt = it }
SuggestionChip("Berikan saran organisasi") { 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 { } else {
// Chat Messages // Chat Messages
@ -413,8 +345,8 @@ fun AIHelperScreen(
chatMessages.forEach { message -> chatMessages.forEach { message ->
ChatBubble( ChatBubble(
message = message, message = message,
onCopy = { textToCopy -> onCopy = {
clipboardManager.setText(AnnotatedString(textToCopy)) clipboardManager.setText(AnnotatedString(message.message))
copiedMessageId = message.id copiedMessageId = message.id
showCopiedMessage = true showCopiedMessage = true
scope.launch { scope.launch {
@ -427,8 +359,8 @@ fun AIHelperScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }
// Loading Indicator (untuk chat biasa DAN file summary) // Loading Indicator
if (isLoading || isGeneratingSummary) { if (isLoading) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -446,11 +378,11 @@ fun AIHelperScreen(
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
color = if (isGeneratingSummary) AppColors.Secondary else AppColors.Primary, color = AppColors.Primary,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
Text( Text(
if (isGeneratingSummary) "Membuat ringkasan..." else "AI sedang berpikir...", "AI sedang berpikir...",
color = AppColors.OnSurfaceVariant, color = AppColors.OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@ -495,75 +427,10 @@ fun AIHelperScreen(
shadowElevation = 8.dp, shadowElevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// NEW: Upload File Button (di atas input text)
if (chatMessages.isNotEmpty() && !isGeneratingSummary && !isLoading) {
FileUploadButton(
onFileSelected = { fileResult ->
uploadedFile = fileResult
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()
chatMessages = chatMessages + ChatMessage(
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
)
saveChatHistory()
} catch (e: Exception) {
errorMessage = "⚠️ Gagal membuat ringkasan: ${e.message}"
} finally {
isGeneratingSummary = false
uploadedFile = null
}
}
},
onError = { error ->
errorMessage = error
},
modifier = Modifier.fillMaxWidth()
)
}
// Text Input & Send Button
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
@ -636,8 +503,10 @@ fun AIHelperScreen(
isUser = false isUser = false
) )
// Auto-save chat history
saveChatHistory() saveChatHistory()
} catch (e: Exception) { } catch (e: Exception) {
// Better error handling
errorMessage = when { errorMessage = when {
e.message?.contains("quota", ignoreCase = true) == true -> e.message?.contains("quota", ignoreCase = true) == true ->
"⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer." "⚠️ Kuota API habis. Silakan coba lagi nanti atau hubungi developer."
@ -673,7 +542,6 @@ fun AIHelperScreen(
} }
} }
} }
}
// Chat History Drawer // Chat History Drawer
AnimatedVisibility( AnimatedVisibility(

View File

@ -5,7 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -16,7 +19,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.ChatMessage import com.example.notesai.data.model.ChatMessage
import com.example.notesai.util.MarkdownText import com.example.notesai.util.MarkdownText
import com.example.notesai.util.MarkdownStripper
import com.example.notesai.util.AppColors import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants import com.example.notesai.util.Constants
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -25,18 +27,17 @@ import java.util.*
@Composable @Composable
fun ChatBubble( fun ChatBubble(
message: ChatMessage, message: ChatMessage,
onCopy: (String) -> Unit, // CHANGED: Sekarang terima text parameter onCopy: () -> Unit,
showCopied: Boolean showCopied: Boolean
) { ) {
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) } val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
var showCopyMenu by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
) { ) {
if (message.isUser) { if (message.isUser) {
// User Message (tidak berubah) // User Message
Surface( Surface(
color = AppColors.Primary, color = AppColors.Primary,
shape = RoundedCornerShape( shape = RoundedCornerShape(
@ -88,7 +89,7 @@ fun ChatBubble(
} }
} }
} else { } else {
// AI Message with IMPROVED Copy Options // AI Message with Markdown
Surface( Surface(
color = AppColors.SurfaceVariant, color = AppColors.SurfaceVariant,
shape = RoundedCornerShape( shape = RoundedCornerShape(
@ -126,10 +127,9 @@ fun ChatBubble(
) )
} }
// IMPROVED: Copy Button with Dropdown Menu // Copy Button
Box {
IconButton( IconButton(
onClick = { showCopyMenu = !showCopyMenu }, onClick = onCopy,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) { ) {
AnimatedContent( AnimatedContent(
@ -147,85 +147,6 @@ fun ChatBubble(
) )
} }
} }
// Dropdown Menu untuk pilihan copy
DropdownMenu(
expanded = showCopyMenu,
onDismissRequest = { showCopyMenu = false },
modifier = Modifier.background(AppColors.SurfaceElevated)
) {
// Option 1: Copy dengan Format (Markdown)
DropdownMenuItem(
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Code,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(18.dp)
)
Column {
Text(
"Copy dengan Format",
fontSize = 13.sp,
color = AppColors.OnSurface,
fontWeight = FontWeight.Medium
)
Text(
"Termasuk markdown",
fontSize = 10.sp,
color = AppColors.OnSurfaceTertiary
)
}
}
},
onClick = {
onCopy(message.message) // Copy original dengan markdown
showCopyMenu = false
}
)
HorizontalDivider(color = AppColors.Divider)
// Option 2: Copy Teks Asli (Plain Text)
DropdownMenuItem(
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.TextFields,
contentDescription = null,
tint = AppColors.Secondary,
modifier = Modifier.size(18.dp)
)
Column {
Text(
"Copy Teks Asli",
fontSize = 13.sp,
color = AppColors.OnSurface,
fontWeight = FontWeight.Medium
)
Text(
"Tanpa format",
fontSize = 10.sp,
color = AppColors.OnSurfaceTertiary
)
}
}
},
onClick = {
val plainText = MarkdownStripper.stripMarkdown(message.message)
onCopy(plainText) // Copy plain text tanpa markdown
showCopyMenu = false
}
)
}
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@ -1,206 +0,0 @@
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
)
}
}
}
}

View File

@ -29,6 +29,8 @@ import androidx.compose.ui.unit.*
import com.example.notesai.data.model.Note import com.example.notesai.data.model.Note
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
import com.example.notesai.presentation.screens.note.editor.RichEditorState import com.example.notesai.presentation.screens.note.editor.RichEditorState
import com.example.notesai.util.MarkdownParser
import com.example.notesai.util.MarkdownSerializer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*

View File

@ -1,221 +0,0 @@
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()
}

View File

@ -0,0 +1,58 @@
package com.example.notesai.util
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
object MarkdownParser {
fun parse(markdown: String): AnnotatedString {
val builder = AnnotatedString.Builder()
var i = 0
while (i < markdown.length) {
when {
markdown.startsWith("**", i) -> {
val end = markdown.indexOf("**", i + 2)
if (end != -1) {
val content = markdown.substring(i + 2, end)
val start = builder.length
builder.append(content)
builder.addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start,
start + content.length
)
i = end + 2
} else {
builder.append(markdown[i++])
}
}
markdown.startsWith("*", i) -> {
val end = markdown.indexOf("*", i + 1)
if (end != -1) {
val content = markdown.substring(i + 1, end)
val start = builder.length
builder.append(content)
builder.addStyle(
SpanStyle(fontStyle = FontStyle.Italic),
start,
start + content.length
)
i = end + 1
} else {
builder.append(markdown[i++])
}
}
else -> {
builder.append(markdown[i++])
}
}
}
return builder.toAnnotatedString()
}
}

View File

@ -0,0 +1,35 @@
package com.example.notesai.util
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
object MarkdownSerializer {
fun toMarkdown(text: AnnotatedString): String {
val raw = text.text
if (text.spanStyles.isEmpty()) return raw
val markers = Array(raw.length + 1) { mutableListOf<String>() }
text.spanStyles.forEach { span ->
if (span.item.fontWeight == FontWeight.Bold) {
markers[span.start].add("**")
markers[span.end].add("**")
}
if (span.item.fontStyle == FontStyle.Italic) {
markers[span.start].add("*")
markers[span.end].add("*")
}
}
val sb = StringBuilder()
for (i in raw.indices) {
markers[i].forEach { sb.append(it) }
sb.append(raw[i])
}
markers[raw.length].forEach { sb.append(it) }
return sb.toString()
}
}

View File

@ -1,68 +0,0 @@
package com.example.notesai.util
/**
* Utility untuk convert markdown text ke plain text
*/
object MarkdownStripper {
/**
* Strip semua markdown formatting dan return plain text
*/
fun stripMarkdown(text: String): String {
var result = text
// 1. Remove code blocks (```...```)
result = result.replace(Regex("""```[\s\S]*?```"""), "")
// 2. Remove inline code (`...`)
result = result.replace(Regex("""`([^`]+)`"""), "$1")
// 3. Remove bold (**...**)
result = result.replace(Regex("""\*\*([^*]+)\*\*"""), "$1")
// 4. Remove italic (*...*)
result = result.replace(Regex("""\*([^*]+)\*"""), "$1")
// 5. Remove strikethrough (~~...~~)
result = result.replace(Regex("""~~([^~]+)~~"""), "$1")
// 6. Remove headers (# ## ### etc)
result = result.replace(Regex("""^#{1,6}\s+""", RegexOption.MULTILINE), "")
// 7. Remove links [text](url) → text
result = result.replace(Regex("""\[([^\]]+)\]\([^)]+\)"""), "$1")
// 8. Remove images ![alt](url) → alt
result = result.replace(Regex("""!\[([^\]]*)\]\([^)]+\)"""), "$1")
// 9. Remove horizontal rules (---, ***, ___)
result = result.replace(Regex("""^[-*_]{3,}$""", RegexOption.MULTILINE), "")
// 10. Remove blockquotes (> ...)
result = result.replace(Regex("""^>\s+""", RegexOption.MULTILINE), "")
// 11. Remove unordered list markers (-, *, +)
result = result.replace(Regex("""^[\s]*[-*+]\s+""", RegexOption.MULTILINE), "")
// 12. Remove ordered list markers (1. 2. 3.)
result = result.replace(Regex("""^[\s]*\d+\.\s+""", RegexOption.MULTILINE), "")
// 13. Clean up extra whitespace
result = result.replace(Regex("""\n{3,}"""), "\n\n") // Max 2 consecutive newlines
result = result.trim()
return result
}
/**
* Get preview text (first N characters, stripped)
*/
fun getPlainPreview(text: String, maxLength: Int = 100): String {
val plain = stripMarkdown(text)
return if (plain.length > maxLength) {
plain.take(maxLength).trim() + "..."
} else {
plain
}
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,3 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources> </resources>

View File

@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -1,5 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.NotesAI" parent="android:Theme.Material.Light.NoActionBar" />
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.Notesai" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.Notesai" parent="Base.Theme.Notesai" />
</resources> </resources>