From c0bbd3e54f43b3c07c3dc45416fa8d89419855fc Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Fri, 19 Dec 2025 00:29:13 +0700 Subject: [PATCH] Fitur Markdown (RichEditorState) pada catatan dan penyeesuaian Selection Handle --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 1 + .../note/EditableFullScreenNoteView.kt | 460 ++++++++++-------- .../DraggableMiniMarkdownToolbar.kt | 127 +++++ .../screens/note/editor/RichEditorState.kt | 163 +++++++ .../example/notesai/util/MarkdownParser.kt | 58 +++ .../notesai/util/MarkdownSerializer.kt | 35 ++ gradle/libs.versions.toml | 6 +- 8 files changed, 639 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt create mode 100644 app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt create mode 100644 app/src/main/java/com/example/notesai/util/MarkdownParser.kt create mode 100644 app/src/main/java/com/example/notesai/util/MarkdownSerializer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a27080..484bc63 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { 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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03bd995..aa1157a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ tools:targetApi="31"> diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt index 7d13486..1b65754 100644 --- a/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/EditableFullScreenNoteView.kt @@ -1,50 +1,45 @@ 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.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 +50,239 @@ 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(MarkdownParser.parse(note.content)) + } + + 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, + MarkdownSerializer.toMarkdown(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 ) } - // 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) - } - } - ) - } + Box(modifier = Modifier.fillMaxSize()) { - Scaffold( - containerColor = MaterialTheme.colorScheme.background, - topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = { - if (title.isNotBlank()) { - onSave(title, content) + 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(), + 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() + onHeading = {}, + onBullet = {}, + onCodeBlock = {} ) - - 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)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt new file mode 100644 index 0000000..ad578e9 --- /dev/null +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/components/DraggableMiniMarkdownToolbar.kt @@ -0,0 +1,127 @@ +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, + + // ACTIONS + onBold: () -> Unit, + onItalic: () -> Unit, + onHeading: () -> Unit, + onBullet: () -> Unit, + onCodeBlock: () -> 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.Title, + onClick = onHeading + ) + + ToolbarIcon( + icon = Icons.Default.FormatListBulleted, + onClick = onBullet + ) + + ToolbarIcon( + icon = Icons.Default.Code, + onClick = onCodeBlock + ) + } + } +} + +@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) + ) + } + } +} diff --git a/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt b/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt new file mode 100644 index 0000000..278f412 --- /dev/null +++ b/app/src/main/java/com/example/notesai/presentation/screens/note/editor/RichEditorState.kt @@ -0,0 +1,163 @@ +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.input.TextFieldValue + +@Stable +class RichEditorState(initial: AnnotatedString) { + + var value by mutableStateOf( + TextFieldValue( + annotatedString = initial, + selection = TextRange(initial.length) + ) + ) + + // 🔑 ACTIVE TYPING STYLES (sticky) + private val activeStyles = mutableStateListOf() + + /* --------------------------- + VALUE CHANGE (typing) + --------------------------- */ + fun onValueChange(newValue: TextFieldValue) { + val old = value + + // Cursor / IME update only + if (newValue.text == old.text) { + value = old.copy( + selection = newValue.selection, + composition = newValue.composition + ) + return + } + + val builder = AnnotatedString.Builder(newValue.text) + + // 1️⃣ copy old spans + old.annotatedString.spanStyles.forEach { + val start = it.start.coerceAtMost(newValue.text.length) + val end = it.end.coerceAtMost(newValue.text.length) + if (start < end) builder.addStyle(it.item, start, end) + } + + // 2️⃣ apply active typing styles to NEW CHAR + val insertStart = newValue.selection.start - 1 + val insertEnd = newValue.selection.start + + if (insertStart >= 0 && insertEnd > insertStart) { + activeStyles.forEach { style -> + builder.addStyle(style, insertStart, insertEnd) + } + } + + value = TextFieldValue( + annotatedString = builder.toAnnotatedString(), + selection = newValue.selection, + composition = newValue.composition + ) + } + + /* --------------------------- + TOGGLE STYLES + --------------------------- */ + + fun toggleBold() = + toggleStyle(SpanStyle(fontWeight = FontWeight.Bold)) + + fun toggleItalic() = + toggleStyle(SpanStyle(fontStyle = FontStyle.Italic)) + + private fun toggleStyle(style: SpanStyle) { + val sel = value.selection.normalized() + val hasSelection = !sel.collapsed + + if (hasSelection) { + applyStyleToSelection(style) + } else { + toggleTypingStyle(style) + } + } + + /* --------------------------- + APPLY TO SELECTION + --------------------------- */ + 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 { + val overlap = it.start < end && it.end > start + val same = + it.item.fontWeight == style.fontWeight && + it.item.fontStyle == style.fontStyle + + if (!(overlap && same)) { + builder.addStyle(it.item, it.start, it.end) + } + } + + builder.addStyle(style, start, end) + + value = value.copy( + annotatedString = builder.toAnnotatedString(), + selection = TextRange(end) + ) + } + + /* --------------------------- + TYPING MODE (sticky) + --------------------------- */ + private fun toggleTypingStyle(style: SpanStyle) { + val index = activeStyles.indexOfFirst { + it.fontWeight == style.fontWeight && + it.fontStyle == style.fontStyle + } + + if (index >= 0) { + activeStyles.removeAt(index) + } else { + activeStyles.add(style) + } + } + + /* --------------------------- + TOOLBAR STATE + --------------------------- */ + + fun isBoldActive(): Boolean = + isStyleActive(FontWeight.Bold, null) + + fun isItalicActive(): Boolean = + isStyleActive(null, FontStyle.Italic) + + private fun isStyleActive( + weight: FontWeight?, + style: FontStyle? + ): Boolean { + val sel = value.selection + if (!sel.collapsed) { + return value.annotatedString.spanStyles.any { + it.start <= sel.start && + it.end >= sel.end && + (weight == null || it.item.fontWeight == weight) && + (style == null || it.item.fontStyle == style) + } + } + return activeStyles.any { + (weight == null || it.fontWeight == weight) && + (style == null || it.fontStyle == style) + } + } +} + +/* -------- helper -------- */ + +private fun TextRange.normalized(): TextRange = + if (start <= end) this else TextRange(end, start) diff --git a/app/src/main/java/com/example/notesai/util/MarkdownParser.kt b/app/src/main/java/com/example/notesai/util/MarkdownParser.kt new file mode 100644 index 0000000..e9901e0 --- /dev/null +++ b/app/src/main/java/com/example/notesai/util/MarkdownParser.kt @@ -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() + } +} diff --git a/app/src/main/java/com/example/notesai/util/MarkdownSerializer.kt b/app/src/main/java/com/example/notesai/util/MarkdownSerializer.kt new file mode 100644 index 0000000..25e38ca --- /dev/null +++ b/app/src/main/java/com/example/notesai/util/MarkdownSerializer.kt @@ -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() } + + 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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 328bdca..431b179 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,8 @@ firebaseAnnotations = "17.0.0" firebaseFirestoreKtx = "26.0.2" uiGraphics = "1.10.0" roomCompiler = "2.8.4" -foundationLayout = "1.10.0" +glance = "1.1.1" +animation = "1.10.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -34,7 +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-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +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" }