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 NOTES_KEY = stringPreferencesKey("notes")
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 {
@ -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
.catch { exception ->
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>) {
try {
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) {
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
// 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
}
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
}
} 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) {
try {
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
.catch { exception ->
if (exception is IOException) {
@ -234,7 +262,7 @@ class DataStoreManager(private val context: Context) {
preferences[THEME_KEY] ?: "dark" // Default dark theme
}
// NEW: Save Theme Preference
// Save Theme Preference
suspend fun saveTheme(theme: String) {
try {
context.dataStore.edit { preferences ->

View File

@ -10,6 +10,7 @@ data class ChatHistory(
val categoryName: String, // Untuk display
val messages: List<SerializableChatMessage>,
val lastMessagePreview: String, // Preview pesan terakhir
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
val timestamp: Long = System.currentTimeMillis(),
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.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)
@Composable
fun AIHelperScreen(
@ -92,9 +102,7 @@ fun AIHelperScreen(
if (chatMessages.isNotEmpty()) {
scope.launch {
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
val preview = if (lastMessage.length > 100) {
lastMessage.take(100) + "..."
} else lastMessage
val preview = lastMessage.toSafeChatPreview()
val chatHistory = ChatHistory(
id = currentChatId ?: UUID.randomUUID().toString(),
@ -102,6 +110,7 @@ fun AIHelperScreen(
categoryName = selectedCategory?.name ?: "Semua Kategori",
messages = chatMessages.map { it.toSerializable() },
lastMessagePreview = preview,
customTitle = null,
timestamp = System.currentTimeMillis()
)
@ -555,7 +564,12 @@ fun AIHelperScreen(
onCategorySelected = { 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),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
// Preview Text with Markdown
// Preview Text with Markdown (use customTitle if available)
Text(
text = buildAnnotatedString {
appendInlineMarkdown(history.lastMessagePreview)
appendInlineMarkdown(history.customTitle ?: history.lastMessagePreview)
},
style = MaterialTheme.typography.bodySmall,
color = AppColors.OnSurface,
@ -709,7 +709,11 @@ private fun CompactChatHistoryItem(
// Edit Dialog
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(
onDismissRequest = { showEditDialog = false },
@ -730,40 +734,105 @@ private fun CompactChatHistoryItem(
text = {
Column {
Text(
"Ubah judul untuk memudahkan pencarian",
fontSize = 13.sp,
"Maks 30 karakter • Gunakan markdown",
fontSize = 11.sp,
color = AppColors.OnSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = editTitle,
onValueChange = { editTitle = it },
onValueChange = {
if (it.length <= maxTitleLength) {
editTitle = it
}
},
label = {
Text(
"Judul Chat",
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,
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
),
shape = RoundedCornerShape(8.dp),
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 = {
Button(
onClick = {
if (editTitle.isNotBlank()) {
onEdit(editTitle)
onEdit(editTitle.trim())
showEditDialog = false
}
},
enabled = editTitle.isNotBlank() && editTitle.trim().isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Primary
containerColor = AppColors.Primary,
disabledContainerColor = AppColors.OnSurfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(8.dp)
) {
@ -883,79 +952,80 @@ private fun groupHistoriesByDate(histories: List<ChatHistory>): Map<String, List
// Parse inline markdown for chat history titles
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
var currentIndex = 0
var processedText = text
val styles = mutableListOf<Triple<IntRange, SpanStyle, String>>()
// Pattern untuk bold dengan **
val boldPattern = Regex("""\*\*(.+?)\*\*""")
boldPattern.findAll(processedText).forEach { match ->
styles.add(
Triple(
match.range,
SpanStyle(fontWeight = FontWeight.Bold),
match.groupValues[1]
)
)
}
// List of patterns to process
val patterns = listOf(
// Bold with **
Regex("""\*\*(.+?)\*\*""") to { content: String ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
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)
}
}
)
// 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]
// 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
)
)
}
}
// 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 }
// 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
sortedStyles.forEach { (range, style, content) ->
// Append text before styled content
if (range.first > lastIndex) {
append(processedText.substring(lastIndex, range.first))
sortedMatches.forEach { match ->
// Append text before match
if (match.start > lastIndex) {
append(processedText.substring(lastIndex, match.start))
}
// Append styled content
withStyle(style) {
append(content)
}
lastIndex = range.last + 1
// Apply style
match.applier(match.content)
lastIndex = match.end
}
// Append remaining text