Edit Title dan Filter Chat History

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-12-18 21:26:38 +07:00
parent 74e1a720cd
commit 5503d53881
4 changed files with 196 additions and 83 deletions

View File

@ -51,7 +51,7 @@ class DataStoreManager(private val context: Context) {
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") val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
val THEME_KEY = stringPreferencesKey("theme") // NEW: "dark" or "light" val THEME_KEY = stringPreferencesKey("theme") // "dark" or "light"
} }
private val json = Json { private val json = Json {
@ -116,7 +116,7 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Chat History Flow // Chat History Flow
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
.catch { exception -> .catch { exception ->
if (exception is IOException) { if (exception is IOException) {
@ -180,7 +180,7 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Save Chat History // Save Chat History
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) { suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
@ -191,13 +191,21 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Add new chat history // Add new chat history
suspend fun addChatHistory(chatHistory: ChatHistory) { suspend fun addChatHistory(chatHistory: ChatHistory) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]" val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList() val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
// Check if already exists, update instead
val existingIndex = currentList.indexOfFirst { it.id == chatHistory.id }
if (existingIndex != -1) {
currentList[existingIndex] = chatHistory
} else {
currentList.add(0, chatHistory) // Add to beginning currentList.add(0, chatHistory) // Add to beginning
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList) preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -205,7 +213,7 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Delete chat history (soft delete) // Delete chat history (soft delete)
suspend fun deleteChatHistory(historyId: String) { suspend fun deleteChatHistory(historyId: String) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
@ -221,7 +229,27 @@ class DataStoreManager(private val context: Context) {
} }
} }
// NEW: Theme Preference Flow // NEW: Update chat history title
suspend fun updateChatHistoryTitle(historyId: String, newTitle: 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(customTitle = newTitle)
} else {
it
}
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Theme Preference Flow
val themeFlow: Flow<String> = context.dataStore.data val themeFlow: Flow<String> = context.dataStore.data
.catch { exception -> .catch { exception ->
if (exception is IOException) { if (exception is IOException) {
@ -234,7 +262,7 @@ class DataStoreManager(private val context: Context) {
preferences[THEME_KEY] ?: "dark" // Default dark theme preferences[THEME_KEY] ?: "dark" // Default dark theme
} }
// NEW: Save Theme Preference // Save Theme Preference
suspend fun saveTheme(theme: String) { suspend fun saveTheme(theme: String) {
try { try {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->

View File

@ -10,6 +10,7 @@ data class ChatHistory(
val categoryName: String, // Untuk display val categoryName: String, // Untuk display
val messages: List<SerializableChatMessage>, val messages: List<SerializableChatMessage>,
val lastMessagePreview: String, // Preview pesan terakhir val lastMessagePreview: String, // Preview pesan terakhir
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
val isDeleted: Boolean = false val isDeleted: Boolean = false
) )

View File

@ -38,6 +38,16 @@ import com.example.notesai.presentation.screens.ai.components.CompactStatItem
import com.example.notesai.presentation.screens.ai.components.SuggestionChip import com.example.notesai.presentation.screens.ai.components.SuggestionChip
import com.example.notesai.util.AppColors import com.example.notesai.util.AppColors
private const val MAX_CHAT_TITLE_LENGTH = 30
private fun String.toSafeChatPreview(maxLength: Int = MAX_CHAT_TITLE_LENGTH): String {
return if (this.length > maxLength) {
this.take(maxLength).trim() + "..."
} else {
this.trim()
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AIHelperScreen( fun AIHelperScreen(
@ -92,9 +102,7 @@ fun AIHelperScreen(
if (chatMessages.isNotEmpty()) { if (chatMessages.isNotEmpty()) {
scope.launch { scope.launch {
val lastMessage = chatMessages.lastOrNull()?.message ?: "" val lastMessage = chatMessages.lastOrNull()?.message ?: ""
val preview = if (lastMessage.length > 100) { val preview = lastMessage.toSafeChatPreview()
lastMessage.take(100) + "..."
} else lastMessage
val chatHistory = ChatHistory( val chatHistory = ChatHistory(
id = currentChatId ?: UUID.randomUUID().toString(), id = currentChatId ?: UUID.randomUUID().toString(),
@ -102,6 +110,7 @@ fun AIHelperScreen(
categoryName = selectedCategory?.name ?: "Semua Kategori", categoryName = selectedCategory?.name ?: "Semua Kategori",
messages = chatMessages.map { it.toSerializable() }, messages = chatMessages.map { it.toSerializable() },
lastMessagePreview = preview, lastMessagePreview = preview,
customTitle = null,
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
) )
@ -555,7 +564,12 @@ fun AIHelperScreen(
onCategorySelected = { category -> onCategorySelected = { category ->
selectedCategory = category selectedCategory = category
}, },
onNewChat = { startNewChat() } onNewChat = { startNewChat() },
onEditHistoryTitle = { historyId, newTitle ->
scope.launch {
dataStoreManager.updateChatHistoryTitle(historyId, newTitle)
}
}
) )
} }
} }

View File

@ -499,10 +499,10 @@ private fun CompactChatHistoryItem(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp) verticalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
// Preview Text with Markdown // Preview Text with Markdown (use customTitle if available)
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
appendInlineMarkdown(history.lastMessagePreview) appendInlineMarkdown(history.customTitle ?: history.lastMessagePreview)
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurface, color = AppColors.OnSurface,
@ -709,7 +709,11 @@ private fun CompactChatHistoryItem(
// Edit Dialog // Edit Dialog
if (showEditDialog) { if (showEditDialog) {
var editTitle by remember { mutableStateOf(history.lastMessagePreview.take(50)) } val maxTitleLength = 30
var editTitle by remember {
val currentTitle = history.customTitle ?: history.lastMessagePreview
mutableStateOf(currentTitle.take(maxTitleLength))
}
AlertDialog( AlertDialog(
onDismissRequest = { showEditDialog = false }, onDismissRequest = { showEditDialog = false },
@ -730,40 +734,105 @@ private fun CompactChatHistoryItem(
text = { text = {
Column { Column {
Text( Text(
"Ubah judul untuk memudahkan pencarian", "Maks 30 karakter • Gunakan markdown",
fontSize = 13.sp, fontSize = 11.sp,
color = AppColors.OnSurfaceVariant color = AppColors.OnSurfaceVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( OutlinedTextField(
value = editTitle, value = editTitle,
onValueChange = { editTitle = it }, onValueChange = {
if (it.length <= maxTitleLength) {
editTitle = it
}
},
label = { label = {
Text( Text(
"Judul Chat", "Judul Chat",
fontSize = 12.sp fontSize = 12.sp
) )
}, },
placeholder = {
Text(
"**Analisis** Data",
fontSize = 11.sp
)
},
supportingText = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text(
"${editTitle.length}/$maxTitleLength",
fontSize = 10.sp,
color = when {
editTitle.length >= maxTitleLength -> AppColors.Error
editTitle.length >= maxTitleLength - 5 -> AppColors.Primary
else -> AppColors.OnSurfaceTertiary
},
fontWeight = if (editTitle.length >= maxTitleLength - 5)
FontWeight.Bold
else
FontWeight.Normal
)
}
},
singleLine = true, singleLine = true,
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary, focusedBorderColor = if (editTitle.length >= maxTitleLength)
AppColors.Error
else
AppColors.Primary,
unfocusedBorderColor = if (editTitle.length >= maxTitleLength)
AppColors.Error.copy(alpha = 0.5f)
else
Color.Gray,
cursorColor = AppColors.Primary cursorColor = AppColors.Primary
), ),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp) textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp)
) )
// Preview
if (editTitle.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Preview:",
fontSize = 10.sp,
color = AppColors.OnSurfaceTertiary,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Surface(
color = AppColors.SurfaceVariant,
shape = RoundedCornerShape(6.dp)
) {
Text(
text = buildAnnotatedString {
appendInlineMarkdown(editTitle)
},
modifier = Modifier.padding(8.dp),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
} }
}, },
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
if (editTitle.isNotBlank()) { if (editTitle.isNotBlank()) {
onEdit(editTitle) onEdit(editTitle.trim())
showEditDialog = false showEditDialog = false
} }
}, },
enabled = editTitle.isNotBlank() && editTitle.trim().isNotEmpty(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Primary containerColor = AppColors.Primary,
disabledContainerColor = AppColors.OnSurfaceVariant.copy(alpha = 0.3f)
), ),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) { ) {
@ -883,79 +952,80 @@ private fun groupHistoriesByDate(histories: List<ChatHistory>): Map<String, List
// Parse inline markdown for chat history titles // Parse inline markdown for chat history titles
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) { private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
var currentIndex = 0
var processedText = text var processedText = text
val styles = mutableListOf<Triple<IntRange, SpanStyle, String>>()
// Pattern untuk bold dengan ** // List of patterns to process
val boldPattern = Regex("""\*\*(.+?)\*\*""") val patterns = listOf(
boldPattern.findAll(processedText).forEach { match -> // Bold with **
styles.add( Regex("""\*\*(.+?)\*\*""") to { content: String ->
Triple( withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
match.range,
SpanStyle(fontWeight = FontWeight.Bold),
match.groupValues[1]
)
)
}
// Pattern untuk italic dengan *
val italicPattern = Regex("""\*(?!\*)(.+?)\*(?!\*)""")
italicPattern.findAll(processedText).forEach { match ->
if (!processedText.substring(maxOf(0, match.range.first - 1), match.range.first).contains("*")) {
styles.add(
Triple(
match.range,
SpanStyle(fontStyle = FontStyle.Italic),
match.groupValues[1]
)
)
}
}
// Pattern untuk inline code dengan `
val codePattern = Regex("""`(.+?)`""")
codePattern.findAll(processedText).forEach { match ->
styles.add(
Triple(
match.range,
SpanStyle(
fontFamily = FontFamily.Monospace,
background = AppColors.Primary.copy(alpha = 0.15f),
fontSize = 12.sp
),
match.groupValues[1]
)
)
}
// Pattern untuk strikethrough dengan ~~
val strikePattern = Regex("""~~(.+?)~~""")
strikePattern.findAll(processedText).forEach { match ->
styles.add(
Triple(
match.range,
SpanStyle(textDecoration = TextDecoration.LineThrough),
match.groupValues[1]
)
)
}
// Sort by range start
val sortedStyles = styles.sortedBy { it.first.first }
var lastIndex = 0
sortedStyles.forEach { (range, style, content) ->
// Append text before styled content
if (range.first > lastIndex) {
append(processedText.substring(lastIndex, range.first))
}
// Append styled content
withStyle(style) {
append(content) append(content)
} }
},
// Inline code with `
Regex("""`(.+?)`""") to { content: String ->
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = AppColors.Primary.copy(alpha = 0.15f)
)
) {
append(content)
}
},
// Italic with * (but not **)
Regex("""\*([^*]+?)\*""") to { content: String ->
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(content)
}
},
// Strikethrough with ~~
Regex("""~~(.+?)~~""") to { content: String ->
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(content)
}
}
)
lastIndex = range.last + 1 // Collect all matches
data class Match(val start: Int, val end: Int, val content: String, val applier: (String) -> Unit)
val matches = mutableListOf<Match>()
patterns.forEach { (regex, applier) ->
regex.findAll(processedText).forEach { matchResult ->
val content = matchResult.groupValues[1]
matches.add(
Match(
start = matchResult.range.first,
end = matchResult.range.last + 1,
content = content,
applier = applier
)
)
}
}
// Sort matches by start position and remove overlaps
val sortedMatches = matches
.sortedBy { it.start }
.fold(mutableListOf<Match>()) { acc, match ->
if (acc.isEmpty() || match.start >= acc.last().end) {
acc.add(match)
}
acc
}
// Build the annotated string
var lastIndex = 0
sortedMatches.forEach { match ->
// Append text before match
if (match.start > lastIndex) {
append(processedText.substring(lastIndex, match.start))
}
// Apply style
match.applier(match.content)
lastIndex = match.end
} }
// Append remaining text // Append remaining text