Compare commits

...

7 Commits

15 changed files with 2127 additions and 762 deletions

View File

@ -146,6 +146,54 @@
---
## **Sprint 4: Rich Text Editor Core Features & AI Chat History UI/UX Improvements**
### **Rich Text Editing**
* **Hybrid Rich Text Editor (WYSIWYG)** Edit teks dengan format langsung tanpa syntax markdown terlihat
* **Bold, Italic, Underline** Formatting bersifat toggle dan tetap aktif sampai dimatikan
* **Heading & Bullet List** Support heading (H1H3) dan bullet list tanpa konflik antar format
* **Undo / Redo** Riwayat perubahan editor terintegrasi
### **Floating Toolbar**
* **Draggable Mini Toolbar** Toolbar dapat dipindahkan bebas oleh user
* **Active State Indicator** Icon toolbar menandakan format aktif (Bold, Italic, dll)
* **Minimal UI** Toolbar kecil agar tidak mengganggu area pengetikan
* **Keyboard-Aware Positioning** Posisi toolbar menyesuaikan saat keyboard muncul
### **Cursor & Editing Stability**
* **Stable Cursor & Selection** Insertion point dan selection handle akurat saat mengetik
* **IME & Keyboard Safe** Editor tetap stabil saat keyboard resize / rotate
* **Auto Bring-Into-View** Cursor selalu terlihat saat mengetik di area bawah layar
### **Data Persistence**
* **Format Tersimpan Permanen** Rich text tidak hilang setelah save atau reopen
* **Auto Save Lifecycle-Aware** Catatan otomatis tersimpan saat app background / keluar
* **Markdown Compatibility** Support import & export markdown secara aman
### **Chat History Enhancements**
* **Compact Modern Design** - Item lebih kecil dengan horizontal layout dan 30 karakter limit
* **Search & Filter System** - Real-time search dengan category dropdown filtering
* **Date Grouping** - Auto-group: "Hari Ini", "Kemarin", "Minggu Ini", "Lebih Lama"
* **Edit Title with Markdown** - Custom title support: **bold**, *italic*, `code`, ~~strike~~
* **Context Menu** - Three-dot menu (⋮) untuk Edit dan Delete actions
* **Live Preview** - Real-time markdown preview saat edit title
### **Technical Updates**
* **ChatHistory Model** - Added `customTitle: String?` field
* **DataStore Integration** - New `updateChatHistoryTitle()` function
* **Smart Truncation** - Auto-truncate preview ke 30 char dengan `toSafeChatPreview()`
* **Markdown Parser** - Inline markdown rendering untuk titles dengan proper styling
* **Character Counter** - Visual feedback dengan color indicator (Gray → Primary → Red)
### **User Experience**
* **Better Empty States** - Informative UI untuk empty search dan no history
* **Smooth Animations** - Slide transitions untuk dialogs
* **Input Validation** - Max 30 char dengan real-time blocking
* **Focus Management** - Seamless editing experience dengan auto-focus
> Rich Text Editor butuh dikembangkan lagi lebih advance
---
## **Fitur Utama Aplikasi**
* Sistem kategori dengan gradient
@ -177,8 +225,8 @@
## **Features for Sprint 4 v1.1.0**
* Penyesuaian UI/UX History Chat AI
* Rich text editor
* AI Agent Catatan
* Penyesuaian UI/UX History Chat AI (ok)
* Rich text editor (ok - Pengembangan Lanjutan)
* AI Agent Catatan
* Fungsi AI (Upload File)
* Fitur Sematkan Category, otomatis paling atas

View File

@ -67,6 +67,9 @@ dependencies {
implementation(libs.androidx.ui.text)
implementation(libs.androidx.material3)
implementation(libs.androidx.animation.core)
implementation(libs.androidx.glance)
implementation(libs.androidx.animation)
implementation(libs.androidx.ui.graphics)
// Untuk integrasi Gemini AI (optional - uncomment jika sudah ada API key)
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2")

View File

@ -16,6 +16,7 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.NotesAI">
<intent-filter>

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

@ -1,538 +0,0 @@
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.AppColors
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(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 = AppColors.Surface,
shadowElevation = Constants.Elevation.ExtraLarge.dp
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Box(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
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 = AppColors.Primary.copy(alpha = 0.2f),
shape = RoundedCornerShape(Constants.Radius.Medium.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.History,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(24.dp)
)
}
Text(
"Riwayat Chat",
style = MaterialTheme.typography.headlineSmall,
color = AppColors.OnBackground,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"${chatHistories.size} percakapan tersimpan",
style = MaterialTheme.typography.bodySmall,
color = 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 = AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
Spacer(modifier = Modifier.height(8.dp))
Box {
Card(
onClick = { showCategoryDropdown = !showCategoryDropdown },
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = 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 = AppColors.Primary,
modifier = Modifier.size(18.dp)
)
Text(
selectedCategory?.name ?: "Semua Kategori",
color = AppColors.OnSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = AppColors.OnSurfaceVariant
)
}
}
DropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false },
modifier = Modifier
.width(280.dp)
.background(AppColors.SurfaceElevated)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = AppColors.OnSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(
"Semua Kategori",
color = AppColors.OnSurface
)
}
},
onClick = {
onCategorySelected(null)
showCategoryDropdown = false
onNewChat()
},
leadingIcon = {
if (selectedCategory == null) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
}
)
if (categoriesWithNotes.isNotEmpty()) {
HorizontalDivider(color = 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 = AppColors.OnSurface
)
}
Surface(
color = 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 = 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 = AppColors.Primary,
modifier = Modifier.size(20.dp)
)
}
}
)
}
}
}
}
Spacer(modifier = Modifier.height(Constants.Spacing.Medium.dp))
HorizontalDivider(
color = 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 = AppColors.OnSurfaceVariant
)
Text(
"Belum ada riwayat chat",
style = MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurfaceVariant,
fontWeight = FontWeight.Medium
)
Text(
"Mulai chat dengan AI",
style = MaterialTheme.typography.bodySmall,
color = 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 = AppColors.Primary,
modifier = Modifier.size(16.dp)
)
Text(
categoryInfo.second,
style = MaterialTheme.typography.labelLarge,
color = AppColors.OnSurfaceVariant,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = 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 = 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 = 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 = AppColors.Primary,
modifier = Modifier.size(12.dp)
)
Text(
"${history.messages.size}",
color = 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 = AppColors.Error,
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Preview
Text(
history.lastMessagePreview,
style = MaterialTheme.typography.bodyMedium,
color = 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 = AppColors.OnSurfaceTertiary,
fontSize = 12.sp
)
}
}
// Delete Confirmation Dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
icon = {
Icon(
Icons.Default.DeleteForever,
contentDescription = null,
tint = 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 = AppColors.Error
)
) {
Text("Hapus")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Batal")
}
},
containerColor = AppColors.SurfaceElevated
)
}
}

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

@ -1,50 +1,46 @@
package com.example.notesai.presentation.screens.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor // ✅ ADD
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import com.example.notesai.data.model.Note
import com.example.notesai.presentation.screens.note.components.DraggableMiniMarkdownToolbar
import com.example.notesai.presentation.screens.note.editor.RichEditorState
import com.example.notesai.util.MarkdownParser
import com.example.notesai.util.MarkdownSerializer
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.*
import kotlin.math.roundToInt
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun EditableFullScreenNoteView(
note: Note,
@ -55,196 +51,245 @@ fun EditableFullScreenNoteView(
onPinToggle: () -> Unit
) {
var title by remember { mutableStateOf(note.title) }
var content by remember { mutableStateOf(note.content) }
var showArchiveDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID")) }
var isContentFocused by remember { mutableStateOf(false) }
// Dialog Konfirmasi Arsip
if (showArchiveDialog) {
AlertDialog(
onDismissRequest = { showArchiveDialog = false },
title = {
Text(
text = "Arsipkan Catatan?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(
onClick = {
if (title.isNotBlank()) {
onSave(title, content)
}
onArchive()
showArchiveDialog = false
}
) {
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
}
},
dismissButton = {
TextButton(onClick = { showArchiveDialog = false }) {
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
}
}
val editorState = remember(note.id) {
RichEditorState(
AnnotatedStringSerializer.fromJson(note.content)
)
}
// Dialog Konfirmasi Hapus
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = {
Text(
text = "Hapus Catatan?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "Catatan ini akan dihapus permanen dan tidak bisa dikembalikan.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
}
) {
Text("Hapus", color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
}
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
fun ensureFocus() {
focusRequester.requestFocus()
keyboard?.show()
}
fun saveNote() {
if (title.isNotBlank()) {
onSave(
title,
AnnotatedStringSerializer.toJson(editorState.value.annotatedString)
)
}
}
// 🔥 AUTO SAVE SAAT APP BACKGROUND / KELUAR
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
saveNote()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val dateFormat = remember {
SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID"))
}
val density = LocalDensity.current
val config = LocalConfiguration.current
val screenWidthPx = with(density) { config.screenWidthDp.dp.toPx() }
val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() }
val marginPx = with(density) { 16.dp.toPx() }
val imeBottomPx = with(density) {
WindowInsets.ime.getBottom(this).toFloat()
}
var toolbarSizePx by remember {
mutableStateOf(androidx.compose.ui.geometry.Size.Zero)
}
var toolbarOffset by remember {
mutableStateOf(Offset(marginPx, screenHeightPx * 0.6f))
}
fun moveToolbar(dx: Float, dy: Float) {
toolbarOffset = toolbarOffset.copy(
x = toolbarOffset.x + dx,
y = toolbarOffset.y + dy
)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = {
saveNote()
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {},
actions = {
IconButton(onClick = {
saveNote()
onPinToggle()
}) {
Icon(
if (note.isPinned) Icons.Filled.Star
else Icons.Outlined.StarBorder,
null
)
}
IconButton(onClick = onArchive) {
Icon(Icons.Default.Archive, null)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, null)
}
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali", tint = MaterialTheme.colorScheme.onBackground)
}
},
actions = {
IconButton(onClick = {
if (title.isNotBlank()) {
onSave(title, content)
)
},
contentWindowInsets = WindowInsets(0)
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imeNestedScroll()
.verticalScroll(scrollState)
.padding(horizontal = 20.dp)
.padding(
bottom = WindowInsets.ime
.asPaddingValues()
.calculateBottomPadding()
)
) {
TextField(
value = title,
onValueChange = { title = it },
textStyle = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
),
placeholder = { Text("Judul") },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
Spacer(Modifier.height(12.dp))
Text(
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
)
HorizontalDivider(Modifier.padding(vertical = 20.dp))
// ✅ FIX UTAMA: set cursorBrush agar insertion cursor muncul
BasicTextField(
value = editorState.value,
onValueChange = {
editorState.onValueChange(it)
scope.launch {
bringIntoViewRequester.bringIntoView()
}
onPinToggle()
}) {
Icon(
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = "Pin Catatan",
tint = if (note.isPinned) Color(0xFFFBBF24) else MaterialTheme.colorScheme.onSurface
},
cursorBrush = SolidColor(Color(0xFFA855F7)), // ✅ ADD THIS
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onBackground,
lineHeight = 28.sp
),
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 400.dp)
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged {
isContentFocused = it.isFocused
if (it.isFocused) {
scope.launch { bringIntoViewRequester.bringIntoView() }
}
},
decorationBox = { innerTextField ->
Box {
if (editorState.value.text.isEmpty()) {
Text(
"Mulai menulis...",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f)
)
}
innerTextField()
}
}
)
Spacer(Modifier.height(180.dp))
}
}
if (isContentFocused) {
DraggableMiniMarkdownToolbar(
modifier = Modifier
.align(Alignment.TopStart)
.offset {
val maxX =
(screenWidthPx - toolbarSizePx.width - marginPx)
.coerceAtLeast(marginPx)
val maxY =
(screenHeightPx - imeBottomPx - toolbarSizePx.height)
.coerceAtLeast(marginPx)
IntOffset(
toolbarOffset.x.coerceIn(marginPx, maxX).roundToInt(),
toolbarOffset.y.coerceIn(marginPx, maxY).roundToInt()
)
}
IconButton(onClick = { showArchiveDialog = true }) {
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
}
.onSizeChanged {
toolbarSizePx = androidx.compose.ui.geometry.Size(
it.width.toFloat(),
it.height.toFloat()
)
},
isBoldActive = editorState.isBoldActive(),
isItalicActive = editorState.isItalicActive(),
isUnderlineActive = editorState.isUnderlineActive(),
onDrag = ::moveToolbar,
onBold = {
ensureFocus()
editorState.toggleBold()
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
) {
TextField(
value = title,
onValueChange = { title = it },
textStyle = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
),
placeholder = {
Text(
"Judul",
style = MaterialTheme.typography.headlineLarge,
color = Color(0xFF64748B)
)
onItalic = {
ensureFocus()
editorState.toggleItalic()
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFFA855F7)
),
modifier = Modifier.fillMaxWidth()
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
onBullet = { ensureFocus(); editorState.toggleBulletList() }
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF64748B)
)
Divider(
modifier = Modifier.padding(vertical = 20.dp),
color = MaterialTheme.colorScheme.surface
)
TextField(
value = content,
onValueChange = { content = it },
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 28.sp
),
placeholder = {
Text(
"Mulai menulis...",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF64748B)
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFFA855F7)
),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 400.dp)
)
Spacer(modifier = Modifier.height(100.dp))
}
}
}
}

View File

@ -0,0 +1,131 @@
package com.example.notesai.presentation.screens.note.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
@Composable
fun DraggableMiniMarkdownToolbar(
modifier: Modifier = Modifier,
onDrag: (dx: Float, dy: Float) -> Unit,
// STATE
isBoldActive: Boolean,
isItalicActive: Boolean,
isUnderlineActive: Boolean,
// ACTIONS
onBold: () -> Unit,
onItalic: () -> Unit,
onHeading: () -> Unit,
onUnderline: () -> Unit,
onBullet: () -> Unit
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 🔹 DRAG HANDLE
Box(
modifier = Modifier
.size(36.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
}
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.DragIndicator,
contentDescription = "Drag",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
modifier = Modifier.size(18.dp)
)
}
ToolbarIcon(
icon = Icons.Default.FormatBold,
isActive = isBoldActive,
onClick = onBold
)
ToolbarIcon(
icon = Icons.Default.FormatItalic,
isActive = isItalicActive,
onClick = onItalic
)
ToolbarIcon(
icon = Icons.Default.FormatUnderlined,
isActive = isUnderlineActive,
onClick = onUnderline
)
ToolbarIcon(
icon = Icons.Default.Title,
onClick = onHeading
)
ToolbarIcon(
icon = Icons.Default.FormatListBulleted,
onClick = onBullet
)
}
}
}
@Composable
private fun ToolbarIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
isActive: Boolean = false
) {
val activeBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
val activeColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(36.dp)
.background(
color = if (isActive) activeBg else androidx.compose.ui.graphics.Color.Transparent,
shape = RoundedCornerShape(10.dp)
),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onClick,
modifier = Modifier.size(36.dp)
) {
Icon(
icon,
contentDescription = null,
tint = if (isActive) activeColor else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(18.dp)
)
}
}
}

View File

@ -0,0 +1,434 @@
package com.example.notesai.presentation.screens.note.editor
import androidx.compose.runtime.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
@Stable
class RichEditorState(initial: AnnotatedString) {
var value by mutableStateOf(
TextFieldValue(
annotatedString = initial,
selection = TextRange(initial.length)
)
)
/* =====================
UNDO / REDO
===================== */
private val undoStack = mutableStateListOf<TextFieldValue>()
private val redoStack = mutableStateListOf<TextFieldValue>()
private fun snapshot() {
undoStack.add(value)
redoStack.clear()
}
fun canUndo() = undoStack.isNotEmpty()
fun canRedo() = redoStack.isNotEmpty()
fun undo() {
if (!canUndo()) return
redoStack.add(value)
value = undoStack.removeLast()
}
fun redo() {
if (!canRedo()) return
undoStack.add(value)
value = redoStack.removeLast()
}
/* =====================
STICKY TYPING STYLE
===================== */
private val activeStyles = mutableStateListOf<SpanStyle>()
/* =====================
VALUE CHANGE (KEY)
===================== */
fun onValueChange(newValue: TextFieldValue) {
val old = value
// cursor/selection change only
if (newValue.text == old.text) {
value = old.copy(
selection = newValue.selection,
composition = newValue.composition
)
return
}
snapshot()
// 1) build new annotated string by preserving old spans
val built = buildPreservingSpans(old, newValue)
// 2) auto-convert markdown patterns around cursor
val converted = autoConvertMarkdown(built)
value = converted
}
private fun buildPreservingSpans(old: TextFieldValue, newValue: TextFieldValue): TextFieldValue {
val builder = AnnotatedString.Builder(newValue.text)
// copy old spans (clamped)
old.annotatedString.spanStyles.forEach { r ->
val s = r.start.coerceIn(0, newValue.text.length)
val e = r.end.coerceIn(0, newValue.text.length)
if (s < e) builder.addStyle(r.item, s, e)
}
// apply sticky styles to newly inserted char (simple heuristic)
val insertPos = newValue.selection.start - 1
if (insertPos >= 0 && insertPos < newValue.text.length) {
activeStyles.forEach { st ->
builder.addStyle(st, insertPos, insertPos + 1)
}
}
return TextFieldValue(
annotatedString = builder.toAnnotatedString(),
selection = newValue.selection,
composition = newValue.composition
)
}
/* =====================
TOOLBAR TOGGLES
===================== */
fun toggleBold() = toggleStyle(SpanStyle(fontWeight = FontWeight.Bold))
fun toggleItalic() = toggleStyle(SpanStyle(fontStyle = FontStyle.Italic))
fun toggleUnderline() = toggleStyle(SpanStyle(textDecoration = TextDecoration.Underline))
private fun toggleStyle(style: SpanStyle) {
val sel = value.selection.normalized()
snapshot()
if (!sel.collapsed) applyStyleToSelection(style)
else toggleTypingStyle(style)
}
private fun toggleTypingStyle(style: SpanStyle) {
val idx = activeStyles.indexOfFirst { it.hasSameStyle(style) }
if (idx >= 0) activeStyles.removeAt(idx) else activeStyles.add(style)
}
private fun applyStyleToSelection(style: SpanStyle) {
val sel = value.selection.normalized()
val start = sel.start
val end = sel.end
if (start >= end) return
val builder = AnnotatedString.Builder(value.text)
value.annotatedString.spanStyles.forEach { r ->
val overlap = r.start < end && r.end > start
val same = r.item.hasSameStyle(style)
if (!(overlap && same)) builder.addStyle(r.item, r.start, r.end)
}
builder.addStyle(style, start, end)
value = value.copy(
annotatedString = builder.toAnnotatedString(),
selection = TextRange(end)
)
}
/* =====================
HEADING / BULLET (for toolbar)
===================== */
fun setHeading(level: Int) {
snapshot()
val sel = value.selection
val text = value.text
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', sel.start).let { if (it == -1) text.length else it }
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
3 -> 18.sp
else -> return
}
val builder = AnnotatedString.Builder(text)
value.annotatedString.spanStyles.forEach { r -> builder.addStyle(r.item, r.start, r.end) }
builder.addStyle(SpanStyle(fontSize = size, fontWeight = FontWeight.Bold), lineStart, lineEnd)
value = value.copy(annotatedString = builder.toAnnotatedString())
}
fun toggleBulletList() {
snapshot()
val sel = value.selection
val text = value.text
val prefix = ""
val lineStart = text.lastIndexOf('\n', (sel.start - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val isBullet = text.startsWith(prefix, startIndex = lineStart)
if (isBullet) {
replaceTextPreserveSpans(
start = lineStart,
end = lineStart + prefix.length,
replacement = "",
newCursor = (sel.start - prefix.length).coerceAtLeast(lineStart)
)
} else {
replaceTextPreserveSpans(
start = lineStart,
end = lineStart,
replacement = prefix,
newCursor = sel.start + prefix.length
)
}
}
/* =====================
AUTO-CONVERT MARKDOWN (LEVEL 3)
===================== */
private fun autoConvertMarkdown(v: TextFieldValue): TextFieldValue {
var cur = v
// order matters: bold before italic
cur = convertBold(cur)
cur = convertItalic(cur)
cur = convertHeading(cur)
cur = convertDashBullet(cur)
return cur
}
// **word** -> bold(word), remove ** **
private fun convertBold(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 2) return v
if (!(text.getOrNull(cursor - 1) == '*' && text.getOrNull(cursor - 2) == '*')) return v
val startMarker = text.lastIndexOf("**", startIndex = cursor - 3)
if (startMarker == -1) return v
val contentStart = startMarker + 2
val contentEnd = cursor - 2
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
// remove end marker then start marker (preserve spans)
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 2, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 2, "")
// after removing start marker, content shifts -2
val newStart = startMarker
val newEnd = contentEnd - 2
out = addStylePreserve(out, SpanStyle(fontWeight = FontWeight.Bold), newStart, newEnd)
// cursor shifts back 4 chars total
out = out.copy(selection = TextRange((cursor - 4).coerceAtLeast(newEnd)))
return out
}
// *word* -> italic(word), remove * *
private fun convertItalic(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
if (cursor < 1) return v
// avoid triggering on bold closing (**)
if (text.getOrNull(cursor - 1) != '*') return v
if (text.getOrNull(cursor - 2) == '*') return v
val startMarker = text.lastIndexOf('*', startIndex = cursor - 2)
if (startMarker == -1) return v
// avoid ** as start
if (text.getOrNull(startMarker - 1) == '*') return v
val contentStart = startMarker + 1
val contentEnd = cursor - 1
if (contentEnd <= contentStart) return v
if (text.substring(contentStart, contentEnd).contains('\n')) return v
var out = v
out = replaceTextPreserveSpansLocal(out, cursor - 1, cursor, "")
out = replaceTextPreserveSpansLocal(out, startMarker, startMarker + 1, "")
val newStart = startMarker
val newEnd = contentEnd - 1
out = addStylePreserve(out, SpanStyle(fontStyle = FontStyle.Italic), newStart, newEnd)
out = out.copy(selection = TextRange((cursor - 2).coerceAtLeast(newEnd)))
return out
}
// "# " / "## " / "### " at line start -> heading + remove markers
private fun convertHeading(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val lineEnd = text.indexOf('\n', cursor).let { if (it == -1) text.length else it }
val linePrefix = text.substring(lineStart, minOf(lineStart + 4, text.length))
val level = when {
linePrefix.startsWith("### ") -> 3
linePrefix.startsWith("## ") -> 2
linePrefix.startsWith("# ") -> 1
else -> return v
}
// only trigger when user just typed the space after #'s OR when cursor is still on same line early
val removeLen = when (level) {
1 -> 2
2 -> 3
else -> 4
}
val triggerPos = lineStart + removeLen
if (cursor < triggerPos) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + removeLen, "")
// apply heading style to the line content
val newLineStart = lineStart
val newLineEnd = (lineEnd - removeLen).coerceAtLeast(newLineStart)
val size = when (level) {
1 -> 28.sp
2 -> 22.sp
else -> 18.sp
}
out = addStylePreserve(
out,
SpanStyle(fontSize = size, fontWeight = FontWeight.Bold),
newLineStart,
newLineEnd
)
// shift cursor back by removedLen
out = out.copy(selection = TextRange((cursor - removeLen).coerceAtLeast(newLineStart)))
return out
}
// "- " at line start -> "• "
private fun convertDashBullet(v: TextFieldValue): TextFieldValue {
val text = v.text
val cursor = v.selection.start
val lineStart = text.lastIndexOf('\n', (cursor - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val prefix = "- "
if (!text.startsWith(prefix, startIndex = lineStart)) return v
// only trigger when user already typed "- "
if (cursor < lineStart + 2) return v
var out = v
out = replaceTextPreserveSpansLocal(out, lineStart, lineStart + 2, "")
// cursor stays same length (2)
return out
}
/* =====================
TOOLBAR STATE
===================== */
fun isBoldActive() = isStyleActive(fontWeight = FontWeight.Bold)
fun isItalicActive() = isStyleActive(fontStyle = FontStyle.Italic)
fun isUnderlineActive() = isStyleActive(decoration = TextDecoration.Underline)
private fun isStyleActive(
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
decoration: TextDecoration? = null
): Boolean {
val sel = value.selection
if (!sel.collapsed) {
return value.annotatedString.spanStyles.any {
it.start <= sel.start &&
it.end >= sel.end &&
(fontWeight == null || it.item.fontWeight == fontWeight) &&
(fontStyle == null || it.item.fontStyle == fontStyle) &&
(decoration == null || it.item.textDecoration == decoration)
}
}
return activeStyles.any {
(fontWeight == null || it.fontWeight == fontWeight) &&
(fontStyle == null || it.fontStyle == fontStyle) &&
(decoration == null || it.textDecoration == decoration)
}
}
/* =====================
INTERNAL: text replace while preserving spans
===================== */
private fun replaceTextPreserveSpans(start: Int, end: Int, replacement: String, newCursor: Int) {
value = replaceTextPreserveSpansLocal(value, start, end, replacement)
.copy(selection = TextRange(newCursor))
}
private fun replaceTextPreserveSpansLocal(
v: TextFieldValue,
start: Int,
end: Int,
replacement: String
): TextFieldValue {
val oldText = v.text
val s = start.coerceIn(0, oldText.length)
val e = end.coerceIn(0, oldText.length)
if (s > e) return v
val newText = oldText.substring(0, s) + replacement + oldText.substring(e)
val delta = replacement.length - (e - s)
val b = AnnotatedString.Builder(newText)
v.annotatedString.spanStyles.forEach { r ->
var rs = r.start
var re = r.end
// adjust spans
when {
re <= s -> Unit
rs >= e -> { rs += delta; re += delta }
rs < s && re > e -> re += delta
rs < s && re in (s + 1)..e -> re = s
rs in s until e && re > e -> { rs = s + replacement.length; re += delta }
else -> return@forEach
}
rs = rs.coerceIn(0, newText.length)
re = re.coerceIn(0, newText.length)
if (rs < re) b.addStyle(r.item, rs, re)
}
return v.copy(annotatedString = b.toAnnotatedString())
}
private fun addStylePreserve(v: TextFieldValue, style: SpanStyle, start: Int, end: Int): TextFieldValue {
val s = start.coerceIn(0, v.text.length)
val e = end.coerceIn(0, v.text.length)
if (s >= e) return v
val b = AnnotatedString.Builder(v.text)
v.annotatedString.spanStyles.forEach { r -> b.addStyle(r.item, r.start, r.end) }
b.addStyle(style, s, e)
return v.copy(annotatedString = b.toAnnotatedString())
}
}
/* =====================
HELPERS
===================== */
private fun SpanStyle.hasSameStyle(other: SpanStyle): Boolean =
fontWeight == other.fontWeight &&
fontStyle == other.fontStyle &&
textDecoration == other.textDecoration
private fun TextRange.normalized(): TextRange =
if (start <= end) this else TextRange(end, start)

View File

@ -0,0 +1,66 @@
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@Serializable
data class SpanDto(
val start: Int,
val end: Int,
val bold: Boolean = false,
val italic: Boolean = false,
val underline: Boolean = false
)
@Serializable
data class RichTextDto(
val text: String,
val spans: List<SpanDto>
)
object AnnotatedStringSerializer {
fun toJson(value: AnnotatedString): String {
val spans = value.spanStyles.map {
SpanDto(
start = it.start,
end = it.end,
bold = it.item.fontWeight != null,
italic = it.item.fontStyle != null,
underline = it.item.textDecoration != null
)
}
return Json.encodeToString(
RichTextDto(
text = value.text,
spans = spans
)
)
}
fun fromJson(json: String): AnnotatedString {
return try {
val dto = Json.decodeFromString<RichTextDto>(json)
val builder = AnnotatedString.Builder(dto.text)
dto.spans.forEach {
builder.addStyle(
SpanStyle(
fontWeight = if (it.bold) androidx.compose.ui.text.font.FontWeight.Bold else null,
fontStyle = if (it.italic) androidx.compose.ui.text.font.FontStyle.Italic else null,
textDecoration = if (it.underline) androidx.compose.ui.text.style.TextDecoration.Underline else null
),
it.start,
it.end
)
}
builder.toAnnotatedString()
} catch (e: Exception) {
AnnotatedString(json) // fallback plain
}
}
}

View File

@ -0,0 +1,58 @@
package com.example.notesai.util
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
object MarkdownParser {
fun parse(markdown: String): AnnotatedString {
val builder = AnnotatedString.Builder()
var i = 0
while (i < markdown.length) {
when {
markdown.startsWith("**", i) -> {
val end = markdown.indexOf("**", i + 2)
if (end != -1) {
val content = markdown.substring(i + 2, end)
val start = builder.length
builder.append(content)
builder.addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start,
start + content.length
)
i = end + 2
} else {
builder.append(markdown[i++])
}
}
markdown.startsWith("*", i) -> {
val end = markdown.indexOf("*", i + 1)
if (end != -1) {
val content = markdown.substring(i + 1, end)
val start = builder.length
builder.append(content)
builder.addStyle(
SpanStyle(fontStyle = FontStyle.Italic),
start,
start + content.length
)
i = end + 1
} else {
builder.append(markdown[i++])
}
}
else -> {
builder.append(markdown[i++])
}
}
}
return builder.toAnnotatedString()
}
}

View File

@ -0,0 +1,35 @@
package com.example.notesai.util
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
object MarkdownSerializer {
fun toMarkdown(text: AnnotatedString): String {
val raw = text.text
if (text.spanStyles.isEmpty()) return raw
val markers = Array(raw.length + 1) { mutableListOf<String>() }
text.spanStyles.forEach { span ->
if (span.item.fontWeight == FontWeight.Bold) {
markers[span.start].add("**")
markers[span.end].add("**")
}
if (span.item.fontStyle == FontStyle.Italic) {
markers[span.start].add("*")
markers[span.end].add("*")
}
}
val sb = StringBuilder()
for (i in raw.indices) {
markers[i].forEach { sb.append(it) }
sb.append(raw[i])
}
markers[raw.length].forEach { sb.append(it) }
return sb.toString()
}
}

View File

@ -16,6 +16,8 @@ firebaseAnnotations = "17.0.0"
firebaseFirestoreKtx = "26.0.2"
uiGraphics = "1.10.0"
roomCompiler = "2.8.4"
glance = "1.1.1"
animation = "1.10.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -33,6 +35,8 @@ firebase-annotations = { group = "com.google.firebase", name = "firebase-annotat
firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebaseFirestoreKtx" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "glance" }
androidx-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }