diff --git a/app/src/main/java/com/kickstarter/features/projectstory/ProjectStoryActivity.kt b/app/src/main/java/com/kickstarter/features/projectstory/ProjectStoryActivity.kt index 16bccec0c9..96171cf184 100644 --- a/app/src/main/java/com/kickstarter/features/projectstory/ProjectStoryActivity.kt +++ b/app/src/main/java/com/kickstarter/features/projectstory/ProjectStoryActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -32,6 +33,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kickstarter.features.projectstory.data.RichTextItem @@ -39,6 +42,7 @@ import com.kickstarter.features.projectstory.ui.RichTextItemPhotoComponent import com.kickstarter.features.projectstory.ui.RichTextItemTextComponent import com.kickstarter.features.projectstory.ui.WebViewComponent import com.kickstarter.libs.KSString +import com.kickstarter.libs.utils.ApplicationUtils import com.kickstarter.libs.utils.extensions.getEnvironment import com.kickstarter.libs.utils.extensions.isDarkModeEnabled import com.kickstarter.ui.compose.ProjectImageFromURl @@ -63,12 +67,22 @@ class ProjectStoryActivity : ComponentActivity() { projectStoryViewModelFactory = ProjectStoryViewModel.Factory(env) setContent { + val context = LocalContext.current + + val uriHandler = object : UriHandler { + override fun openUri(uri: String) { + ApplicationUtils.openUrlExternally(context, uri) + } + } + KickstarterApp( useDarkTheme = isDarkModeEnabled(env) ) { val uiState = projectStoryViewModel.projectStoryUiState.collectAsState() val txtState = projectStoryViewModel.txt - CampaignScreen(uiState, txtState) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + CampaignScreen(uiState, txtState) + } } } } diff --git a/app/src/main/java/com/kickstarter/features/projectstory/ui/ProjectStoryComponents.kt b/app/src/main/java/com/kickstarter/features/projectstory/ui/ProjectStoryComponents.kt index 37f2accdfc..1c93794945 100644 --- a/app/src/main/java/com/kickstarter/features/projectstory/ui/ProjectStoryComponents.kt +++ b/app/src/main/java/com/kickstarter/features/projectstory/ui/ProjectStoryComponents.kt @@ -16,11 +16,15 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LocalPinnableContainer import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Bullet +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString 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.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em @@ -31,8 +35,8 @@ import com.kickstarter.features.projectstory.data.RichTextItem import com.kickstarter.libs.utils.Secrets import com.kickstarter.libs.utils.extensions.getEnvironment import com.kickstarter.ui.compose.designsystem.grey_03 +import com.kickstarter.ui.compose.designsystem.kds_create_700 import timber.log.Timber -import kotlin.collections.forEach object StoryTheme { object Typography { @@ -42,6 +46,10 @@ object StoryTheme { val heading3 = TextStyle.Default.merge(fontSize = 24.sp) val heading4 = TextStyle.Default.merge(fontSize = 22.sp) } + + object InlineStyles { + val link = SpanStyle(color = kds_create_700, textDecoration = TextDecoration.Underline) + } } @Composable @@ -56,7 +64,8 @@ fun RichTextItemTextComponent(item: RichTextItem.Text) { is RichTextItem.Text.ChildParagraph -> null } - /* Should always be empty for first-level items */ + /* Currently, `styles` is empty for all first-level items except `Header`. + * At this point, the GQL transformer has already used `styles` to determine `Header.level` */ val styles = when (item) { is RichTextItem.Text.Paragraph -> item.styles @@ -120,21 +129,52 @@ fun RichTextItemTextComponent(item: RichTextItem.Text) { } private fun parseRichTextChildrenOfRichText(children: List): AnnotatedString { - /* Look for ways to optimize */ return buildAnnotatedString { - children.forEach { - Timber.d("") - if (it.styles.isNullOrEmpty()) { - append("${it.text} ") - } else { -// append(" ") - withStyle( - SpanStyle( - fontWeight = if (it.styles.contains("STRONG")) FontWeight.Bold else null, - fontStyle = if (it.styles.contains("EMPHASIS")) FontStyle.Italic else FontStyle.Normal + children.forEachIndexed { index, it -> + val text = it.text ?: "" + + val style = it.styles?.let { styles -> + SpanStyle( + fontWeight = if (styles.contains("STRONG")) FontWeight.Bold else null, + fontStyle = if (styles.contains("EMPHASIS")) FontStyle.Italic else FontStyle.Normal + ) + } + + val linkAnnotation = it.link?.let { + /* Properties from the design system `link` SpanStyle will override any competing properties + * in the `style` determined by the server response. As of 2026-03-03 there are none. */ + val linkBaseStyle = StoryTheme.InlineStyles.link.let { default -> + style?.merge(default) ?: default + } + LinkAnnotation.Url( + it, + styles = TextLinkStyles( + style = linkBaseStyle ) - ) { - append("${it.text}") + ) + } + + /* Join all sibling text with a space _except_ if the text starts with certain + * kinds of punctuation. This is to handle a peculiarity of how the server-side parser + * deals w/ spaces, and is likely to be changed on the server-side in the near future. */ + val firstCharacter = text.firstOrNull() + if (index != 0 && firstCharacter.needsLeadingSpace()) { + append(" ") + } + + when { + linkAnnotation != null -> { + withLink(linkAnnotation) { + append(text) + } + } + style != null -> { + withStyle(style) { + append(text) + } + } + else -> { + append(text) } } } @@ -190,3 +230,12 @@ fun WebViewComponent(url: String) { } ) } + +private fun Char?.needsLeadingSpace(): Boolean { + if (this == null || isWhitespace()) return false + + val type = Character.getType(this) + return type != Character.END_PUNCTUATION.toInt() && + type != Character.FINAL_QUOTE_PUNCTUATION.toInt() && + this !in ",.!?:;" +}