diff --git a/app/src/main/java/com/example/notesai/config/APIKEY.kt b/app/src/main/java/com/example/notesai/config/APIKEY.kt index d61fa93..726c14a 100644 --- a/app/src/main/java/com/example/notesai/config/APIKEY.kt +++ b/app/src/main/java/com/example/notesai/config/APIKEY.kt @@ -1,5 +1,5 @@ package com.example.notesai.config object APIKey { - const val GEMINI_API_KEY = "MY_GEMINI_KEY" + const val GEMINI_API_KEY = "AIzaSyAUHuVIZwXkl4_T8njUz9hFzRsHWbcXwjM" } \ No newline at end of file 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 4748e80..a631bc1 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 @@ -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 @@ -66,7 +67,7 @@ fun AIHelperScreen( val generativeModel = remember { GenerativeModel( modelName = "gemini-2.0-flash-exp", - apiKey = com.example.notesai.config.APIKey.GEMINI_API_KEY, + 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 } diff --git a/app/src/main/java/com/example/notesai/presentation/screens/ai/components/ChatBubble.kt b/app/src/main/java/com/example/notesai/presentation/screens/ai/components/ChatBubble.kt index c34ba58..7ac8b5f 100644 --- a/app/src/main/java/com/example/notesai/presentation/screens/ai/components/ChatBubble.kt +++ b/app/src/main/java/com/example/notesai/presentation/screens/ai/components/ChatBubble.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/com/example/notesai/util/MarkdownText.kt b/app/src/main/java/com/example/notesai/util/MarkdownText.kt new file mode 100644 index 0000000..ebe2912 --- /dev/null +++ b/app/src/main/java/com/example/notesai/util/MarkdownText.kt @@ -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 { + val blocks = mutableListOf() + 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() + 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() + 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 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)) + } +} \ No newline at end of file