Compare commits

...

2 Commits

Author SHA1 Message Date
79f7e33a5a Mencoba Respon AI dengan MarkdownText.kt 2025-12-18 14:20:51 +07:00
41a1e8268a Mencoba MarkdownText.kt 2025-12-18 14:09:46 +07:00
4 changed files with 430 additions and 92 deletions

View File

@ -1,5 +1,5 @@
package com.example.notesai.config
object APIKey {
const val GEMINI_API_KEY = "MY_GEMINI_KEY"
const val GEMINI_API_KEY = "AIzaSyBzC64RXsNtSERlts_FSd8HXKEpkLdT7-8"
}

View File

@ -35,6 +35,7 @@ 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.config.APIKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -65,8 +66,8 @@ fun AIHelperScreen(
// Inisialisasi Gemini Model
val generativeModel = remember {
GenerativeModel(
modelName = "gemini-2.0-flash-exp",
apiKey = com.example.notesai.config.APIKey.GEMINI_API_KEY,
modelName = "gemini-2.5-flash",
apiKey = APIKey.GEMINI_API_KEY,
generationConfig = generationConfig {
temperature = 0.8f
topK = 40
@ -495,7 +496,23 @@ fun AIHelperScreen(
// Auto-save chat history
saveChatHistory()
} catch (e: Exception) {
errorMessage = "Error: ${e.message}"
// 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"}"
}
} finally {
isLoading = false
}

View File

@ -1,40 +1,28 @@
package com.example.notesai.presentation.screens.ai.components
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.SmartToy
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.ChatMessage
import com.example.notesai.util.MarkdownText
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
@Composable
fun ChatBubble(
@ -44,95 +32,139 @@ fun ChatBubble(
) {
val dateFormat = remember { SimpleDateFormat("HH:mm", Locale("id", "ID")) }
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
) {
if (!message.isUser) {
Box(
modifier = Modifier
.size(32.dp)
.background(
color = AppColors.Primary.copy(alpha = 0.1f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
Column(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalAlignment = if (message.isUser) Alignment.End else Alignment.Start
) {
if (message.isUser) {
// User Message
Surface(
color = if (message.isUser)
AppColors.Primary
else
AppColors.SurfaceVariant,
color = AppColors.Primary,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (message.isUser) 16.dp else 4.dp,
bottomEnd = if (message.isUser) 4.dp else 16.dp
)
topStart = Constants.Radius.Large.dp,
topEnd = Constants.Radius.Large.dp,
bottomStart = Constants.Radius.Large.dp,
bottomEnd = 4.dp
),
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 320.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(16.dp)
)
Text(
"Anda",
color = Color.White.copy(alpha = 0.9f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
message.message,
color = if (message.isUser) Color.White else AppColors.OnSurface,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
dateFormat.format(Date(message.timestamp)),
color = Color.White.copy(alpha = 0.7f),
fontSize = 11.sp
)
}
}
} else {
// AI Message with Markdown
Surface(
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(
topStart = Constants.Radius.Large.dp,
topEnd = Constants.Radius.Large.dp,
bottomStart = 4.dp,
bottomEnd = Constants.Radius.Large.dp
),
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 320.dp)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
dateFormat.format(Date(message.timestamp)),
color = if (message.isUser)
Color.White.copy(0.7f)
else
AppColors.OnSurfaceTertiary,
style = MaterialTheme.typography.bodySmall,
fontSize = 11.sp,
modifier = Modifier.padding(top = 4.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.SmartToy,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Text(
"AI Assistant",
color = AppColors.OnSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
if (!message.isUser) {
IconButton(
onClick = onCopy,
modifier = Modifier.size(28.dp)
) {
// Copy Button
IconButton(
onClick = onCopy,
modifier = Modifier.size(28.dp)
) {
AnimatedContent(
targetState = showCopied,
transitionSpec = {
fadeIn() + scaleIn() togetherWith fadeOut() + scaleOut()
},
label = "copy_icon"
) { copied ->
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(14.dp)
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy",
tint = if (copied) AppColors.Success else AppColors.OnSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
if (showCopied && !message.isUser) {
Text(
"✓ Disalin",
color = AppColors.Success,
style = MaterialTheme.typography.bodySmall,
fontSize = 11.sp,
modifier = Modifier.padding(top = 4.dp, start = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Use MarkdownText for AI responses
MarkdownText(
markdown = message.message,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
Text(
dateFormat.format(Date(message.timestamp)),
color = AppColors.OnSurfaceTertiary,
fontSize = 11.sp
)
}
}
}
}

View File

@ -0,0 +1,289 @@
package com.example.notesai.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.util.AppColors
import com.example.notesai.util.Constants
@Composable
fun MarkdownText(
markdown: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
parseMarkdown(markdown).forEach { block ->
when (block) {
is MarkdownBlock.Paragraph -> {
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
lineHeight = 24.sp
)
}
is MarkdownBlock.Header -> {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = block.content,
style = when (block.level) {
1 -> MaterialTheme.typography.headlineLarge
2 -> MaterialTheme.typography.headlineMedium
3 -> MaterialTheme.typography.headlineSmall
else -> MaterialTheme.typography.titleLarge
},
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
}
is MarkdownBlock.CodeBlock -> {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Text(
text = block.content,
modifier = Modifier.padding(12.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurface,
fontSize = 13.sp
)
}
}
is MarkdownBlock.ListItem -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = if (block.isOrdered) "${block.number}." else "",
color = AppColors.Primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(20.dp)
)
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
modifier = Modifier.weight(1f)
)
}
}
is MarkdownBlock.Quote -> {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppColors.Primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Row {
Spacer(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(AppColors.Primary)
)
Text(
text = buildAnnotatedString {
appendInlineMarkdown(block.content)
},
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurface,
fontStyle = FontStyle.Italic
)
}
}
}
}
}
}
}
// Markdown Block Types
sealed class MarkdownBlock {
data class Paragraph(val content: String) : MarkdownBlock()
data class Header(val level: Int, val content: String) : MarkdownBlock()
data class CodeBlock(val content: String, val language: String? = null) : MarkdownBlock()
data class ListItem(val content: String, val isOrdered: Boolean, val number: Int = 0) : MarkdownBlock()
data class Quote(val content: String) : MarkdownBlock()
}
// Parse markdown into blocks
fun parseMarkdown(text: String): List<MarkdownBlock> {
val blocks = mutableListOf<MarkdownBlock>()
val lines = text.lines()
var i = 0
while (i < lines.size) {
val line = lines[i]
when {
// Code block
line.trimStart().startsWith("```") -> {
val language = line.trimStart().removePrefix("```").trim()
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
blocks.add(MarkdownBlock.CodeBlock(codeLines.joinToString("\n"), language.ifEmpty { null }))
i++
}
// Header
line.trimStart().startsWith("#") -> {
val level = line.takeWhile { it == '#' }.length
val content = line.removePrefix("#".repeat(level)).trim()
blocks.add(MarkdownBlock.Header(level, content))
i++
}
// Quote
line.trimStart().startsWith(">") -> {
val content = line.trimStart().removePrefix(">").trim()
blocks.add(MarkdownBlock.Quote(content))
i++
}
// Unordered list
line.trimStart().matches(Regex("^[-*+]\\s+.*")) -> {
val content = line.trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
blocks.add(MarkdownBlock.ListItem(content, false))
i++
}
// Ordered list
line.trimStart().matches(Regex("^\\d+\\.\\s+.*")) -> {
val number = line.trimStart().takeWhile { it.isDigit() }.toIntOrNull() ?: 1
val content = line.trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
blocks.add(MarkdownBlock.ListItem(content, true, number))
i++
}
// Empty line - skip
line.isBlank() -> {
i++
}
// Paragraph
else -> {
val paragraphLines = mutableListOf<String>()
while (i < lines.size && lines[i].isNotBlank() &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().startsWith(">") &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*")) &&
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*"))) {
paragraphLines.add(lines[i])
i++
}
if (paragraphLines.isNotEmpty()) {
blocks.add(MarkdownBlock.Paragraph(paragraphLines.joinToString(" ")))
}
}
}
}
return blocks
}
// Parse inline markdown (bold, italic, code, links)
fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
var currentIndex = 0
val inlinePatterns = listOf(
// Bold with **
Regex("""\*\*(.+?)\*\*""") to { content: String ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(content)
}
},
// Bold with __
Regex("""__(.+?)__""") to { content: String ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(content)
}
},
// Italic with *
Regex("""\*(.+?)\*""") to { content: String ->
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(content)
}
},
// Italic with _
Regex("""_(.+?)_""") to { content: String ->
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(content)
}
},
// Inline code
Regex("""`(.+?)`""") to { content: String ->
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = Color(0xFF1E1E1E),
color = Color(0xFFE5E5E5)
)
) {
append(" $content ")
}
},
// Links [text](url)
Regex("""\[(.+?)]\((.+?)\)""") to { content: String ->
withStyle(
SpanStyle(
color = Color(0xFF3B82F6),
textDecoration = TextDecoration.Underline
)
) {
append(content)
}
}
)
var remainingText = text
val matches = mutableListOf<Triple<Int, Int, (String) -> Unit>>()
// Find all matches
inlinePatterns.forEach { (regex, styleApplier) ->
regex.findAll(remainingText).forEach { match ->
val content = match.groupValues[1]
matches.add(Triple(match.range.first, match.range.last + 1) { styleApplier(content) })
}
}
// Sort matches by start position
val sortedMatches = matches.sortedBy { it.first }
// Apply styles
var lastIndex = 0
sortedMatches.forEach { (start, end, applier) ->
if (start >= lastIndex) {
// Append text before match
append(remainingText.substring(lastIndex, start))
// Apply style
applier("")
lastIndex = end
}
}
// Append remaining text
if (lastIndex < remainingText.length) {
append(remainingText.substring(lastIndex))
}
}