Compare commits
No commits in common. "f4847ced63199721a50268f22659370b3f62fa1b" and "75033bc671a5fe1cc0aeb5b54305c69927d0b913" have entirely different histories.
f4847ced63
...
75033bc671
54
Readme.md
54
Readme.md
@ -146,54 +146,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **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 (H1–H3) 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**
|
## **Fitur Utama Aplikasi**
|
||||||
|
|
||||||
* Sistem kategori dengan gradient
|
* Sistem kategori dengan gradient
|
||||||
@ -225,8 +177,8 @@
|
|||||||
|
|
||||||
## **Features for Sprint 4 v1.1.0**
|
## **Features for Sprint 4 v1.1.0**
|
||||||
|
|
||||||
* Penyesuaian UI/UX History Chat AI (ok)
|
* Penyesuaian UI/UX History Chat AI
|
||||||
* Rich text editor (ok - Pengembangan Lanjutan)
|
* Rich text editor
|
||||||
* AI Agent Catatan
|
* AI Agent Catatan
|
||||||
* Fungsi AI (Upload File)
|
* Fungsi AI (Upload File)
|
||||||
* Fitur Sematkan Category, otomatis paling atas
|
* Fitur Sematkan Category, otomatis paling atas
|
||||||
|
|||||||
@ -67,9 +67,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.text)
|
implementation(libs.androidx.ui.text)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.animation.core)
|
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)
|
// Untuk integrasi Gemini AI (optional - uncomment jika sudah ada API key)
|
||||||
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
|
// implementation("com.google.ai.client.generativeai:generativeai:0.1.2")
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@
|
|||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.NotesAI">
|
android:theme="@style/Theme.NotesAI">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@ -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") // "dark" or "light"
|
val THEME_KEY = stringPreferencesKey("theme") // NEW: "dark" or "light"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
@ -116,7 +116,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat History Flow
|
// NEW: 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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Chat History
|
// NEW: 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,21 +191,13 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new chat history
|
// NEW: 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()
|
||||||
|
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)
|
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -213,7 +205,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete chat history (soft delete)
|
// NEW: 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 ->
|
||||||
@ -229,27 +221,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Update chat history title
|
// NEW: Theme Preference Flow
|
||||||
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) {
|
||||||
@ -262,7 +234,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
preferences[THEME_KEY] ?: "dark" // Default dark theme
|
preferences[THEME_KEY] ?: "dark" // Default dark theme
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Theme Preference
|
// NEW: Save Theme Preference
|
||||||
suspend fun saveTheme(theme: String) {
|
suspend fun saveTheme(theme: String) {
|
||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
|
|||||||
@ -10,7 +10,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,538 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,16 +38,6 @@ 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(
|
||||||
@ -102,7 +92,9 @@ fun AIHelperScreen(
|
|||||||
if (chatMessages.isNotEmpty()) {
|
if (chatMessages.isNotEmpty()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
|
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
|
||||||
val preview = lastMessage.toSafeChatPreview()
|
val preview = if (lastMessage.length > 100) {
|
||||||
|
lastMessage.take(100) + "..."
|
||||||
|
} else lastMessage
|
||||||
|
|
||||||
val chatHistory = ChatHistory(
|
val chatHistory = ChatHistory(
|
||||||
id = currentChatId ?: UUID.randomUUID().toString(),
|
id = currentChatId ?: UUID.randomUUID().toString(),
|
||||||
@ -110,7 +102,6 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -564,12 +555,7 @@ fun AIHelperScreen(
|
|||||||
onCategorySelected = { category ->
|
onCategorySelected = { category ->
|
||||||
selectedCategory = category
|
selectedCategory = category
|
||||||
},
|
},
|
||||||
onNewChat = { startNewChat() },
|
onNewChat = { startNewChat() }
|
||||||
onEditHistoryTitle = { historyId, newTitle ->
|
|
||||||
scope.launch {
|
|
||||||
dataStoreManager.updateChatHistoryTitle(historyId, newTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,46 +1,50 @@
|
|||||||
package com.example.notesai.presentation.screens.note
|
package com.example.notesai.presentation.screens.note
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.*
|
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.outlined.StarBorder
|
import androidx.compose.material.icons.outlined.StarBorder
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.ui.Alignment
|
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.ui.Modifier
|
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.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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.notesai.data.model.Note
|
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.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
import java.util.Locale
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditableFullScreenNoteView(
|
fun EditableFullScreenNoteView(
|
||||||
note: Note,
|
note: Note,
|
||||||
@ -51,245 +55,196 @@ fun EditableFullScreenNoteView(
|
|||||||
onPinToggle: () -> Unit
|
onPinToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note.title) }
|
var title by remember { mutableStateOf(note.title) }
|
||||||
var isContentFocused by remember { mutableStateOf(false) }
|
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")) }
|
||||||
|
|
||||||
val editorState = remember(note.id) {
|
// Dialog Konfirmasi Arsip
|
||||||
RichEditorState(
|
if (showArchiveDialog) {
|
||||||
AnnotatedStringSerializer.fromJson(note.content)
|
AlertDialog(
|
||||||
)
|
onDismissRequest = { showArchiveDialog = false },
|
||||||
}
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Arsipkan Catatan?",
|
||||||
val focusRequester = remember { FocusRequester() }
|
style = MaterialTheme.typography.titleLarge,
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
fontWeight = FontWeight.Bold
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentWindowInsets = WindowInsets(0)
|
text = {
|
||||||
) { 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(
|
Text(
|
||||||
"Terakhir diubah: ${dateFormat.format(Date(note.timestamp))}",
|
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
|
|
||||||
)
|
)
|
||||||
|
},
|
||||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
// ✅ FIX UTAMA: set cursorBrush agar insertion cursor muncul
|
onClick = {
|
||||||
BasicTextField(
|
if (title.isNotBlank()) {
|
||||||
value = editorState.value,
|
onSave(title, content)
|
||||||
onValueChange = {
|
|
||||||
editorState.onValueChange(it)
|
|
||||||
scope.launch {
|
|
||||||
bringIntoViewRequester.bringIntoView()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
onArchive()
|
||||||
|
showArchiveDialog = false
|
||||||
}
|
}
|
||||||
)
|
) {
|
||||||
|
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.height(180.dp))
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showArchiveDialog = false }) {
|
||||||
|
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isContentFocused) {
|
// Dialog Konfirmasi Hapus
|
||||||
DraggableMiniMarkdownToolbar(
|
if (showDeleteDialog) {
|
||||||
modifier = Modifier
|
AlertDialog(
|
||||||
.align(Alignment.TopStart)
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
.offset {
|
title = {
|
||||||
val maxX =
|
Text(
|
||||||
(screenWidthPx - toolbarSizePx.width - marginPx)
|
text = "Hapus Catatan?",
|
||||||
.coerceAtLeast(marginPx)
|
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 maxY =
|
Scaffold(
|
||||||
(screenHeightPx - imeBottomPx - toolbarSizePx.height)
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
.coerceAtLeast(marginPx)
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
IntOffset(
|
title = { },
|
||||||
toolbarOffset.x.coerceIn(marginPx, maxX).roundToInt(),
|
navigationIcon = {
|
||||||
toolbarOffset.y.coerceIn(marginPx, maxY).roundToInt()
|
IconButton(onClick = {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
onSave(title, content)
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Kembali", tint = MaterialTheme.colorScheme.onBackground)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
onSave(title, content)
|
||||||
|
}
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onSizeChanged {
|
IconButton(onClick = { showArchiveDialog = true }) {
|
||||||
toolbarSizePx = androidx.compose.ui.geometry.Size(
|
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
it.width.toFloat(),
|
}
|
||||||
it.height.toFloat()
|
IconButton(onClick = { showDeleteDialog = true }) {
|
||||||
)
|
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
},
|
}
|
||||||
|
|
||||||
isBoldActive = editorState.isBoldActive(),
|
|
||||||
isItalicActive = editorState.isItalicActive(),
|
|
||||||
isUnderlineActive = editorState.isUnderlineActive(),
|
|
||||||
|
|
||||||
onDrag = ::moveToolbar,
|
|
||||||
onBold = {
|
|
||||||
ensureFocus()
|
|
||||||
editorState.toggleBold()
|
|
||||||
},
|
},
|
||||||
onItalic = {
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
ensureFocus()
|
containerColor = Color.Transparent
|
||||||
editorState.toggleItalic()
|
)
|
||||||
},
|
|
||||||
onUnderline = { ensureFocus(); editorState.toggleUnderline() },
|
|
||||||
onHeading = { ensureFocus(); editorState.setHeading(1) }, // sementara H1
|
|
||||||
onBullet = { ensureFocus(); editorState.toggleBulletList() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) { 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)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = Color(0xFFA855F7)
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,131 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,434 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,8 +16,6 @@ firebaseAnnotations = "17.0.0"
|
|||||||
firebaseFirestoreKtx = "26.0.2"
|
firebaseFirestoreKtx = "26.0.2"
|
||||||
uiGraphics = "1.10.0"
|
uiGraphics = "1.10.0"
|
||||||
roomCompiler = "2.8.4"
|
roomCompiler = "2.8.4"
|
||||||
glance = "1.1.1"
|
|
||||||
animation = "1.10.0"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -35,8 +33,6 @@ firebase-annotations = { group = "com.google.firebase", name = "firebase-annotat
|
|||||||
firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebaseFirestoreKtx" }
|
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-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-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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user