common: migrate to Android-only

This commit is contained in:
Harsh Shandilya 2022-08-02 23:05:28 +05:30
parent 47a00ad61a
commit ab2713154f
No known key found for this signature in database
GPG key ID: 366D7BBAD1031E80
55 changed files with 43 additions and 474 deletions

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest />

View file

@ -0,0 +1,7 @@
package dev.msfjarvis.claw.common
sealed class NetworkState {
class Success<T>(val data: T) : NetworkState()
class Error(val message: String) : NetworkState()
object Loading : NetworkState()
}

View file

@ -0,0 +1,142 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.PostTitle
import dev.msfjarvis.claw.common.posts.Submitter
import dev.msfjarvis.claw.common.posts.TagRow
import dev.msfjarvis.claw.common.res.ClawIcons
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.Comment
import dev.msfjarvis.claw.model.LobstersPostDetails
@Composable
fun CommentsHeader(
postDetails: LobstersPostDetails,
postActions: PostActions,
) {
val htmlConverter = LocalHTMLConverter.current
val uriHandler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PostTitle(title = postDetails.title)
TagRow(tags = postDetails.tags)
Spacer(Modifier.height(4.dp))
if (postDetails.url.isNotBlank()) {
PostLink(
link = postDetails.url,
modifier =
Modifier.clickable { postActions.viewPost(postDetails.url, postDetails.commentsUrl) },
)
Spacer(Modifier.height(4.dp))
}
if (postDetails.description.isNotBlank()) {
ThemedRichText(htmlConverter.convertHTMLToMarkdown(postDetails.description))
Spacer(Modifier.height(4.dp))
}
Submitter(
text = "Submitted by ${postDetails.submitter.username}",
avatarUrl = "https://lobste.rs/${postDetails.submitter.avatarUrl}",
contentDescription = "User avatar for ${postDetails.submitter.username}",
modifier =
Modifier.clickable {
uriHandler.openUri("https://lobste.rs/u/${postDetails.submitter.username}")
},
)
}
}
}
@Composable
fun PostLink(
link: String,
modifier: Modifier = Modifier,
) {
Box(
modifier.background(
color = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(8.dp),
)
) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(
painter = ClawIcons.Web,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondary,
)
Text(
text = link,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
color = MaterialTheme.colorScheme.onSecondary,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun CommentEntry(
comment: Comment,
) {
var expanded by remember(comment) { mutableStateOf(true) }
val htmlConverter = LocalHTMLConverter.current
val uriHandler = LocalUriHandler.current
Box(
modifier =
Modifier.fillMaxWidth()
.clickable { expanded = !expanded }
.background(MaterialTheme.colorScheme.background)
.padding(start = (comment.indentLevel * 16).dp, end = 16.dp, top = 16.dp, bottom = 16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Submitter(
text = comment.user.username,
avatarUrl = "https://lobste.rs/${comment.user.avatarUrl}",
contentDescription = "User avatar for ${comment.user.username}",
modifier =
Modifier.clickable { uriHandler.openUri("https://lobste.rs/u/${comment.user.username}") },
)
AnimatedContent(targetState = expanded) { expandedState ->
if (expandedState) {
ThemedRichText(
text = htmlConverter.convertHTMLToMarkdown(comment.comment),
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
}

View file

@ -0,0 +1,92 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.ui.Divider
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.model.LobstersPostDetails
@Composable
private fun CommentsPageInternal(
details: LobstersPostDetails,
postActions: PostActions,
modifier: Modifier = Modifier,
) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
LazyColumn(modifier = modifier, contentPadding = PaddingValues(bottom = 24.dp)) {
item { CommentsHeader(postDetails = details, postActions = postActions) }
if (details.commentCount > 0) {
item {
Text(
text = "Comments",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
itemsIndexed(details.comments) { index, item ->
if (index != 0) {
Divider()
}
CommentEntry(item)
}
} else {
item {
Text(
text = "No Comments",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth().padding(16.dp),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Suppress("UNCHECKED_CAST")
@Composable
fun CommentsPage(
postId: String,
getDetails: suspend (String) -> LobstersPostDetails,
postActions: PostActions,
modifier: Modifier = Modifier,
) {
val postDetails by
produceState<NetworkState>(NetworkState.Loading) {
value = NetworkState.Success(getDetails(postId))
}
when (postDetails) {
is NetworkState.Success<*> -> {
CommentsPageInternal(
(postDetails as NetworkState.Success<LobstersPostDetails>).data,
postActions,
modifier.fillMaxSize(),
)
}
is NetworkState.Error -> {
NetworkError((postDetails as NetworkState.Error).message)
}
NetworkState.Loading -> ProgressBar(modifier)
}
}

View file

@ -0,0 +1,10 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.runtime.staticCompositionLocalOf
/** Defines a contract to convert strings of HTML to Markdown. */
fun interface HTMLConverter {
fun convertHTMLToMarkdown(html: String): String
}
val LocalHTMLConverter = staticCompositionLocalOf<HTMLConverter> { error("To be provided") }

View file

@ -0,0 +1,229 @@
package dev.msfjarvis.claw.common.posts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import dev.msfjarvis.claw.common.res.ClawIcons
import dev.msfjarvis.claw.common.ui.Divider
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.database.local.SavedPost
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun LobstersCard(
post: SavedPost,
isSaved: Boolean,
postActions: PostActions,
modifier: Modifier = Modifier,
) {
var localSavedState by remember(post, isSaved) { mutableStateOf(isSaved) }
Box(
modifier =
modifier
.fillMaxWidth()
.clickable { postActions.viewPost(post.url, post.commentsUrl) }
.padding(start = 16.dp, top = 16.dp, end = 4.dp, bottom = 16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PostDetails(
modifier = Modifier.weight(1f),
post = post,
)
Column(
modifier = Modifier.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SaveButton(
isSaved = localSavedState,
modifier =
Modifier.clickable(
role = Role.Button,
indication = rememberRipple(bounded = false, radius = 24.dp),
interactionSource = remember { MutableInteractionSource() },
) {
localSavedState = !localSavedState
postActions.toggleSave(post)
},
)
Divider(modifier = Modifier.width(48.dp))
CommentsButton(
commentCount = post.commentCount,
modifier =
Modifier.combinedClickable(
role = Role.Button,
indication = rememberRipple(bounded = false, radius = 24.dp),
interactionSource = remember { MutableInteractionSource() },
onClick = { postActions.viewComments(post.shortId) },
onLongClick = { postActions.viewCommentsPage(post.commentsUrl) },
),
)
}
}
}
}
@Composable
fun PostDetails(post: SavedPost, modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
PostTitle(title = post.title)
TagRow(tags = post.tags)
Spacer(Modifier.height(4.dp))
Submitter(
text = "Submitted by ${post.submitterName}",
avatarUrl = "https://lobste.rs/${post.submitterAvatarUrl}",
contentDescription = "User avatar for ${post.submitterName}",
)
}
}
@Composable
fun PostTitle(
title: String,
modifier: Modifier = Modifier,
) {
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
@Composable
fun Submitter(
text: String,
avatarUrl: String,
contentDescription: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NetworkImage(
url = avatarUrl,
contentDescription = contentDescription,
modifier = modifier.requiredSize(24.dp).clip(CircleShape),
)
Text(text = text, modifier = modifier, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun SaveButton(
isSaved: Boolean,
modifier: Modifier = Modifier,
) {
Crossfade(targetState = isSaved) { saved ->
Box(modifier = modifier.padding(12.dp)) {
Icon(
painter = if (saved) ClawIcons.Heart else ClawIcons.HeartBorder,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = if (saved) "Remove from saved posts" else "Add to saved posts",
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@Composable
fun CommentsButton(
commentCount: Int?,
modifier: Modifier = Modifier,
) {
BadgedBox(
modifier = modifier.padding(12.dp),
badge = {
if (commentCount != null) {
Badge(
modifier = Modifier.absoluteOffset(x = (-8).dp),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
) {
Text(
text = commentCount.toString(),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.labelMedium,
)
}
}
},
) {
Icon(
painter = ClawIcons.Comment,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = "Open comments",
modifier = Modifier.align(Alignment.Center),
)
}
}
@Composable
fun TagRow(
tags: List<String>,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp,
) {
tags.forEach { tag -> TagText(tag) }
}
}
@Composable
fun TagText(
tag: String,
modifier: Modifier = Modifier,
) {
Text(
text = tag,
modifier =
Modifier.background(MaterialTheme.colorScheme.tertiaryContainer, RoundedCornerShape(50))
.padding(vertical = 4.dp, horizontal = 12.dp)
.then(modifier),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}

View file

@ -0,0 +1,10 @@
package dev.msfjarvis.claw.common.posts
import dev.msfjarvis.claw.database.local.SavedPost
interface PostActions {
fun viewPost(postUrl: String, commentsUrl: String)
fun viewComments(postId: String)
fun viewCommentsPage(commentsUrl: String)
fun toggleSave(post: SavedPost)
}

View file

@ -0,0 +1,33 @@
package dev.msfjarvis.claw.common.posts
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
fun LobstersPost.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentCount = commentCount,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}
fun LobstersPostDetails.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentCount = commentCount,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}

View file

@ -0,0 +1,32 @@
package dev.msfjarvis.claw.common.res
import dev.msfjarvis.claw.common.res.clawicons.arrow_back_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.comment_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.favorite_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.favorite_border_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.new_releases_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.new_releases_filled_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.public_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.whatshot_black_24dp
import dev.msfjarvis.claw.common.res.clawicons.whatshot_filled_black_24dp
object ClawIcons {
val ArrowBack = arrow_back_black_24dp()
val Comment = comment_black_24dp()
val Flame = whatshot_black_24dp()
val FlameFilled = whatshot_filled_black_24dp()
val Heart = favorite_black_24dp()
val HeartBorder = favorite_border_black_24dp()
val New = new_releases_black_24dp()
val NewFilled = new_releases_filled_black_24dp()
val Web = public_black_24dp()
}

View file

@ -0,0 +1,58 @@
package dev.msfjarvis.claw.common.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFFac3325)
val md_theme_light_onPrimary = Color(0xFFffffff)
val md_theme_light_primaryContainer = Color(0xFFffdad3)
val md_theme_light_onPrimaryContainer = Color(0xFF410000)
val md_theme_light_secondary = Color(0xFF775752)
val md_theme_light_onSecondary = Color(0xFFffffff)
val md_theme_light_secondaryContainer = Color(0xFFffdad3)
val md_theme_light_onSecondaryContainer = Color(0xFF2c1511)
val md_theme_light_tertiary = Color(0xFF705c2e)
val md_theme_light_onTertiary = Color(0xFFffffff)
val md_theme_light_tertiaryContainer = Color(0xFFfbdfa5)
val md_theme_light_onTertiaryContainer = Color(0xFF261a00)
val md_theme_light_error = Color(0xFFba1b1b)
val md_theme_light_errorContainer = Color(0xFFffdad4)
val md_theme_light_onError = Color(0xFFffffff)
val md_theme_light_onErrorContainer = Color(0xFF410001)
val md_theme_light_background = Color(0xFFfcfcfc)
val md_theme_light_onBackground = Color(0xFF201a19)
val md_theme_light_surface = Color(0xFFfcfcfc)
val md_theme_light_onSurface = Color(0xFF201a19)
val md_theme_light_surfaceVariant = Color(0xFFf5deda)
val md_theme_light_onSurfaceVariant = Color(0xFF534341)
val md_theme_light_outline = Color(0xFF867370)
val md_theme_light_inverseOnSurface = Color(0xFFfbeeec)
val md_theme_light_inverseSurface = Color(0xFF362f2e)
val md_theme_dark_primary = Color(0xFFffb4a6)
val md_theme_dark_onPrimary = Color(0xFF690000)
val md_theme_dark_primaryContainer = Color(0xFF8a1a10)
val md_theme_dark_onPrimaryContainer = Color(0xFFffdad3)
val md_theme_dark_secondary = Color(0xFFe7bdb6)
val md_theme_dark_onSecondary = Color(0xFF442925)
val md_theme_dark_secondaryContainer = Color(0xFF5d3f3a)
val md_theme_dark_onSecondaryContainer = Color(0xFFffdad3)
val md_theme_dark_tertiary = Color(0xFFdec48c)
val md_theme_dark_onTertiary = Color(0xFF3e2e04)
val md_theme_dark_tertiaryContainer = Color(0xFF564418)
val md_theme_dark_onTertiaryContainer = Color(0xFFfbdfa5)
val md_theme_dark_error = Color(0xFFffb4a9)
val md_theme_dark_errorContainer = Color(0xFF930006)
val md_theme_dark_onError = Color(0xFF680003)
val md_theme_dark_onErrorContainer = Color(0xFFffdad4)
val md_theme_dark_background = Color(0xFF201a19)
val md_theme_dark_onBackground = Color(0xFFede0de)
val md_theme_dark_surface = Color(0xFF201a19)
val md_theme_dark_onSurface = Color(0xFFede0de)
val md_theme_dark_surfaceVariant = Color(0xFF534341)
val md_theme_dark_onSurfaceVariant = Color(0xFFd8c2be)
val md_theme_dark_outline = Color(0xFFa08c89)
val md_theme_dark_inverseOnSurface = Color(0xFF201a19)
val md_theme_dark_inverseSurface = Color(0xFFede0de)
val seed = Color(0xFF6c0000)
val error = Color(0xFFba1b1b)

View file

@ -0,0 +1,78 @@
package dev.msfjarvis.claw.common.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidedValue
val LightThemeColors =
lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
)
val DarkThemeColors =
darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
)
@Composable
fun LobstersTheme(
providedValues: Array<ProvidedValue<*>> = emptyArray(),
colorScheme: ColorScheme,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(*providedValues) {
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
}

View file

@ -0,0 +1,144 @@
package dev.msfjarvis.claw.common.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.msfjarvis.claw.common.R
private val Manrope =
FontFamily(
Font(R.font.manrope_bold, FontWeight.Bold),
Font(R.font.manrope_extrabold, FontWeight.ExtraBold),
Font(R.font.manrope_extralight, FontWeight.ExtraLight),
Font(R.font.manrope_light, FontWeight.Light),
Font(R.font.manrope_medium, FontWeight.Medium),
Font(R.font.manrope_regular, FontWeight.Normal),
Font(R.font.manrope_semibold, FontWeight.SemiBold),
)
val AppTypography =
Typography(
displayLarge =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
displayMedium =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp,
),
displaySmall =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp,
),
headlineLarge =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp,
),
headlineSmall =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp,
),
titleSmall =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelLarge =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
labelMedium =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall =
TextStyle(
fontFamily = Manrope,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)

View file

@ -0,0 +1,15 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun Divider(modifier: Modifier = Modifier) {
androidx.compose.material.Divider(
color = MaterialTheme.colorScheme.onBackground.copy(alpha = DividerAlpha),
modifier = modifier,
)
}
private const val DividerAlpha = 0.15f

View file

@ -0,0 +1,23 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.material3.Material3RichText
@Composable
fun ThemedRichText(
text: String,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyLarge,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
Material3RichText(modifier) { Markdown(text) }
}
}

View file

@ -0,0 +1,16 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun NetworkError(message: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = message, style = MaterialTheme.typography.displayMedium)
}
}

View file

@ -0,0 +1,20 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import coil.compose.AsyncImage
@Composable
fun NetworkImage(
url: String,
contentDescription: String,
modifier: Modifier,
) {
AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier.clip(CircleShape)
)
}

View file

@ -0,0 +1,16 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ProgressBar(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary)
}
}

View file

@ -0,0 +1,24 @@
package dev.msfjarvis.claw.common.ui.decorations
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ClawAppBar(
backgroundColor: Color,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
title: @Composable () -> Unit = {},
) {
SmallTopAppBar(
title = title,
modifier = modifier,
colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = backgroundColor),
navigationIcon = navigationIcon,
)
}

View file

@ -0,0 +1,31 @@
package dev.msfjarvis.claw.common.ui.decorations
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.ui.surfaceColorAtNavigationBarElevation
import kotlinx.datetime.Month
@Composable
fun MonthHeader(month: Month) {
Box(
Modifier.fillMaxWidth()
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceColorAtNavigationBarElevation())
) {
Text(
text = month.name.lowercase().capitalize(Locale.current),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
)
}
}

View file

@ -0,0 +1,27 @@
package dev.msfjarvis.claw.common.ui
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import kotlin.math.ln
/**
* Returns the [ColorScheme.surface] color with an alpha of the [ColorScheme.primary] color overlaid
* on top of it. Computes the surface tonal color at different elevation levels e.g. surface1
* through surface5.
*
* Stolen from AndroidX, keep in sync when upgrading Compose. This version is hard-coded to
* replicate the logic used by the Material3 NavigationBar to determine its surface color.
* https://github.com/androidx/androidx/blob/74d3510b608c3cc26b9cf9be8d15a6a6c26192c2/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt#L453-L466
*/
@Composable
fun ColorScheme.surfaceColorAtNavigationBarElevation(): Color {
// Absolute tonal elevation + NavigationBarTokens.ContainerElevation
val elevation = LocalAbsoluteTonalElevation.current + 3.0.dp
if (elevation == 0.dp) return surface
val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
return primary.copy(alpha = alpha).compositeOver(surface)
}

View file

@ -0,0 +1,27 @@
package dev.msfjarvis.claw.common.urllauncher
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.ui.platform.UriHandler
import io.github.aakira.napier.Napier
class UrlLauncher(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
val customTabsIntent =
CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_ON)
.setShowTitle(true)
.setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK)
.build()
try {
customTabsIntent.launchUrl(context, Uri.parse(uri))
} catch (e: ActivityNotFoundException) {
val error = "Failed to open URL: $uri"
Napier.d(tag = "UrlLauncher") { error }
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -0,0 +1,75 @@
package dev.msfjarvis.claw.common.user
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.common.NetworkState.Loading
import dev.msfjarvis.claw.common.NetworkState.Success
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.User
@Suppress("UNCHECKED_CAST")
@Composable
fun UserProfile(
username: String,
getProfile: suspend (username: String) -> User,
modifier: Modifier = Modifier,
) {
val user by produceState<NetworkState>(Loading) { value = Success(getProfile(username)) }
when (user) {
is Success<*> -> {
UserProfileInternal((user as Success<User>).data)
}
is NetworkState.Error -> {
NetworkError((user as NetworkState.Error).message)
}
Loading -> ProgressBar(modifier)
}
}
@Composable
private fun UserProfileInternal(
user: User,
modifier: Modifier = Modifier,
) {
Surface(modifier = modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
) {
NetworkImage(
url = "https://lobste.rs/${user.avatarUrl}",
contentDescription = "Avatar of ${user.username}",
modifier = Modifier.requiredSize(120.dp).clip(CircleShape),
)
Text(
text = user.username,
style = MaterialTheme.typography.displaySmall,
)
ThemedRichText(
text = user.about,
)
ThemedRichText(
text = "Invited by [${user.invitedBy}](https://lobste.rs/u/${user.invitedBy})",
)
}
}
}

View file

@ -0,0 +1,3 @@
package dev.msfjarvis.claw.common
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.