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">
<value>
<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="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />

View File

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

View File

@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.notesai.data.model.Note
import com.example.notesai.data.model.Category
import com.example.notesai.data.model.ChatHistory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@ -29,7 +30,7 @@ data class SerializableCategory(
val gradientStart: Long,
val gradientEnd: Long,
val timestamp: Long,
val isDeleted: Boolean = false // Support untuk soft delete
val isDeleted: Boolean = false
)
@Serializable
@ -37,7 +38,7 @@ data class SerializableNote(
val id: String,
val categoryId: String,
val title: String,
val description: String = "", // Field baru untuk v1.1.0
val description: String = "",
val content: String = "",
val timestamp: Long,
val isArchived: Boolean = false,
@ -49,10 +50,11 @@ class DataStoreManager(private val context: Context) {
companion object {
val CATEGORIES_KEY = stringPreferencesKey("categories")
val NOTES_KEY = stringPreferencesKey("notes")
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history") // NEW
}
private val json = Json {
ignoreUnknownKeys = true // Penting untuk backward compatibility
ignoreUnknownKeys = true
encodeDefaults = true
}
@ -99,7 +101,7 @@ class DataStoreManager(private val context: Context) {
id = it.id,
categoryId = it.categoryId,
title = it.title,
description = it.description, // Support field baru
description = it.description,
content = it.content,
timestamp = it.timestamp,
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>) {
try {
context.dataStore.edit { preferences ->
@ -141,7 +164,7 @@ class DataStoreManager(private val context: Context) {
id = it.id,
categoryId = it.categoryId,
title = it.title,
description = it.description, // Support field baru
description = it.description,
content = it.content,
timestamp = it.timestamp,
isPinned = it.isPinned,
@ -155,4 +178,45 @@ class DataStoreManager(private val context: Context) {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.notesai.data.model.Category
import com.example.notesai.data.model.ChatMessage
import com.example.notesai.data.model.Note
import com.example.notesai.data.local.DataStoreManager
import com.example.notesai.data.model.*
import com.example.notesai.util.Constants
import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
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.SuggestionChip
import com.example.notesai.presentation.screens.ai.components.StatItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -43,19 +41,26 @@ fun AIHelperScreen(
categories: List<Category>,
notes: List<Note>
) {
val context = LocalContext.current
val dataStoreManager = remember { DataStoreManager(context) }
var prompt by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var selectedCategory by remember { mutableStateOf<Category?>(null) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var chatMessages by remember { mutableStateOf(listOf<ChatMessage>()) }
var showCopiedMessage by remember { mutableStateOf(false) }
var copiedMessageId by remember { mutableStateOf("") }
var showHistoryDrawer by remember { mutableStateOf(false) }
var currentChatId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState()
// Load chat histories
val chatHistories by dataStoreManager.chatHistoryFlow.collectAsState(initial = emptyList())
// Inisialisasi Gemini Model
val generativeModel = remember {
GenerativeModel(
@ -79,378 +84,460 @@ fun AIHelperScreen(
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Constants.AppColors.Background)
) {
// Category Selector & Stats - Compact
Surface(
color = Constants.AppColors.Surface,
shadowElevation = 2.dp
// Function to save chat history
fun saveChatHistory() {
if (chatMessages.isNotEmpty()) {
scope.launch {
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
val preview = if (lastMessage.length > 100) {
lastMessage.take(100) + "..."
} else lastMessage
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(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
// Top Bar with History Button & Stats
Surface(
color = Constants.AppColors.Surface,
shadowElevation = 2.dp
) {
// Category Selector
Box {
Card(
onClick = { showCategoryDropdown = !showCategoryDropdown },
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Top Row - Menu & New Chat
Row(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Constants.AppColors.SurfaceVariant
),
shape = RoundedCornerShape(12.dp)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
// History Drawer Button
IconButton(
onClick = { showHistoryDrawer = true },
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
.size(40.dp)
.background(
Constants.AppColors.Primary.copy(alpha = 0.1f),
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(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(
horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = Constants.AppColors.Primary,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(16.dp)
)
Text(
selectedCategory?.name ?: "Semua Kategori",
color = Constants.AppColors.OnSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
color = Constants.AppColors.Primary,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold
)
}
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = Constants.AppColors.OnSurfaceVariant
)
}
}
DropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false },
modifier = Modifier
.fillMaxWidth(0.9f)
.background(Constants.AppColors.SurfaceElevated)
) {
DropdownMenuItem(
text = { Text("Semua Kategori", color = Constants.AppColors.OnSurface) },
onClick = {
selectedCategory = null
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)
// New Chat Button
if (chatMessages.isNotEmpty()) {
Button(
onClick = { startNewChat() },
colors = ButtonDefaults.buttonColors(
containerColor = Constants.AppColors.Primary
),
shape = RoundedCornerShape(Constants.Radius.Medium.dp),
contentPadding = PaddingValues(
horizontal = Constants.Spacing.Medium.dp,
vertical = Constants.Spacing.Small.dp
)
) {
Icon(
Icons.Default.Warning,
Icons.Default.Add,
contentDescription = null,
tint = Constants.AppColors.Error,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
errorMessage,
color = Constants.AppColors.Error,
style = MaterialTheme.typography.bodySmall
"Baru",
fontSize = 14.sp,
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
Surface(
color = Constants.AppColors.Surface,
shadowElevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
// Input Area - Minimalist
Surface(
color = Constants.AppColors.Surface,
shadowElevation = 8.dp,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
OutlinedTextField(
value = prompt,
onValueChange = { prompt = it },
placeholder = {
Text(
"Ketik pesan...",
color = Constants.AppColors.OnSurfaceTertiary
)
},
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Constants.AppColors.OnBackground,
unfocusedTextColor = Constants.AppColors.OnSurface,
focusedContainerColor = Constants.AppColors.SurfaceVariant,
unfocusedContainerColor = Constants.AppColors.SurfaceVariant,
cursorColor = Constants.AppColors.Primary,
focusedBorderColor = Constants.AppColors.Primary,
unfocusedBorderColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp),
maxLines = 4
)
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
chatMessages = chatMessages + ChatMessage(
message = prompt,
isUser = true
)
val userPrompt = prompt
prompt = ""
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"
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = prompt,
onValueChange = { prompt = it },
placeholder = {
Text(
"Ketik pesan...",
color = Constants.AppColors.OnSurfaceTertiary
)
},
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Constants.AppColors.OnBackground,
unfocusedTextColor = Constants.AppColors.OnSurface,
focusedContainerColor = Constants.AppColors.SurfaceVariant,
unfocusedContainerColor = Constants.AppColors.SurfaceVariant,
cursorColor = Constants.AppColors.Primary,
focusedBorderColor = Constants.AppColors.Primary,
unfocusedBorderColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp),
maxLines = 4
)
// Send Button
FloatingActionButton(
onClick = {
if (prompt.isNotBlank() && !isLoading) {
scope.launch {
chatMessages = chatMessages + ChatMessage(
message = response,
isUser = false
message = prompt,
isUser = true
)
} catch (e: Exception) {
errorMessage = "Error: ${e.message}"
} finally {
isLoading = false
val userPrompt = prompt
prompt = ""
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,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
},
containerColor = Constants.AppColors.Primary,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
tint = Color.White,
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 {
// App Info
const val APP_NAME = "NotesAI"
const val APP_VERSION = "1.0.0"
const val APP_VERSION = "1.1.0"
// DataStore
const val DATASTORE_NAME = "notes_prefs"