Plain Text Copy

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-22 14:29:42 +07:00
parent f4847ced63
commit 978b4285bb
13 changed files with 183 additions and 158 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 * Pin catatan penting (Catatan Berbintang)
* 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 modern + gradient * Dark theme & Light theme
* Animasi smooth * Animasi smooth
* Empty states & error handling * Empty states & error handling
@ -226,7 +226,6 @@
## **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 - Pengembangan Lanjutan) * Rich text editor (ok - Harus Pengembangan Lanjutan)
* AI Agent Catatan
* Fungsi AI (Upload File) * Fungsi AI (Upload File)
* Fitur Sematkan Category, otomatis paling atas * Fitur Sematkan Category, otomatis paling atas

View File

@ -80,4 +80,10 @@ 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")
// Gemini AI
implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
// JSON parsing (untuk tool calls)
implementation("org.json:json:20230227")
} }

View File

@ -97,7 +97,6 @@ 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

@ -345,8 +345,8 @@ fun AIHelperScreen(
chatMessages.forEach { message -> chatMessages.forEach { message ->
ChatBubble( ChatBubble(
message = message, message = message,
onCopy = { onCopy = { textToCopy -> // CHANGED: Sekarang terima parameter text
clipboardManager.setText(AnnotatedString(message.message)) clipboardManager.setText(AnnotatedString(textToCopy))
copiedMessageId = message.id copiedMessageId = message.id
showCopiedMessage = true showCopiedMessage = true
scope.launch { scope.launch {

View File

@ -5,10 +5,7 @@ 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.Check import androidx.compose.material.icons.filled.*
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
@ -19,6 +16,7 @@ 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
@ -27,17 +25,18 @@ import java.util.*
@Composable @Composable
fun ChatBubble( fun ChatBubble(
message: ChatMessage, message: ChatMessage,
onCopy: () -> Unit, onCopy: (String) -> Unit, // CHANGED: Sekarang terima text parameter
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 // User Message (tidak berubah)
Surface( Surface(
color = AppColors.Primary, color = AppColors.Primary,
shape = RoundedCornerShape( shape = RoundedCornerShape(
@ -89,7 +88,7 @@ fun ChatBubble(
} }
} }
} else { } else {
// AI Message with Markdown // AI Message with IMPROVED Copy Options
Surface( Surface(
color = AppColors.SurfaceVariant, color = AppColors.SurfaceVariant,
shape = RoundedCornerShape( shape = RoundedCornerShape(
@ -127,23 +126,103 @@ fun ChatBubble(
) )
} }
// Copy Button // IMPROVED: Copy Button with Dropdown Menu
IconButton( Box {
onClick = onCopy, IconButton(
modifier = Modifier.size(28.dp) onClick = { showCopyMenu = !showCopyMenu },
) { modifier = Modifier.size(28.dp)
AnimatedContent( ) {
targetState = showCopied, AnimatedContent(
transitionSpec = { targetState = showCopied,
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut() transitionSpec = {
}, fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
label = "copy_icon" },
) { copied -> label = "copy_icon"
Icon( ) { copied ->
if (copied) Icons.Default.Check else Icons.Default.ContentCopy, Icon(
contentDescription = if (copied) "Copied" else "Copy", if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant, contentDescription = if (copied) "Copied" else "Copy",
modifier = Modifier.size(16.dp) tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
// 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
}
) )
} }
} }

View File

@ -29,8 +29,6 @@ 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,58 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -0,0 +1,68 @@
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

@ -1,19 +0,0 @@
<?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,7 +1,3 @@
<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,5 +1,2 @@
<?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,10 +1,5 @@
<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>