History Chat AI berdasarkan Catatan yang ada didalam kategori dalam bentuk Drawer Menu di AI Helper

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-18 10:13:07 +07:00
parent 2037d32766
commit 2121682dd4
7 changed files with 1078 additions and 357 deletions

View File

@ -6,14 +6,6 @@
<entry key="Firebase Crashlytics"> <entry key="Firebase Crashlytics">
<value> <value>
<InsightsFilterSettings> <InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" /> <option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" /> <option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" /> <option name="visibilityType" value="ALL" />

View File

@ -178,20 +178,22 @@ fun NotesApp() {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Scaffold( Scaffold(
// Di MainActivity.kt, ubah bagian topBar menjadi:
topBar = { topBar = {
if (!showFullScreenNote) { // Hide TopBar untuk AI Helper screen dan FullScreen Note
if (!showFullScreenNote && currentScreen != "ai") {
ModernTopBar( ModernTopBar(
title = when(currentScreen) { title = when(currentScreen) {
"main" -> if (selectedCategory != null) selectedCategory!!.name else "AI Notes" "main" -> if (selectedCategory != null) selectedCategory!!.name else "AI Notes"
"ai" -> "AI Helper"
"starred" -> "Berbintang" "starred" -> "Berbintang"
"archive" -> "Arsip" "archive" -> "Arsip"
"trash" -> "Sampah" "trash" -> "Sampah"
else -> "AI Notes" else -> "AI Notes"
}, },
showBackButton = (selectedCategory != null && currentScreen == "main") || currentScreen == "ai" || currentScreen == "starred", showBackButton = (selectedCategory != null && currentScreen == "main") || currentScreen == "starred",
onBackClick = { onBackClick = {
if (currentScreen == "ai" || currentScreen == "starred") { if (currentScreen == "starred") {
currentScreen = "main" currentScreen = "main"
} else { } else {
selectedCategory = null selectedCategory = null

View File

@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.notesai.data.model.Note import com.example.notesai.data.model.Note
import com.example.notesai.data.model.Category import com.example.notesai.data.model.Category
import com.example.notesai.data.model.ChatHistory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -29,7 +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 // Support untuk soft delete val isDeleted: Boolean = false
) )
@Serializable @Serializable
@ -37,7 +38,7 @@ data class SerializableNote(
val id: String, val id: String,
val categoryId: String, val categoryId: String,
val title: String, val title: String,
val description: String = "", // Field baru untuk v1.1.0 val description: String = "",
val content: String = "", val content: String = "",
val timestamp: Long, val timestamp: Long,
val isArchived: Boolean = false, val isArchived: Boolean = false,
@ -49,10 +50,11 @@ class DataStoreManager(private val context: Context) {
companion object { companion object {
val CATEGORIES_KEY = stringPreferencesKey("categories") val CATEGORIES_KEY = stringPreferencesKey("categories")
val NOTES_KEY = stringPreferencesKey("notes") val NOTES_KEY = stringPreferencesKey("notes")
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history") // NEW
} }
private val json = Json { private val json = Json {
ignoreUnknownKeys = true // Penting untuk backward compatibility ignoreUnknownKeys = true
encodeDefaults = true encodeDefaults = true
} }
@ -99,7 +101,7 @@ class DataStoreManager(private val context: Context) {
id = it.id, id = it.id,
categoryId = it.categoryId, categoryId = it.categoryId,
title = it.title, title = it.title,
description = it.description, // Support field baru description = it.description,
content = it.content, content = it.content,
timestamp = it.timestamp, timestamp = it.timestamp,
isPinned = it.isPinned, isPinned = it.isPinned,
@ -113,6 +115,27 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Chat History Flow
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
try {
json.decodeFromString<List<ChatHistory>>(jsonString)
.filter { !it.isDeleted }
.sortedByDescending { it.timestamp }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
suspend fun saveCategories(categories: List<Category>) { suspend fun saveCategories(categories: List<Category>) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
@ -141,7 +164,7 @@ class DataStoreManager(private val context: Context) {
id = it.id, id = it.id,
categoryId = it.categoryId, categoryId = it.categoryId,
title = it.title, title = it.title,
description = it.description, // Support field baru description = it.description,
content = it.content, content = it.content,
timestamp = it.timestamp, timestamp = it.timestamp,
isPinned = it.isPinned, isPinned = it.isPinned,
@ -155,4 +178,45 @@ class DataStoreManager(private val context: Context) {
e.printStackTrace() e.printStackTrace()
} }
} }
// NEW: Save Chat History
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
try {
context.dataStore.edit { preferences ->
preferences[CHAT_HISTORY_KEY] = json.encodeToString(chatHistoryList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// NEW: Add new chat history
suspend fun addChatHistory(chatHistory: ChatHistory) {
try {
context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
currentList.add(0, chatHistory) // Add to beginning
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// NEW: Delete chat history (soft delete)
suspend fun deleteChatHistory(historyId: String) {
try {
context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
val updatedList = currentList.map {
if (it.id == historyId) it.copy(isDeleted = true) else it
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
} }

View File

@ -0,0 +1,39 @@
package com.example.notesai.data.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class ChatHistory(
val id: String = UUID.randomUUID().toString(),
val categoryId: String?, // null berarti "Semua Kategori"
val categoryName: String, // Untuk display
val messages: List<SerializableChatMessage>,
val lastMessagePreview: String, // Preview pesan terakhir
val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false
)
@Serializable
data class SerializableChatMessage(
val id: String,
val message: String,
val isUser: Boolean,
val timestamp: Long
)
// Extension function untuk convert ChatMessage ke SerializableChatMessage
fun ChatMessage.toSerializable() = SerializableChatMessage(
id = id,
message = message,
isUser = isUser,
timestamp = timestamp
)
// Extension function untuk convert SerializableChatMessage ke ChatMessage
fun SerializableChatMessage.toChatMessage() = ChatMessage(
id = id,
message = message,
isUser = isUser,
timestamp = timestamp
)

View File

@ -0,0 +1,537 @@
package com.example.notesai.presentation.screens.ai.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.ChatHistory
import com.example.notesai.data.model.Category
import com.example.notesai.data.model.Note
import com.example.notesai.util.Constants
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ChatHistoryDrawer(
chatHistories: List<ChatHistory>,
categories: List<Category>,
notes: List<Note>,
selectedCategory: Category?,
onDismiss: () -> Unit,
onHistoryClick: (ChatHistory) -> Unit,
onDeleteHistory: (String) -> Unit,
onCategorySelected: (Category?) -> Unit,
onNewChat: () -> Unit
) {
var showCategoryDropdown by remember { mutableStateOf(false) }
// Filter categories that have notes
val categoriesWithNotes = categories.filter { category ->
notes.any { note -> note.categoryId == category.id && !note.isArchived && !note.isDeleted }
}
// Group histories by category
val groupedHistories = chatHistories.groupBy { it.categoryId to it.categoryName }
// Backdrop with blur effect
Box(
modifier = Modifier
.fillMaxSize()
.background(Constants.AppColors.Overlay)
.clickable(
onClick = onDismiss,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
) {
// Drawer Content
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.align(Alignment.CenterStart)
.clickable(
onClick = {},
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
color = Constants.AppColors.Surface,
shadowElevation = Constants.Elevation.ExtraLarge.dp
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Box(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Constants.AppColors.Primary.copy(alpha = 0.15f),
Color.Transparent
)
)
)
.padding(Constants.Spacing.Large.dp)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
color = Constants.AppColors.Primary.copy(alpha = 0.2f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.History,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(24.dp)
)
}
Text(
"Riwayat Chat",
style = MaterialTheme.typography.headlineSmall,
color = Constants.AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"${chatHistories.size} percakapan tersimpan",
style = MaterialTheme.typography.bodySmall,
color = Constants.AppColors.OnSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
// Category Selector
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Constants.Spacing.Large.dp)
) {
Text(
"Filter Kategori",
style = MaterialTheme.typography.labelMedium,
color = Constants.AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
Spacer(modifier = Modifier.height(8.dp))
Box {
Card(
onClick = { showCategoryDropdown = !showCategoryDropdown },
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Constants.AppColors.SurfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(18.dp)
)
Text(
selectedCategory?.name ?: "Semua Kategori",
color = Constants.AppColors.OnSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = Constants.AppColors.OnSurfaceVariant
)
}
}
DropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false },
modifier = Modifier
.width(280.dp)
.background(Constants.AppColors.SurfaceElevated)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Constants.AppColors.OnSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(
"Semua Kategori",
color = Constants.AppColors.OnSurface
)
}
},
onClick = {
onCategorySelected(null)
showCategoryDropdown = false
onNewChat()
},
leadingIcon = {
if (selectedCategory == null) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
}
)
if (categoriesWithNotes.isNotEmpty()) {
HorizontalDivider(color = Constants.AppColors.Divider)
}
categoriesWithNotes.forEach { category ->
val notesCount = notes.count {
it.categoryId == category.id && !it.isArchived && !it.isDeleted
}
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Color(category.gradientStart),
modifier = Modifier.size(18.dp)
)
Text(
category.name,
color = Constants.AppColors.OnSurface
)
}
Surface(
color = Constants.AppColors.Primary.copy(alpha = 0.15f),
shape = RoundedCornerShape(Constants.Radius.Small.dp)
) {
Text(
notesCount.toString(),
modifier = Modifier.padding(
horizontal = 8.dp,
vertical = 2.dp
),
color = Constants.AppColors.Primary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
},
onClick = {
onCategorySelected(category)
showCategoryDropdown = false
onNewChat()
},
leadingIcon = {
if (selectedCategory?.id == category.id) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
}
)
}
}
}
}
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
HorizontalDivider(
color = Constants.AppColors.Divider,
modifier = Modifier.padding(horizontal = Constants.Spacing.Large.dp)
)
Spacer(modifier = Modifier.height(Constants.Spacing.Small.dp))
// Chat History List
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentPadding = PaddingValues(Constants.Spacing.Medium.dp),
verticalArrangement = Arrangement.spacedBy(Constants.Spacing.Medium.dp)
) {
if (chatHistories.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Constants.Spacing.ExtraLarge.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.ChatBubbleOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Constants.AppColors.OnSurfaceVariant
)
Text(
"Belum ada riwayat chat",
style = MaterialTheme.typography.bodyMedium,
color = Constants.AppColors.OnSurfaceVariant,
fontWeight = FontWeight.Medium
)
Text(
"Mulai chat dengan AI",
style = MaterialTheme.typography.bodySmall,
color = Constants.AppColors.OnSurfaceTertiary
)
}
}
} else {
// Group by category
groupedHistories.forEach { (categoryInfo, histories) ->
item {
// Category Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = Constants.Spacing.Small.dp,
vertical = Constants.Spacing.ExtraSmall.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Text(
categoryInfo.second,
style = MaterialTheme.typography.labelLarge,
color = Constants.AppColors.OnSurfaceVariant,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = Constants.AppColors.Divider
)
}
}
items(histories) { history ->
ChatHistoryItem(
history = history,
onClick = { onHistoryClick(history) },
onDelete = { onDeleteHistory(history.id) }
)
}
}
}
}
}
}
}
}
@Composable
private fun ChatHistoryItem(
history: ChatHistory,
onClick: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale("id", "ID")) }
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Constants.AppColors.SurfaceVariant
),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Constants.Spacing.Medium.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Message Count
Surface(
color = Constants.AppColors.Primary.copy(alpha = 0.15f),
shape = RoundedCornerShape(Constants.Radius.Small.dp)
) {
Row(
modifier = Modifier.padding(
horizontal = Constants.Spacing.Small.dp,
vertical = 4.dp
),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Chat,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(12.dp)
)
Text(
"${history.messages.size}",
color = Constants.AppColors.Primary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
// Delete Button
IconButton(
onClick = { showDeleteConfirm = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = Constants.AppColors.Error,
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Preview
Text(
history.lastMessagePreview,
style = MaterialTheme.typography.bodyMedium,
color = Constants.AppColors.OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(8.dp))
// Timestamp
Text(
dateFormat.format(Date(history.timestamp)),
style = MaterialTheme.typography.bodySmall,
color = Constants.AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
}
}
// Delete Confirmation Dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
icon = {
Icon(
Icons.Default.DeleteForever,
contentDescription = null,
tint = Constants.AppColors.Error
)
},
title = {
Text(
"Hapus Riwayat Chat?",
fontWeight = FontWeight.Bold
)
},
text = {
Text("Riwayat chat ini akan dihapus permanen dan tidak dapat dikembalikan.")
},
confirmButton = {
Button(
onClick = {
onDelete()
showDeleteConfirm = false
},
colors = ButtonDefaults.buttonColors(
containerColor = Constants.AppColors.Error
)
) {
Text("Hapus")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Batal")
}
},
containerColor = Constants.AppColors.SurfaceElevated
)
}
}

View File

@ -14,28 +14,26 @@ 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.clip
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.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.Category import com.example.notesai.data.local.DataStoreManager
import com.example.notesai.data.model.ChatMessage import com.example.notesai.data.model.*
import com.example.notesai.data.model.Note
import com.example.notesai.util.Constants import com.example.notesai.util.Constants
import com.google.ai.client.generativeai.GenerativeModel import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig 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.text.SimpleDateFormat
import java.util.* import java.util.*
import com.example.notesai.presentation.screens.ai.components.ChatBubble 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.CompactStatItem
import com.example.notesai.presentation.screens.ai.components.SuggestionChip import com.example.notesai.presentation.screens.ai.components.SuggestionChip
import com.example.notesai.presentation.screens.ai.components.StatItem
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -43,19 +41,26 @@ fun AIHelperScreen(
categories: List<Category>, categories: List<Category>,
notes: List<Note> notes: List<Note>
) { ) {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
var prompt by remember { mutableStateOf("") } var prompt by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var selectedCategory by remember { mutableStateOf<Category?>(null) } var selectedCategory by remember { mutableStateOf<Category?>(null) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) } var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) }
var showCopiedMessage by remember { mutableStateOf(false) } var showCopiedMessage by remember { mutableStateOf(false) }
var copiedMessageId by remember { mutableStateOf("") } var copiedMessageId by remember { mutableStateOf("") }
var showHistoryDrawer by remember { mutableStateOf(false) }
var currentChatId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
// Load chat histories
val chatHistories by dataStoreManager.chatHistoryFlow.collectAsState(initial = emptyList())
// Inisialisasi Gemini Model // Inisialisasi Gemini Model
val generativeModel = remember { val generativeModel = remember {
GenerativeModel( GenerativeModel(
@ -79,378 +84,460 @@ fun AIHelperScreen(
} }
} }
Column( // Function to save chat history
modifier = Modifier fun saveChatHistory() {
.fillMaxSize() if (chatMessages.isNotEmpty()) {
.background(Constants.AppColors.Background) scope.launch {
) { val lastMessage = chatMessages.lastOrNull()?.message ?: ""
// Category Selector & Stats - Compact val preview = if (lastMessage.length > 100) {
Surface( lastMessage.take(100) + "..."
color = Constants.AppColors.Surface, } else lastMessage
shadowElevation = 2.dp
val chatHistory = ChatHistory(
id = currentChatId ?: UUID.randomUUID().toString(),
categoryId = selectedCategory?.id,
categoryName = selectedCategory?.name ?: "Semua Kategori",
messages = chatMessages.map { it.toSerializable() },
lastMessagePreview = preview,
timestamp = System.currentTimeMillis()
)
dataStoreManager.addChatHistory(chatHistory)
currentChatId = chatHistory.id
}
}
}
// Function to load chat history
fun loadChatHistory(history: ChatHistory) {
chatMessages = history.messages.map { it.toChatMessage() }
currentChatId = history.id
selectedCategory = categories.find { it.id == history.categoryId }
showHistoryDrawer = false
}
// Function to start new chat
fun startNewChat() {
chatMessages = emptyList()
currentChatId = null
errorMessage = ""
showHistoryDrawer = false
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Constants.AppColors.Background)
) { ) {
Column( // Top Bar with History Button & Stats
modifier = Modifier Surface(
.fillMaxWidth() color = Constants.AppColors.Surface,
.padding(16.dp) shadowElevation = 2.dp
) { ) {
// Category Selector Column(
Box { modifier = Modifier
Card( .fillMaxWidth()
onClick = { showCategoryDropdown = !showCategoryDropdown }, .padding(16.dp)
) {
// Top Row - Menu & New Chat
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( horizontalArrangement = Arrangement.SpaceBetween,
containerColor = Constants.AppColors.SurfaceVariant verticalAlignment = Alignment.CenterVertically
),
shape = RoundedCornerShape(12.dp)
) { ) {
Row( // History Drawer Button
IconButton(
onClick = { showHistoryDrawer = true },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(40.dp)
.padding(12.dp), .background(
horizontalArrangement = Arrangement.SpaceBetween, Constants.AppColors.Primary.copy(alpha = 0.1f),
verticalAlignment = Alignment.CenterVertically CircleShape
)
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
tint = Constants.AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
// Category Badge
Surface(
color = Constants.AppColors.Primary.copy(alpha = 0.1f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.Folder, Icons.Default.Folder,
contentDescription = null, contentDescription = null,
tint = Constants.AppColors.Primary, tint = Constants.AppColors.Primary,
modifier = Modifier.size(20.dp) modifier = Modifier.size(16.dp)
) )
Text( Text(
selectedCategory?.name ?: "Semua Kategori", selectedCategory?.name ?: "Semua Kategori",
color = Constants.AppColors.OnSurface, color = Constants.AppColors.Primary,
style = MaterialTheme.typography.bodyMedium, fontSize = 13.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
} }
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = Constants.AppColors.OnSurfaceVariant
)
} }
}
DropdownMenu( // New Chat Button
expanded = showCategoryDropdown, if (chatMessages.isNotEmpty()) {
onDismissRequest = { showCategoryDropdown = false }, Button(
modifier = Modifier onClick = { startNewChat() },
.fillMaxWidth(0.9f) colors = ButtonDefaults.buttonColors(
.background(Constants.AppColors.SurfaceElevated) containerColor = Constants.AppColors.Primary
) { ),
DropdownMenuItem( shape = RoundedCornerShape(Constants.Radius.Medium.dp),
text = { Text("Semua Kategori", color = Constants.AppColors.OnSurface) }, contentPadding = PaddingValues(
onClick = { horizontal = Constants.Spacing.Medium.dp,
selectedCategory = null vertical = Constants.Spacing.Small.dp
showCategoryDropdown = false )
}
)
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name, color = Constants.AppColors.OnSurface) },
onClick = {
selectedCategory = category
showCategoryDropdown = false
}
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Stats - Compact
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CompactStatItem(
icon = Icons.Default.Description,
value = filteredNotes.size.toString(),
label = "Catatan"
)
CompactStatItem(
icon = Icons.Default.Star,
value = filteredNotes.count { it.isPinned }.toString(),
label = "Dipasang"
)
CompactStatItem(
icon = Icons.Default.Folder,
value = categories.size.toString(),
label = "Kategori"
)
}
}
}
HorizontalDivider(color = Constants.AppColors.Divider)
// Chat Area
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (chatMessages.isEmpty()) {
// Welcome State
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = Constants.AppColors.Primary.copy(alpha = 0.1f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = Constants.AppColors.Primary
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
"AI Assistant",
style = MaterialTheme.typography.headlineMedium,
color = Constants.AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tanyakan apa saja tentang catatan Anda",
style = MaterialTheme.typography.bodyLarge,
color = Constants.AppColors.OnSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
// Suggestion Chips
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
"Contoh pertanyaan:",
style = MaterialTheme.typography.labelMedium,
color = Constants.AppColors.OnSurfaceTertiary
)
SuggestionChip("Analisis catatan saya") { prompt = it }
SuggestionChip("Buat ringkasan") { prompt = it }
SuggestionChip("Berikan saran organisasi") { prompt = it }
}
}
} else {
// Chat Messages
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 100.dp)
) {
chatMessages.forEach { message ->
ChatBubble(
message = message,
onCopy = {
clipboardManager.setText(AnnotatedString(message.message))
copiedMessageId = message.id
showCopiedMessage = true
scope.launch {
delay(2000)
showCopiedMessage = false
}
},
showCopied = showCopiedMessage && copiedMessageId == message.id
)
Spacer(modifier = Modifier.height(12.dp))
}
// Loading Indicator
if (isLoading) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Start
) {
Surface(
color = Constants.AppColors.SurfaceVariant,
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Constants.AppColors.Primary,
strokeWidth = 2.dp
)
Text(
"AI sedang berpikir...",
color = Constants.AppColors.OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
// Error Message
if (errorMessage.isNotEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Constants.AppColors.Error.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon( Icon(
Icons.Default.Warning, Icons.Default.Add,
contentDescription = null, contentDescription = null,
tint = Constants.AppColors.Error, modifier = Modifier.size(18.dp)
modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
errorMessage, "Baru",
color = Constants.AppColors.Error, fontSize = 14.sp,
style = MaterialTheme.typography.bodySmall fontWeight = FontWeight.SemiBold
) )
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp))
// Stats - Compact
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CompactStatItem(
icon = Icons.Default.Description,
value = filteredNotes.size.toString(),
label = "Catatan"
)
CompactStatItem(
icon = Icons.Default.Star,
value = filteredNotes.count { it.isPinned }.toString(),
label = "Dipasang"
)
CompactStatItem(
icon = Icons.Default.Folder,
value = categories.size.toString(),
label = "Kategori"
)
}
}
}
HorizontalDivider(color = Constants.AppColors.Divider)
// Chat Area
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (chatMessages.isEmpty()) {
// Welcome State
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = Constants.AppColors.Primary.copy(alpha = 0.1f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = Constants.AppColors.Primary
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
"AI Assistant",
style = MaterialTheme.typography.headlineMedium,
color = Constants.AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Tanyakan apa saja tentang catatan Anda",
style = MaterialTheme.typography.bodyLarge,
color = Constants.AppColors.OnSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
// Suggestion Chips
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
"Contoh pertanyaan:",
style = MaterialTheme.typography.labelMedium,
color = Constants.AppColors.OnSurfaceTertiary
)
SuggestionChip("Analisis catatan saya") { prompt = it }
SuggestionChip("Buat ringkasan") { prompt = it }
SuggestionChip("Berikan saran organisasi") { prompt = it }
}
}
} else {
// Chat Messages
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 100.dp)
) {
chatMessages.forEach { message ->
ChatBubble(
message = message,
onCopy = {
clipboardManager.setText(AnnotatedString(message.message))
copiedMessageId = message.id
showCopiedMessage = true
scope.launch {
delay(2000)
showCopiedMessage = false
}
},
showCopied = showCopiedMessage && copiedMessageId == message.id
)
Spacer(modifier = Modifier.height(12.dp))
}
// Loading Indicator
if (isLoading) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Start
) {
Surface(
color = Constants.AppColors.SurfaceVariant,
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Constants.AppColors.Primary,
strokeWidth = 2.dp
)
Text(
"AI sedang berpikir...",
color = Constants.AppColors.OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
// Error Message
if (errorMessage.isNotEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Constants.AppColors.Error.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = Constants.AppColors.Error,
modifier = Modifier.size(20.dp)
)
Text(
errorMessage,
color = Constants.AppColors.Error,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
} }
} }
}
// Input Area - Minimalist // Input Area - Minimalist
Surface( Surface(
color = Constants.AppColors.Surface, color = Constants.AppColors.Surface,
shadowElevation = 8.dp, shadowElevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
OutlinedTextField( Row(
value = prompt,
onValueChange = { prompt = it },
placeholder = {
Text(
"Ketik pesan...",
color = Constants.AppColors.OnSurfaceTertiary
)
},
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.heightIn(min = 48.dp, max = 120.dp), .padding(16.dp),
colors = OutlinedTextFieldDefaults.colors( verticalAlignment = Alignment.Bottom,
focusedTextColor = Constants.AppColors.OnBackground, horizontalArrangement = Arrangement.spacedBy(12.dp)
unfocusedTextColor = Constants.AppColors.OnSurface, ) {
focusedContainerColor = Constants.AppColors.SurfaceVariant, OutlinedTextField(
unfocusedContainerColor = Constants.AppColors.SurfaceVariant, value = prompt,
cursorColor = Constants.AppColors.Primary, onValueChange = { prompt = it },
focusedBorderColor = Constants.AppColors.Primary, placeholder = {
unfocusedBorderColor = Color.Transparent Text(
), "Ketik pesan...",
shape = RoundedCornerShape(24.dp), color = Constants.AppColors.OnSurfaceTertiary
maxLines = 4 )
) },
modifier = Modifier
// Send Button .weight(1f)
FloatingActionButton( .heightIn(min = 48.dp, max = 120.dp),
onClick = { colors = OutlinedTextFieldDefaults.colors(
if (prompt.isNotBlank() && !isLoading) { focusedTextColor = Constants.AppColors.OnBackground,
scope.launch { unfocusedTextColor = Constants.AppColors.OnSurface,
chatMessages = chatMessages + ChatMessage( focusedContainerColor = Constants.AppColors.SurfaceVariant,
message = prompt, unfocusedContainerColor = Constants.AppColors.SurfaceVariant,
isUser = true cursorColor = Constants.AppColors.Primary,
) focusedBorderColor = Constants.AppColors.Primary,
unfocusedBorderColor = Color.Transparent
val userPrompt = prompt ),
prompt = "" shape = RoundedCornerShape(24.dp),
isLoading = true maxLines = 4
errorMessage = "" )
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
chatMessages = chatMessages + ChatMessage( chatMessages = chatMessages + ChatMessage(
message = response, message = prompt,
isUser = false isUser = true
) )
} catch (e: Exception) {
errorMessage = "Error: ${e.message}" val userPrompt = prompt
} finally { prompt = ""
isLoading = false isLoading = true
errorMessage = ""
try {
val filteredNotes = if (selectedCategory != null) {
notes.filter { it.categoryId == selectedCategory!!.id && !it.isArchived }
} else {
notes.filter { !it.isArchived }
}
val notesContext = buildString {
appendLine("Data catatan pengguna:")
appendLine("Total catatan: ${filteredNotes.size}")
appendLine("Kategori: ${selectedCategory?.name ?: "Semua"}")
appendLine()
appendLine("Daftar catatan:")
filteredNotes.take(10).forEach { note ->
appendLine("- Judul: ${note.title}")
appendLine(" Isi: ${note.content.take(100)}")
appendLine()
}
}
val fullPrompt = "$notesContext\n\nPertanyaan: $userPrompt\n\nBerikan jawaban dalam bahasa Indonesia yang natural dan membantu."
val result = generativeModel.generateContent(fullPrompt)
val response = result.text ?: "Tidak ada respons dari AI"
chatMessages = chatMessages + ChatMessage(
message = response,
isUser = false
)
// Auto-save chat history
saveChatHistory()
} catch (e: Exception) {
errorMessage = "Error: ${e.message}"
} finally {
isLoading = false
}
} }
} }
} },
}, containerColor = Constants.AppColors.Primary,
containerColor = Constants.AppColors.Primary, modifier = Modifier.size(48.dp)
modifier = Modifier.size(48.dp) ) {
) { Icon(
Icon( Icons.Default.Send,
Icons.Default.Send, contentDescription = "Send",
contentDescription = "Send", tint = Color.White,
tint = Color.White, modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp) )
) }
} }
} }
} }
// Chat History Drawer
AnimatedVisibility(
visible = showHistoryDrawer,
enter = fadeIn() + slideInHorizontally(),
exit = fadeOut() + slideOutHorizontally()
) {
ChatHistoryDrawer(
chatHistories = chatHistories,
categories = categories,
notes = notes,
selectedCategory = selectedCategory,
onDismiss = { showHistoryDrawer = false },
onHistoryClick = { loadChatHistory(it) },
onDeleteHistory = { historyId ->
scope.launch {
dataStoreManager.deleteChatHistory(historyId)
}
},
onCategorySelected = { category ->
selectedCategory = category
},
onNewChat = { startNewChat() }
)
}
} }
} }

View File

@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.Color
object Constants { object Constants {
// App Info // App Info
const val APP_NAME = "NotesAI" const val APP_NAME = "NotesAI"
const val APP_VERSION = "1.0.0" const val APP_VERSION = "1.1.0"
// DataStore // DataStore
const val DATASTORE_NAME = "notes_prefs" const val DATASTORE_NAME = "notes_prefs"