Fitur Markdown (RichEditorState) pada catatan dan penyeesuaian Selection Handle
This commit is contained in:
parent
b8d9a71664
commit
c0bbd3e54f
@ -68,6 +68,8 @@ dependencies {
|
|||||||
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.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,6 +16,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@ -1,50 +1,45 @@
|
|||||||
package com.example.notesai.presentation.screens.note
|
package com.example.notesai.presentation.screens.note
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
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.Archive
|
import androidx.compose.material.icons.filled.*
|
||||||
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.AlertDialog
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.ui.Alignment
|
||||||
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.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
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.Date
|
import java.util.*
|
||||||
import java.util.Locale
|
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
|
@Composable
|
||||||
fun EditableFullScreenNoteView(
|
fun EditableFullScreenNoteView(
|
||||||
note: Note,
|
note: Note,
|
||||||
@ -55,196 +50,239 @@ fun EditableFullScreenNoteView(
|
|||||||
onPinToggle: () -> Unit
|
onPinToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf(note.title) }
|
var title by remember { mutableStateOf(note.title) }
|
||||||
var content by remember { mutableStateOf(note.content) }
|
var isContentFocused by remember { mutableStateOf(false) }
|
||||||
var showArchiveDialog by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
val dateFormat = remember { SimpleDateFormat("dd MMMM yyyy, HH:mm", Locale("id", "ID")) }
|
|
||||||
|
|
||||||
// Dialog Konfirmasi Arsip
|
val editorState = remember(note.id) {
|
||||||
if (showArchiveDialog) {
|
RichEditorState(MarkdownParser.parse(note.content))
|
||||||
AlertDialog(
|
}
|
||||||
onDismissRequest = { showArchiveDialog = false },
|
|
||||||
title = {
|
val focusRequester = remember { FocusRequester() }
|
||||||
Text(
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
text = "Arsipkan Catatan?",
|
val scrollState = rememberScrollState()
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val scope = rememberCoroutineScope()
|
||||||
fontWeight = FontWeight.Bold
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
)
|
|
||||||
},
|
fun ensureFocus() {
|
||||||
text = {
|
focusRequester.requestFocus()
|
||||||
Text(
|
keyboard?.show()
|
||||||
text = "Catatan ini akan dipindahkan ke arsip. Anda masih bisa mengaksesnya nanti.",
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
fun saveNote() {
|
||||||
},
|
if (title.isNotBlank()) {
|
||||||
confirmButton = {
|
onSave(
|
||||||
TextButton(
|
title,
|
||||||
onClick = {
|
MarkdownSerializer.toMarkdown(editorState.value.annotatedString)
|
||||||
if (title.isNotBlank()) {
|
)
|
||||||
onSave(title, content)
|
}
|
||||||
}
|
}
|
||||||
onArchive()
|
|
||||||
showArchiveDialog = false
|
// 🔥 AUTO SAVE SAAT APP BACKGROUND / KELUAR
|
||||||
}
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
) {
|
|
||||||
Text("Arsipkan", color = MaterialTheme.colorScheme.primary)
|
DisposableEffect(lifecycleOwner) {
|
||||||
}
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
},
|
if (event == Lifecycle.Event.ON_STOP) {
|
||||||
dismissButton = {
|
saveNote()
|
||||||
TextButton(onClick = { showArchiveDialog = false }) {
|
|
||||||
Text("Batal", color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
topBar = {
|
||||||
topBar = {
|
TopAppBar(
|
||||||
TopAppBar(
|
navigationIcon = {
|
||||||
title = { },
|
IconButton(onClick = {
|
||||||
navigationIcon = {
|
saveNote()
|
||||||
IconButton(onClick = {
|
onBack()
|
||||||
if (title.isNotBlank()) {
|
}) {
|
||||||
onSave(title, content)
|
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 = {
|
contentWindowInsets = WindowInsets(0)
|
||||||
if (title.isNotBlank()) {
|
) { paddingValues ->
|
||||||
onSave(title, content)
|
|
||||||
|
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()
|
},
|
||||||
}) {
|
cursorBrush = SolidColor(Color(0xFFA855F7)), // ✅ ADD THIS
|
||||||
Icon(
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
if (note.isPinned) Icons.Filled.Star else Icons.Outlined.StarBorder,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
contentDescription = "Pin Catatan",
|
lineHeight = 28.sp
|
||||||
tint = if (note.isPinned) Color(0xFFFBBF24) else MaterialTheme.colorScheme.onSurface
|
),
|
||||||
|
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 }) {
|
.onSizeChanged {
|
||||||
Icon(Icons.Default.Archive, contentDescription = "Arsipkan", tint = MaterialTheme.colorScheme.onSurface)
|
toolbarSizePx = androidx.compose.ui.geometry.Size(
|
||||||
}
|
it.width.toFloat(),
|
||||||
IconButton(onClick = { showDeleteDialog = true }) {
|
it.height.toFloat()
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.onSurface)
|
)
|
||||||
}
|
},
|
||||||
|
isBoldActive = editorState.isBoldActive(),
|
||||||
|
isItalicActive = editorState.isItalicActive(),
|
||||||
|
onDrag = ::moveToolbar,
|
||||||
|
onBold = {
|
||||||
|
ensureFocus()
|
||||||
|
editorState.toggleBold()
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
onItalic = {
|
||||||
containerColor = Color.Transparent
|
ensureFocus()
|
||||||
)
|
editorState.toggleItalic()
|
||||||
)
|
|
||||||
}
|
|
||||||
) { 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(
|
onHeading = {},
|
||||||
focusedContainerColor = Color.Transparent,
|
onBullet = {},
|
||||||
unfocusedContainerColor = Color.Transparent,
|
onCodeBlock = {}
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<SpanStyle>()
|
||||||
|
|
||||||
|
/* ---------------------------
|
||||||
|
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)
|
||||||
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
58
app/src/main/java/com/example/notesai/util/MarkdownParser.kt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownParser {
|
||||||
|
|
||||||
|
fun parse(markdown: String): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < markdown.length) {
|
||||||
|
when {
|
||||||
|
markdown.startsWith("**", i) -> {
|
||||||
|
val end = markdown.indexOf("**", i + 2)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 2, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 2
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown.startsWith("*", i) -> {
|
||||||
|
val end = markdown.indexOf("*", i + 1)
|
||||||
|
if (end != -1) {
|
||||||
|
val content = markdown.substring(i + 1, end)
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(content)
|
||||||
|
builder.addStyle(
|
||||||
|
SpanStyle(fontStyle = FontStyle.Italic),
|
||||||
|
start,
|
||||||
|
start + content.length
|
||||||
|
)
|
||||||
|
i = end + 1
|
||||||
|
} else {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
builder.append(markdown[i++])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.example.notesai.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
object MarkdownSerializer {
|
||||||
|
|
||||||
|
fun toMarkdown(text: AnnotatedString): String {
|
||||||
|
val raw = text.text
|
||||||
|
if (text.spanStyles.isEmpty()) return raw
|
||||||
|
|
||||||
|
val markers = Array(raw.length + 1) { mutableListOf<String>() }
|
||||||
|
|
||||||
|
text.spanStyles.forEach { span ->
|
||||||
|
if (span.item.fontWeight == FontWeight.Bold) {
|
||||||
|
markers[span.start].add("**")
|
||||||
|
markers[span.end].add("**")
|
||||||
|
}
|
||||||
|
if (span.item.fontStyle == FontStyle.Italic) {
|
||||||
|
markers[span.start].add("*")
|
||||||
|
markers[span.end].add("*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (i in raw.indices) {
|
||||||
|
markers[i].forEach { sb.append(it) }
|
||||||
|
sb.append(raw[i])
|
||||||
|
}
|
||||||
|
markers[raw.length].forEach { sb.append(it) }
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,8 @@ 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"
|
||||||
foundationLayout = "1.10.0"
|
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" }
|
||||||
@ -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" }
|
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-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]
|
[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