kopia lustrzana https://github.com/rt-bishop/Look4Sat
Updated dependencies and gradle plugin, minor fixes
rodzic
e2415cda4b
commit
a523684ffb
|
@ -46,6 +46,7 @@ dependencies {
|
|||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.composeAll)
|
||||
|
||||
implementation(libs.other.okhttp)
|
||||
|
|
|
@ -29,7 +29,7 @@ import com.rtbishop.look4sat.presentation.satellites.SatellitesScreen
|
|||
import com.rtbishop.look4sat.presentation.satellites.SatellitesViewModel
|
||||
import com.rtbishop.look4sat.presentation.settings.SettingsScreen
|
||||
|
||||
sealed class Screen(var title: String, var icon: Int, var route: String) {
|
||||
private sealed class Screen(var title: String, var icon: Int, var route: String) {
|
||||
data object Main : Screen("Main", R.drawable.ic_sputnik, "main")
|
||||
data object Radar : Screen("Radar", R.drawable.ic_sputnik, "radar")
|
||||
data object Satellites : Screen("Satellites", R.drawable.ic_sputnik, "satellites")
|
||||
|
@ -61,17 +61,19 @@ private fun NavBarScreen(navToRadar: (Int, Long) -> Unit) {
|
|||
val navToPasses = { innerNavController.navigate(Screen.Passes.route) }
|
||||
Scaffold(bottomBar = { MainNavBar(navController = innerNavController) }) { innerPadding ->
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
NavHost(innerNavController, startDestination = Screen.Passes.route) {
|
||||
NavHost(navController = innerNavController, startDestination = Screen.Passes.route) {
|
||||
composable(Screen.Satellites.route) {
|
||||
val viewModel = viewModel(
|
||||
SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
|
||||
modelClass = SatellitesViewModel::class.java,
|
||||
factory = SatellitesViewModel.Factory
|
||||
)
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
SatellitesScreen(uiState, navToPasses)
|
||||
}
|
||||
composable(Screen.Passes.route) {
|
||||
val viewModel = viewModel(
|
||||
PassesViewModel::class.java, factory = PassesViewModel.Factory
|
||||
modelClass = PassesViewModel::class.java,
|
||||
factory = PassesViewModel.Factory
|
||||
)
|
||||
val uiState = viewModel.uiState.value
|
||||
PassesScreen(uiState, navToRadar)
|
||||
|
@ -91,18 +93,18 @@ private fun MainNavBar(navController: NavController) {
|
|||
val currentRoute = currentBackStackEntry.value?.destination?.route
|
||||
NavigationBar {
|
||||
items.forEach { item ->
|
||||
NavigationBarItem(selected = currentRoute?.contains(item.route) ?: false,
|
||||
NavigationBarItem(
|
||||
icon = { Icon(painterResource(item.icon), item.title) },
|
||||
label = { Text(item.title) },
|
||||
selected = currentRoute?.contains(item.route) ?: false,
|
||||
onClick = {
|
||||
navController.navigate(item.route) {
|
||||
navController.graph.startDestinationRoute?.let {
|
||||
popUpTo(it) { saveState = false }
|
||||
}
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = false }
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
},
|
||||
icon = { Icon(painterResource(id = item.icon), contentDescription = item.title) },
|
||||
label = { Text(item.title) })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.presentation.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
|
||||
* @param refreshing A boolean representing whether a refresh is occurring.
|
||||
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
|
||||
* @param modifier Modifiers for the indicator.
|
||||
* @param backgroundColor The color of the indicator's background.
|
||||
* @param contentColor The color of the indicator's arc and arrow.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
*/
|
||||
@Composable
|
||||
fun PullRefreshIndicator(
|
||||
refreshing: Boolean,
|
||||
state: PullRefreshState,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(backgroundColor),
|
||||
scale: Boolean = false
|
||||
) {
|
||||
val showElevation by remember(refreshing, state) {
|
||||
derivedStateOf { refreshing || state.position > 0.5f }
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(indicatorSize)
|
||||
.pullRefreshIndicatorTransform(state, scale),
|
||||
shape = spinnerShape,
|
||||
color = backgroundColor,
|
||||
shadowElevation = if (showElevation) elevation else 0.dp,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = refreshing,
|
||||
animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS),
|
||||
label = "pullToRefresh"
|
||||
) { refreshing ->
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
val spinnerSize = (arcRadius + strokeWidth).times(2)
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
color = contentColor,
|
||||
strokeWidth = strokeWidth,
|
||||
modifier = Modifier.size(spinnerSize),
|
||||
)
|
||||
} else {
|
||||
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifier.size MUST be specified.
|
||||
*/
|
||||
@Composable
|
||||
private fun CircularArrowIndicator(state: PullRefreshState, color: Color, modifier: Modifier) {
|
||||
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
|
||||
Canvas(modifier.semantics { contentDescription = "Refreshing" }) {
|
||||
val values = ArrowValues(state.progress)
|
||||
rotate(degrees = values.rotation) {
|
||||
val arcRadius = arcRadius.toPx() + strokeWidth.toPx() / 2f
|
||||
val arcBounds = Rect(
|
||||
size.center.x - arcRadius,
|
||||
size.center.y - arcRadius,
|
||||
size.center.x + arcRadius,
|
||||
size.center.y + arcRadius
|
||||
)
|
||||
drawArc(
|
||||
color = color,
|
||||
alpha = values.alpha,
|
||||
startAngle = values.startAngle,
|
||||
sweepAngle = values.endAngle - values.startAngle,
|
||||
useCenter = false,
|
||||
topLeft = arcBounds.topLeft,
|
||||
size = arcBounds.size,
|
||||
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Square)
|
||||
)
|
||||
drawArrow(path, arcBounds, color, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private class ArrowValues(
|
||||
val alpha: Float, val rotation: Float, val startAngle: Float, val endAngle: Float, val scale: Float
|
||||
)
|
||||
|
||||
private fun ArrowValues(progress: Float): ArrowValues {
|
||||
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
|
||||
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
|
||||
// Calculations based on SwipeRefreshLayout specification.
|
||||
val alpha = progress.coerceIn(0f, 1f)
|
||||
val endTrim = adjustedPercent * MAX_PROGRESS_ARC
|
||||
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
|
||||
val startAngle = rotation * 360
|
||||
val endAngle = (rotation + endTrim) * 360
|
||||
val scale = min(1f, adjustedPercent)
|
||||
|
||||
return ArrowValues(alpha, rotation, startAngle, endAngle, scale)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawArrow(arrow: Path, bounds: Rect, color: Color, values: ArrowValues) {
|
||||
arrow.reset()
|
||||
arrow.moveTo(0f, 0f) // Move to left corner
|
||||
arrow.lineTo(x = arrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
|
||||
|
||||
// Line to tip of arrow
|
||||
arrow.lineTo(x = arrowWidth.toPx() * values.scale / 2, y = arrowHeight.toPx() * values.scale)
|
||||
|
||||
val radius = min(bounds.width, bounds.height) / 2f
|
||||
val inset = arrowWidth.toPx() * values.scale / 2f
|
||||
arrow.translate(Offset(x = radius + bounds.center.x - inset, y = bounds.center.y + strokeWidth.toPx() / 2f))
|
||||
arrow.close()
|
||||
rotate(degrees = values.endAngle) {
|
||||
drawPath(path = arrow, color = color, alpha = values.alpha)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CROSSFADE_DURATION_MS = 100
|
||||
private const val MAX_PROGRESS_ARC = 0.8f
|
||||
private val indicatorSize = 40.dp
|
||||
private val spinnerShape = CircleShape
|
||||
private val arcRadius = 7.5.dp
|
||||
private val strokeWidth = 2.5.dp
|
||||
private val arrowWidth = 10.dp
|
||||
private val arrowHeight = 5.dp
|
||||
private val elevation = 6.dp
|
|
@ -1,128 +0,0 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.platform.inspectable
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
/**
|
||||
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator
|
||||
* based on the given [PullRefreshState].
|
||||
* @param state The [PullRefreshState] which determines the position of the indicator.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
*/
|
||||
fun Modifier.pullRefreshIndicatorTransform(
|
||||
state: PullRefreshState,
|
||||
scale: Boolean = false,
|
||||
) = composed(inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefreshIndicatorTransform"
|
||||
properties["state"] = state
|
||||
properties["scale"] = scale
|
||||
}) {
|
||||
var height by remember { mutableIntStateOf(0) }
|
||||
Modifier
|
||||
.onSizeChanged { height = it.height }
|
||||
.graphicsLayer {
|
||||
translationY = state.position - height
|
||||
if (scale && !state.refreshing) {
|
||||
val scaleFraction = LinearOutSlowInEasing
|
||||
.transform(state.position / state.threshold)
|
||||
.coerceIn(0f, 1f)
|
||||
scaleX = scaleFraction
|
||||
scaleY = scaleFraction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PullRefresh modifier to be used in conjunction with [PullRefreshState]. Provides a connection
|
||||
* to the nested scroll system. Based on Android's SwipeRefreshLayout.
|
||||
* @param state The [PullRefreshState] associated with this pull-to-refresh component.
|
||||
* The state will be updated by this modifier.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
|
||||
*/
|
||||
fun Modifier.pullRefresh(
|
||||
state: PullRefreshState, enabled: Boolean = true
|
||||
) = inspectable(inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["state"] = state
|
||||
properties["enabled"] = enabled
|
||||
}) {
|
||||
Modifier.pullRefresh(state::onPull, { state.onRelease() }, enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* A modifier for building pull-to-refresh components. Provides a connection to the nested scroll
|
||||
* system.
|
||||
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
|
||||
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
|
||||
* down despite being at the top of a scrollable component), whereas negative delta (swiping up) is
|
||||
* dispatched first (in case it is needed to push the indicator back up), and then whatever is not
|
||||
* consumed is passed on to the child.
|
||||
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
|
||||
* [onPull] nor [onRelease] will be invoked.
|
||||
*/
|
||||
fun Modifier.pullRefresh(
|
||||
onPull: (pullDelta: Float) -> Float, onRelease: suspend (flingVelocity: Float) -> Unit, enabled: Boolean = true
|
||||
) = inspectable(inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["onPull"] = onPull
|
||||
properties["onRelease"] = onRelease
|
||||
properties["enabled"] = enabled
|
||||
}) {
|
||||
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
|
||||
}
|
||||
|
||||
private class PullRefreshNestedScrollConnection(
|
||||
private val onPull: (pullDelta: Float) -> Float,
|
||||
private val onRelease: suspend (flingVelocity: Float) -> Unit,
|
||||
private val enabled: Boolean
|
||||
) : NestedScrollConnection {
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
onRelease(available.y)
|
||||
return Velocity.Zero
|
||||
}
|
||||
}
|
|
@ -1,184 +0,0 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Creates a [PullRefreshState] that is remembered across compositions.
|
||||
* Changes to [refreshing] will result in [PullRefreshState] being updated.
|
||||
* @param refreshing A boolean representing whether a refresh is currently occurring.
|
||||
* @param onRefresh The function to be called to trigger a refresh.
|
||||
* @param refreshThreshold The threshold below which, if a release
|
||||
* occurs, [onRefresh] will be called.
|
||||
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
|
||||
* offset corresponds to the position of the bottom of the indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPullRefreshState(
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
|
||||
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
|
||||
): PullRefreshState {
|
||||
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
|
||||
val scope = rememberCoroutineScope()
|
||||
val onRefreshState = rememberUpdatedState(onRefresh)
|
||||
val thresholdPx: Float
|
||||
val refreshingOffsetPx: Float
|
||||
with(LocalDensity.current) {
|
||||
thresholdPx = refreshThreshold.toPx()
|
||||
refreshingOffsetPx = refreshingOffset.toPx()
|
||||
}
|
||||
// refreshThreshold and refreshingOffset should not be changed after instantiation, so any
|
||||
// changes to these values are ignored.
|
||||
val state = remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) }
|
||||
SideEffect { state.setRefreshing(refreshing) }
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
|
||||
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* Provides [progress], a float representing how far the user has pulled as a percentage of the
|
||||
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
|
||||
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
|
||||
*
|
||||
* Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
|
||||
* pull-to-refresh behaviour with a custom indicator.
|
||||
*
|
||||
* Should be created using [rememberPullRefreshState].
|
||||
*/
|
||||
class PullRefreshState internal constructor(
|
||||
private val animationScope: CoroutineScope,
|
||||
private val onRefreshState: State<() -> Unit>,
|
||||
private val refreshingOffset: Float,
|
||||
internal val threshold: Float
|
||||
) {
|
||||
/**
|
||||
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
|
||||
*
|
||||
* If the component has not been pulled at all, progress is zero. If the pull has reached
|
||||
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
|
||||
* gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
|
||||
* two times the refreshThreshold.
|
||||
*/
|
||||
val progress get() = adjustedDistancePulled / threshold
|
||||
|
||||
internal val refreshing get() = _refreshing
|
||||
internal val position get() = _position
|
||||
|
||||
private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER }
|
||||
|
||||
private var _refreshing by mutableStateOf(false)
|
||||
private var _position by mutableFloatStateOf(0f)
|
||||
private var distancePulled by mutableFloatStateOf(0f)
|
||||
|
||||
internal fun onPull(pullDelta: Float): Float {
|
||||
if (this._refreshing) return 0f // Already refreshing, do nothing.
|
||||
|
||||
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
|
||||
val dragConsumed = newOffset - distancePulled
|
||||
distancePulled = newOffset
|
||||
_position = calculateIndicatorPosition()
|
||||
return dragConsumed
|
||||
}
|
||||
|
||||
internal fun onRelease() {
|
||||
if (!this._refreshing) {
|
||||
if (adjustedDistancePulled > threshold) {
|
||||
onRefreshState.value()
|
||||
} else {
|
||||
animateIndicatorTo(0f)
|
||||
}
|
||||
}
|
||||
distancePulled = 0f
|
||||
}
|
||||
|
||||
internal fun setRefreshing(refreshing: Boolean) {
|
||||
if (this._refreshing != refreshing) {
|
||||
this._refreshing = refreshing
|
||||
this.distancePulled = 0f
|
||||
animateIndicatorTo(if (refreshing) refreshingOffset else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateIndicatorTo(offset: Float) = animationScope.launch {
|
||||
animate(initialValue = _position, targetValue = offset) { value, _ ->
|
||||
_position = value
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateIndicatorPosition(): Float = when {
|
||||
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||
adjustedDistancePulled <= threshold -> adjustedDistancePulled
|
||||
else -> {
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
// The additional offset beyond the threshold.
|
||||
val extraOffset = threshold * tensionPercent
|
||||
threshold + extraOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default parameter values for [rememberPullRefreshState].
|
||||
*/
|
||||
object PullRefreshDefaults {
|
||||
/**
|
||||
* If the indicator is below this threshold offset when it is released, a refresh
|
||||
* will be triggered.
|
||||
*/
|
||||
val RefreshThreshold = 80.dp
|
||||
|
||||
/**
|
||||
* The offset at which the indicator should be rendered whilst a refresh is occurring.
|
||||
*/
|
||||
val RefreshingOffset = 56.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
|
||||
* is used in calculating the indicator position (when the adjusted distance pulled is less than
|
||||
* the refresh threshold, it is the indicator position, otherwise the indicator position is
|
||||
* derived from the progress).
|
||||
*/
|
||||
private const val DRAG_MULTIPLIER = 0.5f
|
|
@ -3,7 +3,6 @@ package com.rtbishop.look4sat.presentation.passes
|
|||
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
|
||||
|
@ -15,11 +14,15 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -38,12 +41,8 @@ import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|||
import com.rtbishop.look4sat.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.presentation.components.CardIcon
|
||||
import com.rtbishop.look4sat.presentation.components.NextPassRow
|
||||
import com.rtbishop.look4sat.presentation.components.PullRefreshIndicator
|
||||
import com.rtbishop.look4sat.presentation.components.PullRefreshState
|
||||
import com.rtbishop.look4sat.presentation.components.TimerBar
|
||||
import com.rtbishop.look4sat.presentation.components.TimerRow
|
||||
import com.rtbishop.look4sat.presentation.components.pullRefresh
|
||||
import com.rtbishop.look4sat.presentation.components.rememberPullRefreshState
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
@ -54,7 +53,6 @@ private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
|||
@Composable
|
||||
fun PassesScreen(uiState: PassesState, navToRadar: (Int, Long) -> Unit) {
|
||||
val refreshPasses = { uiState.takeAction(PassesAction.RefreshPasses) }
|
||||
val refreshState = rememberPullRefreshState(refreshing = uiState.isRefreshing, onRefresh = refreshPasses)
|
||||
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
|
||||
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
|
||||
if (uiState.isPassesDialogShown) {
|
||||
|
@ -74,31 +72,47 @@ fun PassesScreen(uiState: PassesState, navToRadar: (Int, Long) -> Unit) {
|
|||
CardIcon(onClick = { showRadiosDialog() }, iconId = R.drawable.ic_satellite)
|
||||
}
|
||||
NextPassRow(pass = uiState.nextPass)
|
||||
PassesList(refreshState, uiState.isRefreshing, uiState.itemsList, navToRadar)
|
||||
PassesList(uiState.isRefreshing, uiState.itemsList, navToRadar, refreshPasses)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PassesList(
|
||||
refreshState: PullRefreshState,
|
||||
isRefreshing: Boolean,
|
||||
passes: List<OrbitalPass>,
|
||||
navToRadar: (Int, Long) -> Unit
|
||||
navToRadar: (Int, Long) -> Unit,
|
||||
refreshPasses: () -> Unit
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colorScheme.primary
|
||||
val refreshState = rememberPullToRefreshState()
|
||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.pullRefresh(refreshState), contentAlignment = Alignment.TopCenter) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
state = refreshState,
|
||||
onRefresh = refreshPasses,
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = refreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||
PassItem(pass = pass, navToRadar = navToRadar, modifier = Modifier.animateItem())
|
||||
PassItem(
|
||||
pass = pass,
|
||||
navToRadar = navToRadar,
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(refreshing = isRefreshing, state = refreshState, backgroundColor = backgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeepSpacePassPreview() {
|
||||
|
@ -230,6 +244,7 @@ private fun PassItem(pass: OrbitalPass, navToRadar: (Int, Long) -> Unit, modifie
|
|||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { if (pass.isDeepSpace) 100f else pass.progress },
|
||||
drawStopIndicator = {},
|
||||
modifier = modifier.fillMaxWidth(0.75f)
|
||||
)
|
||||
Text(
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
[versions]
|
||||
android-gradle-plugin = "8.7.2"
|
||||
google-ksp = "2.0.20-1.0.25"
|
||||
android-gradle-plugin = "8.7.3"
|
||||
google-ksp = "2.0.21-1.0.26"
|
||||
kotlin = "2.0.21"
|
||||
|
||||
androidx-core-ktx = "1.15.0"
|
||||
androidx-core-splashscreen = "1.0.1"
|
||||
androidx-room = "2.6.1"
|
||||
|
||||
composeBom = "2024.10.01"
|
||||
compose-bom = "2024.11.00"
|
||||
compose-activity = "1.9.3"
|
||||
compose-lifecycle = "2.8.7"
|
||||
compose-material3 = "1.3.1"
|
||||
compose-navigation = "2.8.3"
|
||||
compose-navigation = "2.8.4"
|
||||
|
||||
other-coroutines = "1.9.0"
|
||||
other-json = "20240303"
|
||||
|
@ -31,14 +30,14 @@ androidx-room = { module = "androidx.room:room-ktx", version.ref = "androidx-roo
|
|||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
|
||||
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
|
||||
compose-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
|
||||
compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" }
|
||||
compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compose-lifecycle" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
|
||||
compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" }
|
||||
compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" }
|
||||
|
||||
|
@ -67,9 +66,9 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|||
|
||||
[bundles]
|
||||
composeAll = [
|
||||
"compose-bom", "compose-animation", "compose-runtime", "compose-tooling", "compose-activity",
|
||||
"compose-animation", "compose-runtime", "compose-tooling", "compose-activity",
|
||||
"compose-lifecycle", "compose-material3", "compose-navigation", "compose-viewmodel"
|
||||
]
|
||||
composeDebug = ["compose-bom", "compose-debug-manifest", "compose-debug-tooling"]
|
||||
composeDebug = ["compose-debug-manifest", "compose-debug-tooling"]
|
||||
unitTest = ["test-coroutines", "test-junit4", "test-mockk"]
|
||||
androidTest = ["androidTest-junit", "androidTest-espresso"]
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
|
@ -7,6 +5,9 @@ pluginManagement {
|
|||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version ("0.8.0")
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
|
@ -14,6 +15,5 @@ dependencyResolutionManagement {
|
|||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Look4Sat"
|
||||
include(":app", ":data", ":domain")
|
||||
|
|
Ładowanie…
Reference in New Issue