|
| 1 | +package com.flipcash.app.messenger.internal.screens.components |
| 2 | + |
| 3 | +import androidx.compose.foundation.background |
| 4 | +import androidx.compose.foundation.clickable |
| 5 | +import androidx.compose.foundation.layout.Arrangement |
| 6 | +import androidx.compose.foundation.layout.Box |
| 7 | +import androidx.compose.foundation.layout.Column |
| 8 | +import androidx.compose.foundation.layout.fillMaxWidth |
| 9 | +import androidx.compose.foundation.layout.navigationBarsPadding |
| 10 | +import androidx.compose.foundation.layout.padding |
| 11 | +import androidx.compose.foundation.layout.size |
| 12 | +import androidx.compose.foundation.shape.CircleShape |
| 13 | +import androidx.compose.foundation.text.input.OutputTransformation |
| 14 | +import androidx.compose.foundation.text.input.TextFieldState |
| 15 | +import androidx.compose.material.icons.Icons |
| 16 | +import androidx.compose.material.icons.rounded.Code |
| 17 | +import androidx.compose.material.icons.rounded.DataObject |
| 18 | +import androidx.compose.material.icons.rounded.FormatBold |
| 19 | +import androidx.compose.material.icons.rounded.FormatItalic |
| 20 | +import androidx.compose.material.icons.rounded.FormatListBulleted |
| 21 | +import androidx.compose.material.icons.rounded.FormatListNumbered |
| 22 | +import androidx.compose.material.icons.rounded.FormatQuote |
| 23 | +import androidx.compose.material.icons.rounded.FormatStrikethrough |
| 24 | +import androidx.compose.material.icons.rounded.Link |
| 25 | +import androidx.compose.material3.ExperimentalMaterial3Api |
| 26 | +import androidx.compose.material3.ModalBottomSheet |
| 27 | +import androidx.compose.material3.Text |
| 28 | +import androidx.compose.material3.rememberModalBottomSheetState |
| 29 | +import androidx.compose.foundation.text.BasicTextField |
| 30 | +import androidx.compose.foundation.text.KeyboardOptions |
| 31 | +import androidx.compose.runtime.Composable |
| 32 | +import androidx.compose.runtime.LaunchedEffect |
| 33 | +import androidx.compose.runtime.Stable |
| 34 | +import androidx.compose.runtime.getValue |
| 35 | +import androidx.compose.runtime.mutableStateOf |
| 36 | +import androidx.compose.runtime.remember |
| 37 | +import androidx.compose.runtime.setValue |
| 38 | +import androidx.compose.ui.Alignment |
| 39 | +import androidx.compose.ui.Modifier |
| 40 | +import androidx.compose.ui.draw.clip |
| 41 | +import androidx.compose.ui.graphics.Color |
| 42 | +import androidx.compose.ui.graphics.SolidColor |
| 43 | +import androidx.compose.ui.platform.testTag |
| 44 | +import androidx.compose.ui.res.stringResource |
| 45 | +import androidx.compose.ui.text.SpanStyle |
| 46 | +import androidx.compose.ui.text.TextRange |
| 47 | +import androidx.compose.ui.text.input.KeyboardType |
| 48 | +import androidx.compose.ui.unit.dp |
| 49 | +import com.flipcash.core.R as CoreR |
| 50 | +import com.flipcash.libs.textformat.ComposerEdit |
| 51 | +import com.flipcash.libs.textformat.DraftRanges |
| 52 | +import com.flipcash.libs.textformat.InlineFormat |
| 53 | +import com.flipcash.libs.textformat.LineFormat |
| 54 | +import com.flipcash.libs.textformat.consumedRanges |
| 55 | +import com.flipcash.libs.textformat.insertLink |
| 56 | +import com.flipcash.libs.textformat.isInlineActive |
| 57 | +import com.flipcash.libs.textformat.isLineActive |
| 58 | +import com.flipcash.libs.textformat.toggleInline |
| 59 | +import com.flipcash.libs.textformat.toggleLine |
| 60 | +import com.flipcash.shared.chat.ui.maskableUrlOrNull |
| 61 | +import com.flipcash.shared.chat.ui.parseChatText |
| 62 | +import com.flipcash.shared.chat.ui.protectedRangesOf |
| 63 | +import com.getcode.theme.CodeTheme |
| 64 | +import com.getcode.ui.components.chat.FormatAction |
| 65 | +import com.getcode.ui.components.chat.ToolbarFormatItem |
| 66 | + |
| 67 | +private val Ranges = DraftRanges { protectedRangesOf(it) } |
| 68 | + |
| 69 | +/** |
| 70 | + * The composer's formatting state: whether the strip is open, whether the link sheet is, and the |
| 71 | + * edits the buttons make to [field]. Every edit goes through the shared helpers in |
| 72 | + * `:libs:text-format`, so the parser is the one judge of what a button may do. |
| 73 | + */ |
| 74 | +@Stable |
| 75 | +internal class ComposerFormatting(private val field: TextFieldState) { |
| 76 | + var stripOpen by mutableStateOf(false) |
| 77 | + var linkSheetOpen by mutableStateOf(false) |
| 78 | + |
| 79 | + private val text get() = field.text.toString() |
| 80 | + private val from get() = field.selection.min |
| 81 | + private val to get() = field.selection.max |
| 82 | + |
| 83 | + fun toggle(format: InlineFormat) { |
| 84 | + toggleInline(text, from, to, format, Ranges)?.let(::apply) |
| 85 | + } |
| 86 | + |
| 87 | + fun toggle(format: LineFormat) = apply(toggleLine(text, from, to, format)) |
| 88 | + |
| 89 | + fun isActive(format: InlineFormat) = isInlineActive(text, from, to, format, Ranges) |
| 90 | + |
| 91 | + fun isEnabled(format: InlineFormat) = |
| 92 | + field.selection.collapsed || isActive(format) || toggleInline(text, from, to, format, Ranges) != null |
| 93 | + |
| 94 | + fun isActive(format: LineFormat) = isLineActive(text, from, to, format) |
| 95 | + |
| 96 | + fun addLink(url: String) = apply(insertLink(text, from, to, url)) |
| 97 | + |
| 98 | + private fun apply(edit: ComposerEdit) { |
| 99 | + field.edit { |
| 100 | + replace(0, length, edit.text) |
| 101 | + selection = TextRange(edit.selectionStart, edit.selectionEnd) |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + /** The items the system text toolbar leads with: none until there is a selection to wrap. */ |
| 106 | + fun toolbarItems(): List<ToolbarFormatItem> { |
| 107 | + if (field.selection.collapsed) return emptyList() |
| 108 | + fun inline(key: String, label: String, format: InlineFormat) = |
| 109 | + ToolbarFormatItem(key, label, isEnabled(format)) { toggle(format) } |
| 110 | + return listOf( |
| 111 | + inline("fmt_bold", "B", InlineFormat.Bold), |
| 112 | + inline("fmt_italic", "I", InlineFormat.Italic), |
| 113 | + inline("fmt_strike", "S", InlineFormat.Strike), |
| 114 | + inline("fmt_code", "</>", InlineFormat.Code), |
| 115 | + ToolbarFormatItem("fmt_link", "Link", true) { linkSheetOpen = true }, |
| 116 | + ) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +@Composable |
| 121 | +internal fun rememberComposerFormatting(field: TextFieldState): ComposerFormatting { |
| 122 | + val formatting = remember(field) { ComposerFormatting(field) } |
| 123 | + // A cleared draft is back to one line, where the toggle is gone, so the strip goes with it. |
| 124 | + LaunchedEffect(field.text.isEmpty()) { |
| 125 | + if (field.text.isEmpty()) formatting.stripOpen = false |
| 126 | + } |
| 127 | + return formatting |
| 128 | +} |
| 129 | + |
| 130 | +/** Dims the characters the parser consumed, so the markers show but recede. */ |
| 131 | +@Composable |
| 132 | +internal fun rememberMarkerDimming(enabled: Boolean): OutputTransformation? { |
| 133 | + val dim = CodeTheme.colors.textSecondary |
| 134 | + return remember(enabled, dim) { |
| 135 | + if (!enabled) null else OutputTransformation { |
| 136 | + val raw = asCharSequence().toString() |
| 137 | + consumedRanges(raw, parseChatText(raw)).forEach { |
| 138 | + addStyle(SpanStyle(color = dim), it.first, it.last + 1) |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/** The `Aa` button that opens and closes the strip. */ |
| 145 | +@Composable |
| 146 | +internal fun FormatToggleButton(formatting: ComposerFormatting) { |
| 147 | + val open = formatting.stripOpen |
| 148 | + Box( |
| 149 | + modifier = Modifier |
| 150 | + .testTag("chat_format_toggle") |
| 151 | + .size(com.getcode.ui.components.chat.ChatInputDefaults.AccessorySize) |
| 152 | + .clip(CircleShape) |
| 153 | + .background(Color.White.copy(alpha = if (open) 0.25f else 0.1f), CircleShape) |
| 154 | + .clickable { formatting.stripOpen = !formatting.stripOpen }, |
| 155 | + contentAlignment = Alignment.Center, |
| 156 | + ) { |
| 157 | + Text( |
| 158 | + text = "Aa", |
| 159 | + style = CodeTheme.typography.textSmall, |
| 160 | + color = CodeTheme.colors.textMain, |
| 161 | + ) |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +/** The nine buttons, docked under the composer in the keyboard's colour. */ |
| 166 | +@Composable |
| 167 | +internal fun FormatStrip(formatting: ComposerFormatting, field: TextFieldState) { |
| 168 | + // Reads the field so the buttons follow the selection and the text. |
| 169 | + val context = androidx.compose.ui.platform.LocalContext.current |
| 170 | + @Suppress("UNUSED_VARIABLE") val tick = field.text to field.selection |
| 171 | + fun inline(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: InlineFormat) = |
| 172 | + FormatAction(id, icon, context.getString(res), formatting.isActive(f), formatting.isEnabled(f)) { formatting.toggle(f) } |
| 173 | + fun line(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: LineFormat) = |
| 174 | + FormatAction(id, icon, context.getString(res), formatting.isActive(f), true) { formatting.toggle(f) } |
| 175 | + com.getcode.ui.components.chat.ComposerFormatStrip( |
| 176 | + inline = listOf( |
| 177 | + inline("bold", Icons.Rounded.FormatBold, CoreR.string.description_chatFormatBold, InlineFormat.Bold), |
| 178 | + inline("italic", Icons.Rounded.FormatItalic, CoreR.string.description_chatFormatItalic, InlineFormat.Italic), |
| 179 | + inline("strike", Icons.Rounded.FormatStrikethrough, CoreR.string.description_chatFormatStrike, InlineFormat.Strike), |
| 180 | + inline("code", Icons.Rounded.Code, CoreR.string.description_chatFormatCode, InlineFormat.Code), |
| 181 | + FormatAction( |
| 182 | + id = "link", |
| 183 | + icon = Icons.Rounded.Link, |
| 184 | + description = stringResource(CoreR.string.description_chatFormatLink), |
| 185 | + selected = false, |
| 186 | + enabled = true, |
| 187 | + onClick = { formatting.linkSheetOpen = true }, |
| 188 | + ), |
| 189 | + ), |
| 190 | + lines = listOf( |
| 191 | + line("bulleted", Icons.Rounded.FormatListBulleted, CoreR.string.description_chatFormatBulleted, LineFormat.Bullet), |
| 192 | + line("numbered", Icons.Rounded.FormatListNumbered, CoreR.string.description_chatFormatNumbered, LineFormat.Numbered), |
| 193 | + line("quote", Icons.Rounded.FormatQuote, CoreR.string.description_chatFormatQuote, LineFormat.Quote), |
| 194 | + line("codeblock", Icons.Rounded.DataObject, CoreR.string.description_chatFormatCodeBlock, LineFormat.CodeBlock), |
| 195 | + ), |
| 196 | + ) |
| 197 | +} |
| 198 | + |
| 199 | +/** Asks for the URL, and accepts only exactly one detected link. */ |
| 200 | +@OptIn(ExperimentalMaterial3Api::class) |
| 201 | +@Composable |
| 202 | +internal fun LinkSheet(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { |
| 203 | + var input by remember { mutableStateOf("") } |
| 204 | + var rejected by remember { mutableStateOf(false) } |
| 205 | + ModalBottomSheet( |
| 206 | + onDismissRequest = onDismiss, |
| 207 | + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), |
| 208 | + containerColor = CodeTheme.colors.background, |
| 209 | + ) { |
| 210 | + Column( |
| 211 | + modifier = Modifier |
| 212 | + .fillMaxWidth() |
| 213 | + .navigationBarsPadding() |
| 214 | + .padding(horizontal = 20.dp, vertical = 12.dp), |
| 215 | + verticalArrangement = Arrangement.spacedBy(12.dp), |
| 216 | + ) { |
| 217 | + Text( |
| 218 | + text = stringResource(CoreR.string.title_chatFormatLink), |
| 219 | + style = CodeTheme.typography.textLarge, |
| 220 | + color = CodeTheme.colors.textMain, |
| 221 | + ) |
| 222 | + Box( |
| 223 | + Modifier |
| 224 | + .fillMaxWidth() |
| 225 | + .clip(CodeTheme.shapes.small) |
| 226 | + .background(Color.White.copy(alpha = 0.08f)) |
| 227 | + .padding(14.dp), |
| 228 | + ) { |
| 229 | + if (input.isEmpty()) { |
| 230 | + Text( |
| 231 | + text = stringResource(CoreR.string.hint_chatFormatLink), |
| 232 | + style = CodeTheme.typography.textMedium, |
| 233 | + color = CodeTheme.colors.textSecondary, |
| 234 | + ) |
| 235 | + } |
| 236 | + BasicTextField( |
| 237 | + value = input, |
| 238 | + onValueChange = { input = it; rejected = false }, |
| 239 | + modifier = Modifier.fillMaxWidth().testTag("chat_format_link_input"), |
| 240 | + singleLine = true, |
| 241 | + textStyle = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain), |
| 242 | + cursorBrush = SolidColor(CodeTheme.colors.textMain), |
| 243 | + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), |
| 244 | + ) |
| 245 | + } |
| 246 | + if (rejected) { |
| 247 | + Text( |
| 248 | + text = stringResource(CoreR.string.error_chatFormatLink), |
| 249 | + style = CodeTheme.typography.textSmall, |
| 250 | + color = com.getcode.theme.TextError, |
| 251 | + modifier = Modifier.testTag("chat_format_link_error"), |
| 252 | + ) |
| 253 | + } |
| 254 | + Box( |
| 255 | + modifier = Modifier |
| 256 | + .testTag("chat_format_link_add") |
| 257 | + .fillMaxWidth() |
| 258 | + .clip(CodeTheme.shapes.small) |
| 259 | + .background(Color.White) |
| 260 | + .clickable { |
| 261 | + val url = maskableUrlOrNull(input) |
| 262 | + if (url == null) rejected = true else onConfirm(url) |
| 263 | + } |
| 264 | + .padding(vertical = 14.dp), |
| 265 | + contentAlignment = Alignment.Center, |
| 266 | + ) { |
| 267 | + Text( |
| 268 | + text = stringResource(CoreR.string.action_chatFormatLinkAdd), |
| 269 | + style = CodeTheme.typography.textMedium, |
| 270 | + color = Color.Black, |
| 271 | + ) |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | +} |
0 commit comments