Compare commits

..

No commits in common. "79f7e33a5a781ef19a18ea01e4a7aa0b40c221d9" and "520da1f66a53809cd1702cee01b5bfdae6e67f76" have entirely different histories.

4 changed files with 93 additions and 431 deletions

View File

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

View File

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

View File

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

View File

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