diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b6690897f..85353690f 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,7 @@ plugins { alias(libs.plugins.bugsnag) alias(libs.plugins.sqldelight) alias(libs.plugins.baselineprofile) + alias(libs.plugins.moshix) // alias(libs.plugins.playPublisher) } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index d93144790..a39bb15a4 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -14,16 +14,17 @@ ~ limitations under the License. --> - + - - - - - + + + + + - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f80e0ce0..378e0be6a 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,14 @@ android:name=".ui.activity.MainActivity" android:launchMode="singleTask" android:configChanges="colorMode|density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode" + android:exported="true" > + + + + + + Unit = {} ) { Row( modifier = modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - DetailColumn(item, themeColor) + DetailColumn(item, themeColor, showDescription = showDescription) item.mark?.let { mark -> Column( modifier = @@ -232,6 +234,7 @@ fun RowScope.DetailColumn( item: CatchUpItem, themeColor: Color, modifier: Modifier = Modifier, + showDescription: Boolean = true, ) { Column(modifier = modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { // Score, tag, timestamp @@ -244,15 +247,17 @@ fun RowScope.DetailColumn( color = MaterialTheme.colorScheme.onSurface ) // Description - item.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 5, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlphas.Medium) - ) - } + item.description + ?.takeIf { showDescription } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 5, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlphas.Medium) + ) + } // Author, source ItemFooter(item) } diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/about/AboutScreen.kt b/app/src/main/kotlin/io/sweers/catchup/ui/about/AboutScreen.kt index 4c7244425..6926ccf44 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/about/AboutScreen.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/about/AboutScreen.kt @@ -1,8 +1,8 @@ package io.sweers.catchup.ui.about +import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -13,7 +13,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,26 +25,63 @@ import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Screen import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dev.zacsweers.catchup.appconfig.AppConfig import dev.zacsweers.catchup.di.AppScope import io.sweers.catchup.R -import javax.inject.Inject +import java.util.Locale +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import timber.log.Timber @Parcelize -object AboutScreen : Screen { - data class State(val version: String) : CircuitUiState +data class AboutScreen(val selectedTab: AboutScreenComponent = AboutScreenComponent.DEFAULT) : + Screen { + data class State( + val initialPage: Int, + val version: String, + ) : CircuitUiState + + enum class AboutScreenComponent( + val screen: Screen, + @StringRes val titleRes: Int, + ) { + Licenses(LicensesScreen, R.string.licenses), + Changelog(ChangelogScreen, R.string.changelog); + + companion object { + internal val DEFAULT = Licenses + + fun componentFor(path: String?): AboutScreenComponent { + return when (path?.lowercase(Locale.US)) { + "licenses" -> Licenses + "changelog" -> Changelog + else -> { + Timber.d("Unknown path $path, defaulting to $DEFAULT") + DEFAULT + } + } + } + } + } } -@CircuitInject(AboutScreen::class, AppScope::class) -class AboutPresenter @Inject constructor(private val appConfig: AppConfig) : +class AboutPresenter +@AssistedInject +constructor(@Assisted val screen: AboutScreen, private val appConfig: AppConfig) : Presenter { - @Composable override fun present() = AboutScreen.State(appConfig.versionName) -} + @Composable + override fun present() = AboutScreen.State(screen.selectedTab.ordinal, appConfig.versionName) -private val SCREENS = listOf(LicensesScreen, ChangelogScreen) -private val SCREEN_TITLES = intArrayOf(R.string.licenses, R.string.changelog) + @CircuitInject(AboutScreen::class, AppScope::class) + @AssistedFactory + interface Factory { + fun create(screen: AboutScreen): AboutPresenter + } +} @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @CircuitInject(AboutScreen::class, AppScope::class) @@ -52,20 +89,22 @@ private val SCREEN_TITLES = intArrayOf(R.string.licenses, R.string.changelog) fun About(state: AboutScreen.State, modifier: Modifier = Modifier) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), containerColor = Color.Transparent, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { CollapsingAboutHeader(state.version, scrollBehavior = scrollBehavior) } ) { paddingValues -> Column(Modifier.padding(paddingValues)) { - val pagerState = rememberPagerState { 2 } + val components = remember { AboutScreen.AboutScreenComponent.entries.toImmutableList() } + val pagerState = rememberPagerState(initialPage = state.initialPage) { 2 } TabRow( // Our selected tab is our current page selectedTabIndex = pagerState.currentPage, ) { // Add tabs for all of our pages val coroutinesScope = rememberCoroutineScope() - SCREEN_TITLES.forEachIndexed { index, titleRes -> + components.forEach { component -> + val index = component.ordinal + val titleRes = component.titleRes Tab( text = { Text(stringResource(titleRes)) }, selected = pagerState.currentPage == index, @@ -84,7 +123,7 @@ fun About(state: AboutScreen.State, modifier: Modifier = Modifier) { state = pagerState, verticalAlignment = Alignment.Top, ) { page -> - CircuitContent(SCREENS[page]) + CircuitContent(components[page].screen) } } } diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/about/ChangelogScreen.kt b/app/src/main/kotlin/io/sweers/catchup/ui/about/ChangelogScreen.kt index 74f3cef40..c481be5bc 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/about/ChangelogScreen.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/about/ChangelogScreen.kt @@ -1,14 +1,20 @@ package io.sweers.catchup.ui.about import android.graphics.Color +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,10 +28,12 @@ import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Screen import com.slack.circuit.runtime.presenter.Presenter import com.squareup.anvil.annotations.ContributesBinding +import dev.zacsweers.catchup.compose.LocalDynamicTheme import dev.zacsweers.catchup.di.AppScope import dev.zacsweers.catchup.service.ClickableItem import dev.zacsweers.catchup.service.ErrorItem import dev.zacsweers.catchup.service.TextItem +import dev.zacsweers.catchup.service.rememberClickableItemState import io.sweers.catchup.R import io.sweers.catchup.data.LinkManager import io.sweers.catchup.data.github.RepoReleasesQuery @@ -80,6 +88,13 @@ constructor( @CircuitInject(ChangelogScreen::class, AppScope::class) @Composable fun Changelog(state: ChangelogScreen.State, modifier: Modifier = Modifier) { + var expandedItemIndex by remember { mutableIntStateOf(-1) } + val themeColor = + if (LocalDynamicTheme.current) { + MaterialTheme.colorScheme.primary + } else { + colorResource(R.color.colorAccent) + } LazyColumn(modifier = modifier) { val items = state.items if (items == null) { @@ -104,11 +119,17 @@ fun Changelog(state: ChangelogScreen.State, modifier: Modifier = Modifier) { key = { items[it].id }, ) { index -> val item = items[index] + val clickableItemState = rememberClickableItemState() + clickableItemState.focused = expandedItemIndex == index ClickableItem( modifier = Modifier.animateItemPlacement(), + state = clickableItemState, onClick = { state.eventSink(ChangelogScreen.Event.Click(item.clickUrl!!)) }, + onLongClick = { expandedItemIndex = if (expandedItemIndex == index) -1 else index } ) { - TextItem(item, colorResource(R.color.colorAccent)) + Column(Modifier.animateContentSize()) { + TextItem(item, themeColor, showDescription = expandedItemIndex == index) + } } } } @@ -152,8 +173,8 @@ constructor( tag = tag.name, source = tag.target.abbreviatedOid, // sha itemClickUrl = url.toString(), - // TODO revisit when we have expandable items and markdown support - // description = markdownConverter.replaceMarkdownEmojisIn(description!!), + // TODO render markdown at some point? + description = markdownConverter.replaceMarkdownEmojisIn(description!!), serviceId = "changelog", indexInResponse = index, // Not summarizable diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/about/CollapsingAboutHeader.kt b/app/src/main/kotlin/io/sweers/catchup/ui/about/CollapsingAboutHeader.kt index 8c381423a..ca3b589be 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/about/CollapsingAboutHeader.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/about/CollapsingAboutHeader.kt @@ -7,21 +7,27 @@ import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -31,6 +37,8 @@ import androidx.compose.material3.TopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -43,7 +51,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -63,10 +70,12 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap import dev.zacsweers.catchup.compose.CatchUpTheme +import dev.zacsweers.catchup.compose.arcLerp import io.sweers.catchup.R import io.sweers.catchup.base.ui.BackPressNavButton import io.sweers.catchup.util.UiUtil import kotlin.math.abs +import kotlin.math.absoluteValue import kotlin.math.roundToInt private const val FADE_PERCENT = 0.75F @@ -80,7 +89,11 @@ private const val TITLE_TRANSLATION_PERCENT = 0.50F // - Clean up leftover local state vars // - Can all this state be hoisted? // - Title initially jumps multiple pixels when collapsing -@OptIn(ExperimentalTextApi::class, ExperimentalMaterial3Api::class) +@OptIn( + ExperimentalTextApi::class, + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) @Composable fun CollapsingAboutHeader( versionName: String, @@ -88,13 +101,18 @@ fun CollapsingAboutHeader( modifier: Modifier = Modifier, maxHeight: Dp = 275.0.dp, pinnedHeight: Dp = 56.0.dp, - debugUi: Boolean = false, ) { + var debugUiCount by remember { mutableIntStateOf(0) } + val debugUi = debugUiCount >= 5 val pinnedHeightPx: Float val maxHeightPx: Float + val top: Int + val topDp: Dp LocalDensity.current.run { - pinnedHeightPx = pinnedHeight.toPx() - maxHeightPx = maxHeight.toPx() + top = WindowInsets.systemBars.getTop(this) + topDp = top.toDp() + pinnedHeightPx = pinnedHeight.toPx() + top + maxHeightPx = maxHeight.toPx() + top } // Sets the app bar's height offset limit to hide just the bottom title area and keep top title @@ -132,20 +150,20 @@ fun CollapsingAboutHeader( // alpha so they have time to fade out sufficiently). From here we just set translation // offsets to adjust the position naturally to give the appearance of settling in to // the right place. - val adjustedPercentage = (1 - collapsedFraction) * (1.0F / TITLE_TRANSLATION_PERCENT) - var bannerAlpha by remember { mutableStateOf(1f) } - var aboutTextAlpha by remember { mutableStateOf(1f) } + val adjustedPercentage = + (collapsedFraction - TITLE_TRANSLATION_PERCENT) * (1.0F / (1.0F - TITLE_TRANSLATION_PERCENT)) + var bannerAlpha by remember { mutableFloatStateOf(1f) } + var aboutTextAlpha by remember { mutableFloatStateOf(1f) } var titleLaidOut by remember { mutableStateOf(false) } - val xDelta = LocalDensity.current.run { 72.dp.toPx() } - var yDelta by remember { mutableStateOf(0f) } - var targetY by remember { mutableStateOf(0f) } - var titleY by remember { mutableStateOf(0f) } - var titleHeight by remember { mutableStateOf(0) } - var actualY by remember { mutableStateOf(0f) } + var titleY by remember { mutableFloatStateOf(0f) } + var titleHeight by remember { mutableIntStateOf(0) } + var actualY by remember { mutableFloatStateOf(0f) } var offset by remember { mutableStateOf(IntOffset.Zero) } val appBarHeightPx = maxHeightPx + scrollBehavior.state.heightOffset val appBarHeight = LocalDensity.current.run { appBarHeightPx.toDp() } - val appBarOverlap = (appBarHeightPx - (titleY + titleHeight)).coerceAtMost(0f) + val appBarOverlap = (appBarHeightPx - (titleY + titleHeight)).coerceIn(-titleHeight.toFloat(), 0f) + + var targetTitleOffset by remember { mutableStateOf(IntOffset.Zero) } if (debugUi) { SideEffect { println("ZAC: Surface height: ${maxHeightPx + scrollBehavior.state.heightOffset}") @@ -154,29 +172,30 @@ fun CollapsingAboutHeader( val boxDebugBackground = if (debugUi) Modifier.background(Color.Cyan) else Modifier Surface( - modifier = modifier.then(appBarDragModifier).height(appBarHeight).then(boxDebugBackground) + modifier = + modifier + .then(appBarDragModifier) + .heightIn(pinnedHeight, appBarHeight) + .then(boxDebugBackground) ) { - Box { + Box(Modifier.padding(top = topDp)) { Column(Modifier.fillMaxWidth()) { + if (debugUi) { + HorizontalDivider() + } if (titleLaidOut && collapsedFraction > TITLE_TRANSLATION_PERCENT) { - val interpolation = UiUtil.fastOutSlowInInterpolator.getInterpolation(adjustedPercentage) - val newY = - -LocalDensity.current.run { - lerp( - start = titleY.toDp() + appBarOverlap.toDp(), - stop = targetY.toDp(), - fraction = adjustedPercentage - ) - .roundToPx() - } - offset = - IntOffset( - x = -(xDelta - (interpolation * xDelta)).roundToInt(), - y = newY, - ) + val yOffset = + arcLerp(start = IntOffset.Zero, stop = targetTitleOffset, fraction = adjustedPercentage) + .y + // TODO why tf does this jump the start? + // val xOffset = arcLerp(IntOffset.Zero, targetTitleOffset, adjustedPercentage).x + val xOffset = lerp(IntOffset.Zero, targetTitleOffset, adjustedPercentage).x + offset = IntOffset(xOffset, yOffset) if (debugUi) { SideEffect { - println("ZAC: offset: $offset, yDelta: $yDelta, interpolation: $interpolation") + println( + "ZAC: offset: $offset, titleEnd: $targetTitleOffset, percentage: $adjustedPercentage / $collapsedFraction" + ) } } } else { @@ -199,7 +218,7 @@ fun CollapsingAboutHeader( aboutTextAlpha = interpolation } - Spacer(Modifier.height(48.dp)) + Spacer(Modifier.requiredHeight(48.dp)) // TODO kinda gross but shrug val context = LocalContext.current @@ -212,9 +231,12 @@ fun CollapsingAboutHeader( bitmap = icon.asImageBitmap(), contentDescription = "CatchUp icon", modifier = - Modifier.size(48.dp).align(CenterHorizontally).graphicsLayer { alpha = bannerAlpha } + Modifier.size(48.dp) + .align(CenterHorizontally) + .graphicsLayer { alpha = bannerAlpha } + .clickable { debugUiCount++ } ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) var fixedTitleHeight by remember { mutableStateOf(null) } val heightModifier = fixedTitleHeight?.let { Modifier.requiredHeight(it) } ?: Modifier val density = LocalDensity.current @@ -230,7 +252,7 @@ fun CollapsingAboutHeader( actualY = coordinates.positionInParent().y if (debugUi) { println( - "ZAC: y: ${coordinates.positionInParent().y} - ${coordinates.positionInRoot().y} - ${coordinates.positionInWindow().y}" + "ZAC: y: ${coordinates.positionInParent().y} - ${coordinates.positionInParent().y} - ${coordinates.positionInWindow().y}" ) } if (!titleLaidOut) { @@ -238,22 +260,25 @@ fun CollapsingAboutHeader( if (debugUi) { println("ZAC: fixedTitleHeight: $fixedTitleHeight, pinnedHeight: $pinnedHeight") } - targetY = density.run { ((pinnedHeight - fixedTitleHeight!!) / 2).toPx() } + + // positionInParent calls do not account for the system bar titleY = coordinates.positionInParent().y - // Y values are a bit trickier - these need to figure out where they would be on - // the larger plane, so we calculate it upfront by predicting where it would land - // after collapse is done. This requires knowing the parallax multiplier and - // adjusting for the parent plane rather than the relative plane of the internal - // Column. Once we know the predicted global Y, easy to calculate desired delta - // from there. // TODO account for parallax effect of shrinking column - yDelta = titleY - targetY - if (debugUi) { - println( - "ZAC: Computed yDelta: $yDelta. HeightOffset: ${scrollBehavior.state.heightOffsetLimit}, titleY: $titleY, height: ${coordinates.size.height}, targetY: $targetY" + // TODO account for system bar + val settledTitleOffset = + IntOffset( + x = coordinates.positionInParent().x.roundToInt(), + y = coordinates.positionInParent().y.roundToInt() + ) + + // top + vcenter of pinned height + val targetY = (((pinnedHeightPx - top) - titleHeight) / 2f) + targetTitleOffset = + IntOffset( + x = density.run { 56.dp.toPx() }.roundToInt() - settledTitleOffset.x, + y = targetY.roundToInt() - settledTitleOffset.y ) - } titleLaidOut = true } } @@ -264,7 +289,7 @@ fun CollapsingAboutHeader( style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) val isInPreview = LocalInspectionMode.current val text = if (isInPreview) { @@ -295,22 +320,24 @@ fun CollapsingAboutHeader( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) - Spacer(Modifier.height(48.dp)) + Spacer(Modifier.requiredHeight(48.dp)) } - BackPressNavButton(Modifier.align(Alignment.TopStart).padding(16.dp)) + BackPressNavButton(Modifier.align(Alignment.TopStart).padding(4.dp)) if (debugUi) { Text( text = - "offset: $offset\n" + - "actualY: ${actualY.roundToInt()}, distance: ${(actualY - targetY).roundToInt()}\n" + - "height: ${LocalDensity.current.run { appBarHeight.toPx() }.roundToInt()}, interp: ${(adjustedPercentage * 100).roundToInt().takeUnless { it > 100 } ?: "--"}%\n" + - "appBarOverlap: $appBarOverlap heightOffset: $appBarHeightPx, titleYActual: ${titleHeight + titleY}\n" + - "targetY: ${targetY.roundToInt()}, yDelta: $yDelta\n", + """ + offset: $offset, originalY: $titleY, titleHeight: $titleHeight + actualY: ${actualY.roundToInt()}, distance: ${(targetTitleOffset.y - actualY).roundToInt().absoluteValue} + height: $appBarHeightPx, interp: ${(adjustedPercentage * 100).roundToInt().takeUnless { it > 100 } ?: "--"}% + appBarOverlap: $appBarOverlap heightOffset: $appBarHeightPx + titleTarget: $targetTitleOffset, top: $top, pinned: $pinnedHeightPx + """ + .trimIndent(), modifier = Modifier.align(TopEnd), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.End, fontSize = 10.sp, - color = Color.Black, ) } } @@ -376,150 +403,3 @@ private suspend fun settleAppBar( private fun PreviewCollapsingAboutHeader() { CatchUpTheme { CollapsingAboutHeader("v1.0", TopAppBarDefaults.enterAlwaysScrollBehavior()) } } - -// private val headerHeight = 250.dp -// private val toolbarHeight = 56.dp -// -// private val paddingMedium = 16.dp -// -// private val titlePaddingStart = 16.dp -// private val titlePaddingEnd = 72.dp -// -// private const val titleFontScaleStart = 1f -// private const val titleFontScaleEnd = 0.66f -// -// @Composable -// fun CollapsingToolbarParallaxEffect() { -// val scroll: ScrollState = rememberScrollState(0) -// -// val headerHeightPx = with(LocalDensity.current) { headerHeight.toPx() } -// val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.toPx() } -// -// Box(modifier = Modifier.fillMaxSize()) { -// Header(scroll, headerHeightPx) -// Toolbar(scroll, headerHeightPx, toolbarHeightPx) -// Title(scroll, headerHeightPx, toolbarHeightPx) -// } -// } -// -// @Composable -// private fun Header(scroll: ScrollState, headerHeightPx: Float) { -// Box( -// modifier = -// Modifier.fillMaxWidth().height(headerHeight).graphicsLayer { -// translationY = -scroll.value.toFloat() / 2f // Parallax effect -// alpha = (-1f / headerHeightPx) * scroll.value + 1 -// } -// ) { -// // Image( -// // painter = painterResource(id = R.drawable.bg_pexel), -// // contentDescription = "", -// // contentScale = ContentScale.FillBounds -// // ) -// -// Box( -// Modifier.fillMaxSize() -// .background( -// brush = -// Brush.verticalGradient( -// colors = listOf(Color.Transparent, Color(0xAA000000)), -// startY = 3 * headerHeightPx / 4 // Gradient applied to wrap the title only -// ) -// ) -// ) -// } -// } -// -// @OptIn(ExperimentalMaterial3Api::class) -// @Composable -// private fun Toolbar(scroll: ScrollState, headerHeightPx: Float, toolbarHeightPx: Float) { -// val toolbarBottom = headerHeightPx - toolbarHeightPx -// val showToolbar by remember { derivedStateOf { scroll.value >= toolbarBottom } } -// -// AnimatedVisibility( -// visible = showToolbar, -// enter = fadeIn(animationSpec = tween(300)), -// exit = fadeOut(animationSpec = tween(300)) -// ) { -// TopAppBar( -// modifier = -// Modifier.background( -// brush = Brush.horizontalGradient(listOf(Color(0xff026586), Color(0xff032C45))) -// ), -// navigationIcon = { -// IconButton(onClick = {}, modifier = Modifier.padding(16.dp).size(24.dp)) { -// Icon(imageVector = Icons.Default.Menu, contentDescription = "", tint = Color.White) -// } -// }, -// title = {}, -// // backgroundColor = Color.Transparent, -// // elevation = 0.dp -// ) -// } -// } -// -// @Composable -// private fun Title(scroll: ScrollState, headerHeightPx: Float, toolbarHeightPx: Float) { -// var titleHeightPx by remember { mutableStateOf(0f) } -// var titleWidthPx by remember { mutableStateOf(0f) } -// -// Text( -// text = "New York", -// fontSize = 30.sp, -// fontWeight = FontWeight.Bold, -// modifier = -// Modifier.graphicsLayer { -// val collapseRange: Float = (headerHeightPx - toolbarHeightPx) -// val collapseFraction: Float = (scroll.value / collapseRange).coerceIn(0f, 1f) -// -// val scaleXY = lerp(titleFontScaleStart.dp, titleFontScaleEnd.dp, collapseFraction) -// -// val titleExtraStartPadding = titleWidthPx.toDp() * (1 - scaleXY.value) / 2f -// -// val titleYFirstInterpolatedPoint = -// lerp( -// headerHeight - titleHeightPx.toDp() - paddingMedium, -// headerHeight / 2, -// collapseFraction -// ) -// -// val titleXFirstInterpolatedPoint = -// lerp( -// titlePaddingStart, -// (titlePaddingEnd - titleExtraStartPadding) * 5 / 4, -// collapseFraction -// ) -// -// val titleYSecondInterpolatedPoint = -// lerp(headerHeight / 2, toolbarHeight / 2 - titleHeightPx.toDp() / 2, collapseFraction) -// -// val titleXSecondInterpolatedPoint = -// lerp( -// (titlePaddingEnd - titleExtraStartPadding) * 5 / 4, -// titlePaddingEnd - titleExtraStartPadding, -// collapseFraction -// ) -// -// val titleY = -// lerp(titleYFirstInterpolatedPoint, titleYSecondInterpolatedPoint, collapseFraction) -// -// val titleX = -// lerp(titleXFirstInterpolatedPoint, titleXSecondInterpolatedPoint, collapseFraction) -// -// translationY = titleY.toPx() -// translationX = titleX.toPx() -// scaleX = scaleXY.value -// scaleY = scaleXY.value -// } -// .onGloballyPositioned { -// titleHeightPx = it.size.height.toFloat() -// titleWidthPx = it.size.width.toFloat() -// } -// ) -// } -// -// @Preview(showBackground = true) -// @Composable -// fun DefaultPreview() { -// CatchUpTheme { CollapsingToolbarParallaxEffect() } -// } diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/about/LicensesScreen.kt b/app/src/main/kotlin/io/sweers/catchup/ui/about/LicensesScreen.kt index 198938c0f..6fe71ebb1 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/about/LicensesScreen.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/about/LicensesScreen.kt @@ -217,26 +217,6 @@ private fun OssItemHeaderUi(item: OssItemHeader) { fontWeight = FontWeight.Bold, ) } - // icon.load(item.avatarUrl) { - // onSuccess = { _, _ -> - // val bitmap: Bitmap? = - // when (val data = icon.drawable) { - // is BitmapDrawable -> data.bitmap - // else -> null - // } - // bitmap?.let { - // newScope().launch { - // val color = - // Palette.from(it) - // .clearFilters() - // .generateAsync() - // ?.findSwatch(headerColorThresholdFun) - // ?.rgb - // ?: defaultHeaderTextColor - // holder.title.setTextColor(color) - // } - // } - } } diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/activity/LauncherActivity.kt b/app/src/main/kotlin/io/sweers/catchup/ui/activity/LauncherActivity.kt index 02368bcfc..ed1243b3e 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/activity/LauncherActivity.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/activity/LauncherActivity.kt @@ -23,7 +23,9 @@ class LauncherActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - startActivity(Intent(this, MainActivity::class.java)) + val newIntent = + Intent(intent).apply { setClass(this@LauncherActivity, MainActivity::class.java) } + startActivity(newIntent) finish() } } diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/activity/MainActivity.kt b/app/src/main/kotlin/io/sweers/catchup/ui/activity/MainActivity.kt index 4cc477cbe..a5107c1b7 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/activity/MainActivity.kt @@ -16,6 +16,7 @@ package io.sweers.catchup.ui.activity import android.app.Activity +import android.content.Intent import android.graphics.Color import android.os.Bundle import androidx.activity.SystemBarStyle @@ -35,6 +36,7 @@ import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.push import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.overlay.ContentWithOverlays +import com.slack.circuit.runtime.Screen import com.squareup.anvil.annotations.ContributesMultibinding import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -50,6 +52,7 @@ import io.sweers.catchup.data.LinkManager import io.sweers.catchup.home.HomeScreen import io.sweers.catchup.service.api.Service import io.sweers.catchup.service.api.ServiceMeta +import io.sweers.catchup.ui.about.AboutScreen import io.sweers.catchup.util.customtabs.CustomTabActivityHelper import javax.inject.Inject import timber.log.Timber @@ -100,7 +103,10 @@ constructor( CatchUpTheme(useDarkTheme = useDarkTheme, isDynamicColor = useDynamicTheme) { CircuitCompositionLocals(circuit) { ContentWithOverlays { - val backstack = rememberSaveableBackStack { push(HomeScreen) } + val backstack = rememberSaveableBackStack { + push(HomeScreen) + intent?.parseRoute()?.forEach(::push) + } val navigator = rememberCircuitNavigator(backstack) val intentAwareNavigator = remember(navigator) { IntentAwareNavigator(this, navigator) } rootContent.Content(intentAwareNavigator) { @@ -132,6 +138,31 @@ constructor( super.onDestroy() } + companion object { + private fun routeFor(segment: String, queryParams: Map): Screen { + return when (segment) { + "settings" -> SettingsScreen + "about" -> AboutScreen(AboutScreen.AboutScreenComponent.componentFor(queryParams["tab"])) + else -> throw IllegalArgumentException("Unknown path segment $segment") + } + } + + private fun Intent.parseRoute(): List { + // -a android.intent.action.VIEW -d "catchup://home/settings/about/?tab=changelog" + // io.sweers.catchup + return if (action == Intent.ACTION_VIEW) { + data + ?.let { uri -> + val queryParams = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) } + uri.pathSegments.mapNotNull { segment -> routeFor(segment, queryParams) } + } + .orEmpty() + } else { + emptyList() + } + } + } + @ContributesTo(AppScope::class) @Module abstract class ServiceIntegrationModule { diff --git a/app/src/main/kotlin/io/sweers/catchup/ui/activity/SettingsScreen.kt b/app/src/main/kotlin/io/sweers/catchup/ui/activity/SettingsScreen.kt index 5001933e6..d55eef5a8 100644 --- a/app/src/main/kotlin/io/sweers/catchup/ui/activity/SettingsScreen.kt +++ b/app/src/main/kotlin/io/sweers/catchup/ui/activity/SettingsScreen.kt @@ -309,7 +309,7 @@ constructor( ClickablePreference( title = stringResource(R.string.about), ) { - state.eventSink(SettingsScreen.Event.NavToScreen(AboutScreen)) + state.eventSink(SettingsScreen.Event.NavToScreen(AboutScreen())) } } diff --git a/gradle.properties b/gradle.properties index 60b998286..07c871244 100755 --- a/gradle.properties +++ b/gradle.properties @@ -60,7 +60,7 @@ slack.robolectricIVersion=4 slack.compileSdkVersion=android-34 slack.minSdkVersion=29 slack.targetSdkVersion=34 -slack.latestCompileSdkWithSources=33 +slack.latestCompileSdkWithSources=34 slack.location.slack-platform=:platform #slack.detekt.configs=config/detekt/detekt.yml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a917f9d1b..4bb96f01f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -accompanist = "0.31.6-rc" +accompanist = "0.33.0-alpha" agp = "8.2.0-alpha15" androidx-activity = "1.8.0-alpha06" androidx-core = "1.12.0-rc01" @@ -38,7 +38,7 @@ okhttp = "5.0.0-alpha.11" preferences = "1.2.1" retrofit = "2.9.0" sortDependencies = "0.4" -sgp = "0.10.3" +sgp = "0.10.4" spotless = "6.20.0" sqldelight = "2.0.0" telephoto = "1.0.0-alpha02" diff --git a/libraries/compose-extensions/src/main/kotlin/dev/zacsweers/catchup/compose/Math.kt b/libraries/compose-extensions/src/main/kotlin/dev/zacsweers/catchup/compose/Math.kt new file mode 100644 index 000000000..61430a4b4 --- /dev/null +++ b/libraries/compose-extensions/src/main/kotlin/dev/zacsweers/catchup/compose/Math.kt @@ -0,0 +1,72 @@ +package dev.zacsweers.catchup.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +/** + * Interpolates between two [Offset] values using an arc motion. + * + * Given two points `start` and `stop`, the function calculates a half-circle arc that connects + * them. The returned [Offset] represents a point on that arc based on the provided `fraction`. + * + * Illustration: + * ``` + * start .-. + * / \ + * | O | <-- Arc center (midpoint between `start` and `stop`) + * \ / + * stop '-' + * ``` + * + * @param start The starting [Offset] (represents fraction = 0). + * @param stop The ending [Offset] (represents fraction = 1). + * @param fraction The fraction of the motion, ranging from 0f to 1f. + * @return The [Offset] on the arc corresponding to the provided fraction. + */ +fun arcLerp(start: Offset, stop: Offset, fraction: Float): Offset { + // Ensure fraction is within [0, 1] + val clampedFraction = fraction.coerceIn(0f, 1f) + + // Midpoint (center of the circle) + val centerX = (start.x + stop.x) / 2 + val centerY = (start.y + stop.y) / 2 + + // Radius (half the distance between start and stop) + val radiusX = (stop.x - start.x) / 2 + val radiusY = (stop.y - start.y) / 2 + + // Angle based on fraction + val theta = Math.PI * clampedFraction + + // Calculate point on the arc + val x = centerX + radiusX * sin(theta).toFloat() + val y = centerY - radiusY * cos(theta).toFloat() + + return Offset(x, y) +} + +/** @see arcLerp */ +fun arcLerp(start: IntOffset, stop: IntOffset, fraction: Float): IntOffset { + // Ensure fraction is within [0, 1] + val clampedFraction = fraction.coerceIn(0f, 1f) + + // Midpoint (center of the circle) + val centerX = (start.x + stop.x) / 2 + val centerY = (start.y + stop.y) / 2 + + // Radius (half the distance between start and stop) + val radiusX = (stop.x - start.x) / 2 + val radiusY = (stop.y - start.y) / 2 + + // Angle based on fraction + val theta = Math.PI * clampedFraction + + // Calculate point on the arc + val x = (centerX + radiusX * sin(theta)).roundToInt() + val y = (centerY - radiusY * cos(theta)).roundToInt() + + return IntOffset(x, y) +} diff --git a/services/hackernews/src/main/kotlin/io/sweers/catchup/service/hackernews/HackerNewsService.kt b/services/hackernews/src/main/kotlin/io/sweers/catchup/service/hackernews/HackerNewsService.kt index 2f6249f30..f4a4c6664 100644 --- a/services/hackernews/src/main/kotlin/io/sweers/catchup/service/hackernews/HackerNewsService.kt +++ b/services/hackernews/src/main/kotlin/io/sweers/catchup/service/hackernews/HackerNewsService.kt @@ -181,7 +181,7 @@ abstract class HackerNewsMetaModule { R.drawable.logo_hn, pagesAreNumeric = true, firstPageKey = 0, - enabled = true // HN is broken for some reason + enabled = false // HN is broken for some reason ) } }