diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt index 4e0046532ac..0c7cdc48c15 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt @@ -78,6 +78,7 @@ import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapOrientationA import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapPositionAware import de.westnordost.streetcomplete.screens.main.bottom_sheet.MoveNodeFragment import de.westnordost.streetcomplete.screens.main.bottom_sheet.SplitWayFragment +import de.westnordost.streetcomplete.screens.main.controls.LocationState import de.westnordost.streetcomplete.screens.main.controls.LocationStateButton import de.westnordost.streetcomplete.screens.main.controls.MainMenuDialog import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryFragment @@ -668,7 +669,7 @@ class MainFragment : @SuppressLint("MissingPermission") private fun onLocationIsEnabled() { - binding.gpsTrackingButton.state = LocationStateButton.State.SEARCHING + binding.gpsTrackingButton.state = LocationState.SEARCHING mapFragment!!.startPositionTracking() setIsFollowingPosition(wasFollowingPosition ?: true) @@ -677,8 +678,8 @@ class MainFragment : private fun onLocationIsDisabled() { binding.gpsTrackingButton.state = when { - requireContext().hasLocationPermission -> LocationStateButton.State.ALLOWED - else -> LocationStateButton.State.DENIED + requireContext().hasLocationPermission -> LocationState.ALLOWED + else -> LocationState.DENIED } binding.gpsTrackingButton.isNavigation = false binding.locationPointerPin.visibility = View.GONE @@ -688,7 +689,7 @@ class MainFragment : private fun onLocationChanged(location: Location) { viewLifecycleScope.launch { - binding.gpsTrackingButton.state = LocationStateButton.State.UPDATING + binding.gpsTrackingButton.state = LocationState.UPDATING updateLocationPointerPin() } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt new file mode 100644 index 00000000000..1192999d879 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt @@ -0,0 +1,17 @@ +package de.westnordost.streetcomplete.screens.main.controls + +/** State of location updates */ +enum class LocationState { + /** user declined to give this app access to location */ + DENIED, + /** user allowed this app to access location (but location disabled) */ + ALLOWED, + /** location service is turned on (but no location request active) */ + ENABLED, + /** requested location updates and waiting for first fix */ + SEARCHING, + /** receiving location updates */ + UPDATING; + + val isEnabled: Boolean get() = ordinal >= ENABLED.ordinal +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt index ed4aaf0a2fa..0360b96b697 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt @@ -23,12 +23,12 @@ class LocationStateButton @JvmOverloads constructor( defStyle: Int = 0 ) : AppCompatImageButton(context, attrs, defStyle) { - var state: State - get() = _state ?: State.DENIED + var state: LocationState + get() = _state ?: LocationState.DENIED set(value) { _state = value } // this is necessary because state is accessed before it is initialized (in constructor of super) - private var _state: State? = null + private var _state: LocationState? = null set(value) { if (field != value) { field = value @@ -55,12 +55,12 @@ class LocationStateButton @JvmOverloads constructor( a.recycle() } - private fun determineStateFrom(a: TypedArray): State = when { - a.getBoolean(R.styleable.LocationStateButton_state_updating, false) -> State.UPDATING - a.getBoolean(R.styleable.LocationStateButton_state_searching, false) -> State.SEARCHING - a.getBoolean(R.styleable.LocationStateButton_state_enabled, false) -> State.ENABLED - a.getBoolean(R.styleable.LocationStateButton_state_allowed, false) -> State.ALLOWED - else -> State.DENIED + private fun determineStateFrom(a: TypedArray): LocationState = when { + a.getBoolean(R.styleable.LocationStateButton_state_updating, false) -> LocationState.UPDATING + a.getBoolean(R.styleable.LocationStateButton_state_searching, false) -> LocationState.SEARCHING + a.getBoolean(R.styleable.LocationStateButton_state_enabled, false) -> LocationState.ENABLED + a.getBoolean(R.styleable.LocationStateButton_state_allowed, false) -> LocationState.ALLOWED + else -> LocationState.DENIED } override fun drawableStateChanged() { @@ -103,18 +103,7 @@ class LocationStateButton @JvmOverloads constructor( } } - enum class State { - DENIED, // user declined to give this app access to location - ALLOWED, // user allowed this app to access location (but location disabled) - ENABLED, // location service is turned on (but no location request active) - SEARCHING, // requested location updates and waiting for first fix - UPDATING; - - // receiving location updates - val isEnabled: Boolean get() = ordinal >= ENABLED.ordinal - } - - private val State.styleableAttributes: List get() = + private val LocationState.styleableAttributes: List get() = listOf( R.attr.state_allowed, R.attr.state_enabled, diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt new file mode 100644 index 00000000000..063ca8202a4 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt @@ -0,0 +1,81 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.westnordost.streetcomplete.R +import kotlinx.coroutines.delay + +@Composable +fun LocationStateButton( + onClick: () -> Unit, + state: LocationState, + modifier: Modifier = Modifier, + isNavigationMode: Boolean = false, + isFollowing: Boolean = false, + enabled: Boolean = true +) { + var iconResource by remember(state) { mutableStateOf(getIcon(state, isNavigationMode)) } + + MapButton( + onClick = onClick, + modifier = modifier, + enabled = enabled + ) { + LaunchedEffect(state) { + if (state == LocationState.SEARCHING) { + while (true) { + delay(750) + iconResource = getIcon(LocationState.UPDATING, isNavigationMode) + delay(750) + iconResource = getIcon(LocationState.ENABLED, isNavigationMode) + } + } + } + Icon( + painter = painterResource(iconResource), + contentDescription = stringResource(R.string.map_btn_gps_tracking), + tint = if (isFollowing) MaterialTheme.colors.secondary else Color.Black + ) + } +} + +private fun getIcon(state: LocationState, isNavigationMode: Boolean) = when (state) { + LocationState.DENIED, + LocationState.ALLOWED -> + R.drawable.ic_location_disabled_24dp + LocationState.ENABLED, + LocationState.SEARCHING -> + if (isNavigationMode) R.drawable.ic_location_navigation_no_location_24dp + else R.drawable.ic_location_no_location_24dp + LocationState.UPDATING -> + if (isNavigationMode) R.drawable.ic_location_navigation_24dp + else R.drawable.ic_location_24dp +} + +@Preview +@Composable +private fun PreviewLocationButton() { + Column { + for (state in LocationState.entries) { + Row { + LocationStateButton(onClick = {}, state = state) + LocationStateButton(onClick = {}, state = state, isNavigationMode = true) + LocationStateButton(onClick = {}, state = state, isFollowing = true) + LocationStateButton(onClick = {}, state = state, isNavigationMode = true, isFollowing = true) + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt new file mode 100644 index 00000000000..bce6cebefc4 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt @@ -0,0 +1,44 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MapButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable() (BoxScope.() -> Unit) +) { + Surface( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = CircleShape, + color = Color.White, + elevation = 4.dp + ) { + Box(Modifier.padding(16.dp), content = content) + } +} + +@Preview +@Composable +private fun PreviewMapButton() { + MapButton(onClick = {}) { + Icon(painterResource(R.drawable.ic_location_24dp), null) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/CheckmarkCirclePainter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/CheckmarkCirclePainter.kt new file mode 100644 index 00000000000..7cbe6765233 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/CheckmarkCirclePainter.kt @@ -0,0 +1,35 @@ +package de.westnordost.streetcomplete.screens.tutorial + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.Path +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.theme.LeafGreen +import de.westnordost.streetcomplete.ui.util.svgPath + +@Composable +fun checkmarkCirclePainter(progress: Float): VectorPainter = rememberVectorPainter( + defaultWidth = 128.dp, + defaultHeight = 128.dp, + viewportWidth = 128f, + viewportHeight = 128f, + autoMirror = false +) { _, _ -> + Path( + pathData = circlePath, + strokeLineWidth = 12f, + stroke = SolidColor(LeafGreen), + trimPathEnd = (progress * 3f/2).coerceIn(0f, 1f) + ) + Path( + pathData = checkmarkPath, + strokeLineWidth = 12f, + stroke = SolidColor(LeafGreen), + trimPathEnd = ((progress - 2f/3) * 3f).coerceIn(0f, 1f) + ) +} + +private val circlePath = svgPath("m122,64a58,58 0,0 1,-58 58,58 58,0 0,1 -58,-58 58,58 0,0 1,58 -58,58 58,0 0,1 58,58z") +private val checkmarkPath = svgPath("m28.459,67.862c7.344,4.501 19.241,13.97 27.571,23.732 11.064,-20.587 27.756,-39.206 44.333,-55.458") diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt new file mode 100644 index 00000000000..32c8ef3e564 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt @@ -0,0 +1,244 @@ +package de.westnordost.streetcomplete.screens.tutorial + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.absolutePadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.screens.main.controls.LocationState +import de.westnordost.streetcomplete.screens.main.controls.LocationStateButton +import de.westnordost.streetcomplete.ui.common.Pin +import de.westnordost.streetcomplete.ui.theme.headlineLarge +import de.westnordost.streetcomplete.ui.theme.titleLarge +import kotlinx.coroutines.launch + +/** Shows a short tutorial for first-time users */ +@Composable +fun IntroTutorialScreen( + onFinished: () -> Unit, +) { + TutorialScreen( + pageCount = 4, + onFinished = onFinished, + illustration = { page -> + IntroTutorialIllustration(page) + } + ) { page -> + Column( + modifier = Modifier.fillMaxSize(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (page) { + 0 -> IntroTutorialStep0Text() + 1 -> IntroTutorialStep1Text() + 2 -> IntroTutorialStep2Text() + 3 -> IntroTutorialStep3Text() + } + } + } +} + +@Composable +private fun BoxScope.IntroTutorialIllustration( + page: Int +) { + val mapZoom = remember { Animatable(0f) } + val button = remember { Animatable(0f) } + val pin1 = remember { Animatable(0f) } + val pin2 = remember { Animatable(0f) } + val pin3 = remember { Animatable(0f) } + val checkmark = remember { Animatable(0f) } + + LaunchedEffect(page) { + // map is zoomed in and magnifier is zoomed out and faded out on page > 0 + launch { mapZoom.animateTo(if (page == 0) 0f else 1f, tween(800)) } + + // show button on page 1 and 2 (appear with delay) + if (page in 1..2) { + launch { button.animateTo(1f, tween(400, 400)) } + } else { + launch { button.animateTo(0f, tween(400)) } + } + + // drop pins on page 2 + launch { pin1.animateTo(if (page == 2) 1f else 0f, tween(400, 400)) } + launch { pin2.animateTo(if (page == 2) 1f else 0f, tween(400, 600)) } + launch { pin3.animateTo(if (page == 2) 1f else 0f, tween(400, 800)) } + + // checkmark on page 3 + if (page == 3) { + checkmark.animateTo(1f, tween(1200, 1200)) + } else { + checkmark.animateTo(0f, tween(400)) + } + } + + Box(contentAlignment = Alignment.TopStart) { + Box( + Modifier + .size(width = 226.dp, height = 222.dp) + .graphicsLayer { + val scale = 1f + mapZoom.value * 0.5f + scaleX = scale + scaleY = scale + rotationX = mapZoom.value * 50f + } + ) { + Image( + painter = painterResource(R.drawable.logo_osm_map), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + Image( + painter = painterResource(R.drawable.logo_osm_map_lighting), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .alpha(1f - mapZoom.value) + ) + } + + val pinDropHeight = 200.dp + Pin( + iconPainter = painterResource(R.drawable.ic_quest_traffic_lights), + modifier = Modifier + .absolutePadding(left = 0.dp, top = 25.dp) + .graphicsLayer { + alpha = pin1.value + translationY = -(1f - pin1.value) * pinDropHeight.toPx() + } + ) + Pin( + iconPainter = painterResource(R.drawable.ic_quest_street), + modifier = Modifier + .absolutePadding(left = 45.dp, top = 110.dp) + .graphicsLayer { + alpha = pin2.value + translationY = -(1f - pin2.value) * pinDropHeight.toPx() + } + ) + Pin( + iconPainter = painterResource(R.drawable.ic_quest_recycling), + modifier = Modifier + .absolutePadding(left = 160.dp, top = 70.dp) + .graphicsLayer { + alpha = pin3.value + translationY = -(1f - pin3.value) * pinDropHeight.toPx() + } + ) + + LocationStateButton( + onClick = {}, + state = if (page == 1) LocationState.SEARCHING else LocationState.UPDATING, + modifier = Modifier + .absoluteOffset(150.dp, 150.dp) + .alpha(button.value) + ) + + Image( + painter = painterResource(R.drawable.logo_osm_magnifier), + contentDescription = null, + modifier = Modifier + .size(225.dp) + .absolutePadding(left = 15.dp, top = 15.dp) + .graphicsLayer { + val scale = 1f + mapZoom.value * 5f + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.67f, 0.33f) + alpha = 1f - mapZoom.value + } + ) + } + + Image( + painter = checkmarkCirclePainter(checkmark.value), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) +} + +@Composable +private fun IntroTutorialStep0Text() { + Text( + text = stringResource(R.string.tutorial_welcome_to_osm), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.tutorial_welcome_to_osm_subtitle), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp) + ) +} + +@Composable +private fun IntroTutorialStep1Text() { + Text( + text = stringResource(R.string.tutorial_intro), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.no_location_permission_warning), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp) + ) +} + +@Composable +private fun IntroTutorialStep2Text() { + Text( + text = stringResource(R.string.tutorial_solving_quests), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun IntroTutorialStep3Text() { + Text( + text = stringResource(R.string.tutorial_stay_safe), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.tutorial_happy_mapping), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp) + ) +} + +@Preview(device = Devices.NEXUS_5) // darn small device +@PreviewScreenSizes +@Composable +private fun PreviewIntroTutorialScreen() { + IntroTutorialScreen {} +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlayPainter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlayPainter.kt new file mode 100644 index 00000000000..c0842955180 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlayPainter.kt @@ -0,0 +1,93 @@ +package de.westnordost.streetcomplete.screens.tutorial + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.Group +import androidx.compose.ui.graphics.vector.Path +import androidx.compose.ui.graphics.vector.VectorComposable +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.util.svgPath +import kotlin.math.PI +import kotlin.math.cos + +@Composable +fun overlayPainter(progress: Float): VectorPainter = rememberMapOverlayPainter { + val translate = -(1f - progress.coerceIn(0f, 1f)) * 317f + Group( + clipPathData = clipPath, + translationX = translate, + translationY = translate, + ) { + Group( + translationX = -translate, + translationY = -translate, + ) { + Path( + pathData = way1Path, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = 8f, + stroke = SolidColor(Color(0xffeebd0d)) + ) + Path( + pathData = way2Path, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = 8f, + stroke = SolidColor(Color(0xffff0000)) + ) + Path( + pathData = way3Path, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = 8f, + stroke = SolidColor(Color(0xff1a87e6)) + ) + } + } +} + +@Composable +fun overlayEditHighlightedPainter(progress: Float): VectorPainter = rememberMapOverlayPainter { + val breathing = -cos(progress * 2 * PI) / 2.0 + 0.5 // 0..1 + Path( + pathData = way2Path, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = ((breathing + 1) * 8).toFloat(), // 8..16 + stroke = SolidColor(Color(0xffD14000)), + strokeAlpha = ((1 - breathing) * 0.5 + 0.5).toFloat() // 1 .. 0.5 + ) +} + +@Composable +fun overlayEditDonePainter(progress: Float): VectorPainter = rememberMapOverlayPainter { + Path( + pathData = way2Path, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = 8 + (1 - progress.coerceIn(0f, 1f)) * 16, + stroke = SolidColor(Color(0xff10C1B8)), + strokeAlpha = progress.coerceIn(0f, 1f) + ) +} + +@Composable +private fun rememberMapOverlayPainter(content: @Composable @VectorComposable () -> Unit) = + rememberVectorPainter( + defaultWidth = 226.dp, + defaultHeight = 222.dp, + viewportWidth = 226f, + viewportHeight = 222f, + autoMirror = false + ) { _, _ -> content() } + +private val clipPath = svgPath("M-111,111 l317,317 l317,-317 l-317,-317z") +private val way1Path = svgPath("m48.72,10.05 l-8.5,28.23 17.99,6.25 7.75,36.23 -20.99,22.24 8.99,10.49") +private val way2Path = svgPath("m53.97,113.51 l-11.99,11.49 0.5,4.5 20.24,24.49 13.99,-6.75 20.49,18.49 -10.49,28.23 10.24,8.5") +private val way3Path = svgPath("M94.19,215.44 l2.5,-13.24 l12.49,-27.73 10.99,-7 27.48,15.74 20.49,-3.75 -0.25,-15.74 -10.24,-6 12.74,-26.23 5.75,-3.75 38.73,-9.99") diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt index ae46c7cb505..40baab9125a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt @@ -1,236 +1,26 @@ package de.westnordost.streetcomplete.screens.tutorial -import android.animation.TimeAnimator -import android.annotation.SuppressLint -import android.content.pm.ActivityInfo -import android.graphics.drawable.AnimatedVectorDrawable import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.AnticipateInterpolator -import android.view.animation.LinearInterpolator -import androidx.core.view.isInvisible +import android.view.ViewGroup +import androidx.compose.material.Surface import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.databinding.FragmentOverlaysTutorialBinding -import de.westnordost.streetcomplete.util.ktx.dpToPx -import de.westnordost.streetcomplete.util.ktx.pxToDp -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope -import de.westnordost.streetcomplete.util.viewBinding -import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import de.westnordost.streetcomplete.ui.util.composableContent -class OverlaysTutorialFragment : Fragment(R.layout.fragment_overlays_tutorial) { - - private var currentPage: Int = 0 - - private var shineAnimation: TimeAnimator? = null - - private val binding by viewBinding(FragmentOverlaysTutorialBinding::bind) +class OverlaysTutorialFragment : Fragment() { interface Listener { fun onOverlaysTutorialFinished() } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener - @SuppressLint("SourceLockedOrientationActivity") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.respectSystemInsets() - updateIndicatorDots() - enableNextButton() - - val anim = TimeAnimator() - anim.setTimeListener { _, _, deltaTime -> - binding.shineView1.rotation += deltaTime / 25f - binding.shineView2.rotation -= deltaTime / 50f - } - anim.start() - shineAnimation = anim - } - - override fun onDestroyView() { - super.onDestroyView() - shineAnimation?.cancel() - } - - override fun onDestroy() { - super.onDestroy() - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - - private fun nextStep() { - disableNextButton() - when (currentPage) { - 0 -> { - currentPage = 1 - step1Transition() - } - 1 -> { - currentPage = 2 - step2Transition() - } - MAX_PAGE_INDEX -> { - listener?.onOverlaysTutorialFinished() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + composableContent { + Surface { + OverlaysTutorialScreen( + onFinished = { listener?.onOverlaysTutorialFinished() } + ) } } - } - - private fun disableNextButton() { - binding.nextButton.setOnClickListener(null) - binding.nextButton.isClickable = false - } - - private fun enableNextButton() { - binding.nextButton.isClickable = true - binding.nextButton.setOnClickListener { nextStep() } - } - - private fun step1Transition() = viewLifecycleScope.launch { - val ctx = requireContext() - - updateIndicatorDots() - - disappearText(binding.tutorialStepIntro) - - // "explode" overlay button - binding.overlaysButton.animate() - .setInterpolator(AnticipateInterpolator()) - .setDuration(450) - .alpha(0f) - .scaleX(3f) - .scaleY(3f) - .start() - - listOf(binding.shineView1, binding.shineView2).forEach { - it.animate() - .setDuration(250) - .alpha(0f) - .start() - } - - // reveal paint roller "behind" the button - binding.paintRollerView.isInvisible = false - binding.paintRollerView.rotation = -15f - binding.paintRollerView.scaleX = 0.1f - binding.paintRollerView.scaleY = 0.1f - binding.paintRollerView.translationX = ctx.resources.dpToPx(132) - binding.paintRollerView.translationY = ctx.resources.dpToPx(66) - - delay(300) - - // move paint roller to start position - binding.paintRollerView.animate() - .setInterpolator(AccelerateDecelerateInterpolator()) - .setDuration(700) - .translationX(-binding.paintRollerView.width / 4f) - .translationY(-binding.paintRollerView.height / 4f) - .rotation(-45f) - .scaleX(1f) - .scaleY(1f) - .start() - - delay(700) - - // move the paint roller from left to right - (binding.paintRollerView.drawable as? AnimatedVectorDrawable)?.start() - - binding.paintRollerView.animate() - .setInterpolator(LinearInterpolator()) - .setDuration(1000) - .translationX(binding.paintRollerView.width.toFloat()) - .translationY(binding.paintRollerView.height.toFloat()) - .start() - - // reveal overlay colors - binding.overlayImageView.isInvisible = false - (binding.overlayImageView.drawable as? AnimatedVectorDrawable)?.start() - - delay(200) - binding.overlayIcon1.isInvisible = false - - delay(400) - binding.overlayIcon2.isInvisible = false - - appearText(binding.tutorialStepDisplay) - - enableNextButton() - } - - private fun step2Transition() = viewLifecycleScope.launch { - val ctx = requireContext() - - updateIndicatorDots() - - binding.nextButton.setText(R.string.letsgo) - - disappearText(binding.tutorialStepDisplay) - - delay(400) - - binding.mapImageContainer.animate() - .setInterpolator(AccelerateDecelerateInterpolator()) - .setDuration(900) - .scaleX(2f) - .scaleY(2f) - .rotation(-15f) - .translationX(ctx.resources.dpToPx(+60)) - .translationY(ctx.resources.dpToPx(-120)) - .start() - - binding.overlaySelectedImageView.isInvisible = false - (binding.overlaySelectedImageView.drawable as? AnimatedVectorDrawable)?.start() - - delay(600) - - appearText(binding.tutorialStepEdit) - - delay(3000) - - binding.overlaySelectedImageView.setImageResource(R.drawable.overlay_osm_map_edit_done_animated) - (binding.overlaySelectedImageView.drawable as? AnimatedVectorDrawable)?.start() - - enableNextButton() - } - - private fun appearText(view: View) { - view.translationY = view.resources.pxToDp(-100) - view.animate() - .withStartAction { view.visibility = View.VISIBLE } - .setDuration(300) - .alpha(1f) - .translationY(0f) - .start() - } - - private fun disappearText(view: View) { - view.animate() - .setDuration(300) - .alpha(0f) - .translationY(view.resources.pxToDp(100)) - .withEndAction { view.visibility = View.GONE } - .start() - } - - private fun updateIndicatorDots() { - listOf(binding.dot1, binding.dot2, binding.dot3).forEachIndexed { index, dot -> - dot.setImageResource( - if (currentPage == index) { - R.drawable.indicator_dot_selected - } else { - R.drawable.indicator_dot_default - } - ) - } - } - - companion object { - private const val MAX_PAGE_INDEX = 2 - } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt new file mode 100644 index 00000000000..73e4dc649b6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt @@ -0,0 +1,260 @@ +package de.westnordost.streetcomplete.screens.tutorial + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.screens.user.achievements.AnimatedTadaShine +import de.westnordost.streetcomplete.screens.main.controls.MapButton +import de.westnordost.streetcomplete.ui.theme.headlineLarge +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun OverlaysTutorialScreen( + onFinished: () -> Unit, +) { + TutorialScreen( + pageCount = 3, + onFinished = onFinished, + illustration = { page -> + OverlaysTutorialIllustration(page) + }, + ) { page -> + Column( + modifier = Modifier.fillMaxSize(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (page) { + 0 -> OverlaysTutorialStepIntroText() + 1 -> OverlaysTutorialStepDisplayText() + 2 -> OverlaysTutorialStepEditText() + } + } + } +} + +private enum class ShowEdit { No, Selected, Done } + +@Composable +private fun BoxScope.OverlaysTutorialIllustration( + page: Int +) { + val density = LocalDensity.current.density + val paintRollerPosition = remember { Animatable(0f) } + val paintRollerAlpha = remember { Animatable(0f) } + val overlayPaint = remember { Animatable(0f) } + val button = remember { Animatable(0f) } + val mapZoom = remember { Animatable(0f) } + var showSelection by remember { mutableStateOf(ShowEdit.No) } + val showEdit = remember { Animatable(0f) } + LaunchedEffect(page) { + // button only visible on page 0 + launch { button.animateTo(if (page == 0) 0f else 1f, tween(450)) } + + // paint roller rolls from the top left to the bottom right + if (page == 1) { + launch { + paintRollerAlpha.animateTo(1f, tween(300, 300)) + paintRollerAlpha.animateTo(0f, tween(300, 450)) + } + launch { paintRollerPosition.animateTo(1f, tween(900, 300, LinearEasing)) } + } else { + launch { paintRollerAlpha.animateTo(0f, tween(300)) } + launch { paintRollerPosition.animateTo(0f, tween(300, 300)) } + } + // overlay paint is visible on pages > 0 + if (page > 0) { + launch { overlayPaint.animateTo(1f, tween(900, 600, LinearEasing)) } + } else { + launch { overlayPaint.animateTo(0f, tween(600)) } + } + // map zooms in on page 2, shows selection etc. + if (page == 2) { + showSelection = ShowEdit.Selected + mapZoom.animateTo(1f, tween(900)) + delay(3600) + showSelection = ShowEdit.Done + showEdit.animateTo(1f, tween(450)) + } else { + showSelection = ShowEdit.No + showEdit.animateTo(0f, tween(300)) + mapZoom.animateTo(0f, tween(900)) + } + } + + Box(contentAlignment = Alignment.TopStart) { + Box( + Modifier + .size(width = 226.dp, height = 222.dp) + .graphicsLayer { + val scale = 1f + mapZoom.value + scaleX = scale + scaleY = scale + rotationZ = mapZoom.value * -15f + transformOrigin = TransformOrigin(0.25f, 0.75f) + translationX = 45f * mapZoom.value * density + } + ) { + Image( + painter = painterResource(R.drawable.logo_osm_map), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + + Image( + painter = overlayPainter(overlayPaint.value), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + + if (overlayPaint.value > 0.2f) { + Icon( + painter = painterResource(R.drawable.ic_preset_fas_shopping_cart), + contentDescription = null, + tint = Color.Black, + modifier = Modifier + .size(24.dp) + .absoluteOffset(80.dp, 40.dp) + ) + } + if (overlayPaint.value > 0.7f) { + Icon( + painter = painterResource(R.drawable.ic_preset_maki_fuel), + contentDescription = null, + tint = Color.Black, + modifier = Modifier + .size(24.dp) + .absoluteOffset(180.dp, 170.dp) + ) + } + + if (showSelection == ShowEdit.Selected) { + val highlightTransition = rememberInfiniteTransition() + val highlight by highlightTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1200, 0, LinearEasing)) + ) + Image( + painter = overlayEditHighlightedPainter(highlight), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } else if (showSelection == ShowEdit.Done) { + Image( + painter = overlayEditDonePainter(showEdit.value), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + } + + val paintRollingTransition = rememberInfiniteTransition() + val paintRolling by paintRollingTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(600, 0, LinearEasing)) + ) + Image( + painter = paintRollerPainter(paintRolling), + contentDescription = null, + modifier = Modifier.graphicsLayer { + val offset = (-128 + paintRollerPosition.value * 256) * density + translationX = offset + translationY = offset + rotationZ = -45.0f + alpha = paintRollerAlpha.value + } + ) + + Box( + modifier = Modifier + .size(56.dp) + .absoluteOffset(150.dp, 150.dp) + .alpha(1f - button.value), + contentAlignment = Alignment.Center, + ) { + AnimatedTadaShine() + MapButton(onClick = {}) { + Icon( + painter = painterResource(R.drawable.ic_overlay_black_24dp), + contentDescription = null, + tint = Color.Black + ) + } + } + } +} + +@Composable +private fun OverlaysTutorialStepIntroText() { + Text( + text = stringResource(R.string.overlays_tutorial_title), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.overlays_tutorial_intro), + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(top = 24.dp), + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun OverlaysTutorialStepDisplayText() { + Text( + text = stringResource(R.string.overlays_tutorial_display), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun OverlaysTutorialStepEditText() { + Text( + text = stringResource(R.string.overlays_tutorial_edit), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + ) +} + + +@PreviewScreenSizes +@Composable +private fun PreviewOverlaysTutorialScreen() { + OverlaysTutorialScreen {} +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/PaintRollerPainter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/PaintRollerPainter.kt new file mode 100644 index 00000000000..46c83240d24 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/PaintRollerPainter.kt @@ -0,0 +1,72 @@ +package de.westnordost.streetcomplete.screens.tutorial + + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.Group +import androidx.compose.ui.graphics.vector.Path +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.util.svgPath + +@Composable +fun paintRollerPainter(progress: Float): VectorPainter = rememberVectorPainter( + defaultWidth = 234.dp, + defaultHeight = 234.dp, + viewportWidth = 26f, + viewportHeight = 26f, + autoMirror = false +) { _, _ -> + Path( + pathData = rodPath, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + stroke = SolidColor(Color(0xff788ca4)) + ) + Path( + pathData = rodShinePath, + strokeLineWidth = 0.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + stroke = SolidColor(Color(0xff9baabb)) + ) + Path( + pathData = rollerPath, + fill = SolidColor(Color(0xfffef0c1)) + ) + Group(clipPathData = rollerLintClipPath) { + Group(translationY = progress.coerceIn(0f, 1f) * 9f) { + Path( + pathData = rollerLintPath, + fill = SolidColor(Color(0xffe2cf90)) + ) + } + } + Path( + pathData = rollerShadowPath, + fill = SolidColor(Color(0xffe2cf90)) + ) + Path( + pathData = handlePath, + fill = SolidColor(Color(0xffef4431)) + ) + Path( + pathData = gripPath, + strokeLineWidth = 1f, + stroke = SolidColor(Color(0xffac3024)) + ) +} + +private val rodPath = svgPath("m12.5,14v-3l12,-2v-5h-23") +private val rodShinePath = svgPath("m12.25,13.625v-3l12,-2v-5h-23") +private val rollerPath = svgPath("M3.5,1L21.5,1A1,1 0,0 1,22.5 2L22.5,6A1,1 0,0 1,21.5 7L3.5,7A1,1 0,0 1,2.5 6L2.5,2A1,1 0,0 1,3.5 1z") +private val rollerLintPath = svgPath("m11,-8v2h1v-2zM4,-7v2h1v-2zM15,-7v2h1v-2zM7,-6v2h1v-2zM19,-6v2h1v-2zM10,-4v2h1v-2zM3,-3v2h1v-2zM21,-3v2h1v-2zM16,-2v2h1v-2zM6,-1v2h1v-2zM11,1v2h1v-2zM4,2v2h1v-2zM15,2v2h1v-2zM7,3v2h1v-2zM19,3v2h1v-2z") +private val rollerLintClipPath = svgPath("M3.5,1L21.5,1A1,1 0,0 1,22.5 2L22.5,4A1,1 0,0 1,21.5 5L3.5,5A1,1 0,0 1,2.5 4L2.5,2A1,1 0,0 1,3.5 1z") +private val rollerShadowPath = svgPath("m4.5,5h16.104c0.496,0 0.896,-0.33 0.896,-0.979v-3.021s1,0 1,1v4.104c0,0.496 -0.4,0.896 -0.896,0.896h-18.104v-1s0,-1 1,-1z") +private val handlePath = svgPath("M11.5,13L13.5,13A1,1 0,0 1,14.5 14L14.5,24A1,1 0,0 1,13.5 25L11.5,25A1,1 0,0 1,10.5 24L10.5,14A1,1 0,0 1,11.5 13z M10.375,13L14.625,13A0.875,0.875 0,0 1,15.5 13.875L15.5,14.125A0.875,0.875 0,0 1,14.625 15L10.375,15A0.875,0.875 0,0 1,9.5 14.125L9.5,13.875A0.875,0.875 0,0 1,10.375 13z") +private val gripPath = svgPath("M13.5,16v8 M11.5,16v8") diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt index b897ba97100..e7db3f13dba 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt @@ -1,223 +1,26 @@ package de.westnordost.streetcomplete.screens.tutorial -import android.annotation.SuppressLint -import android.content.pm.ActivityInfo -import android.graphics.drawable.AnimatedVectorDrawable import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.AccelerateInterpolator -import android.view.animation.BounceInterpolator +import android.view.ViewGroup +import androidx.compose.material.Surface import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.databinding.FragmentTutorialBinding -import de.westnordost.streetcomplete.screens.main.controls.LocationStateButton -import de.westnordost.streetcomplete.util.ktx.dpToPx -import de.westnordost.streetcomplete.util.ktx.pxToDp -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope -import de.westnordost.streetcomplete.util.viewBinding -import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import de.westnordost.streetcomplete.ui.util.composableContent -/** Shows a short tutorial for first-time users */ -class TutorialFragment : Fragment(R.layout.fragment_tutorial) { - - private var currentPage: Int = 0 - - private val binding by viewBinding(FragmentTutorialBinding::bind) +class TutorialFragment : Fragment() { interface Listener { fun onTutorialFinished() } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener - @SuppressLint("SourceLockedOrientationActivity") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.respectSystemInsets() - updateIndicatorDots() - enableNextButton() - } - - override fun onDestroy() { - super.onDestroy() - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - - private fun nextStep() { - disableNextButton() - when (currentPage) { - 0 -> { - currentPage = 1 - step1Transition() - } - 1 -> { - currentPage = 2 - step2Transition() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + composableContent { + Surface { + IntroTutorialScreen( + onFinished = { listener?.onTutorialFinished() } + ) } - MAX_PAGE_INDEX -> { - listener?.onTutorialFinished() - } - } - } - - private fun disableNextButton() { - binding.nextButton.setOnClickListener(null) - binding.nextButton.isClickable = false - } - - private fun enableNextButton() { - binding.nextButton.isClickable = true - binding.nextButton.setOnClickListener { nextStep() } - } - - private fun step1Transition() = viewLifecycleScope.launch { - val ctx = requireContext() - - updateIndicatorDots() - - // magnifier flies towards viewer and fades out - binding.magnifierImageView.animate() - .setDuration(500) - .setInterpolator(AccelerateInterpolator()) - .scaleX(6f).scaleY(6f) - .alpha(0f) - .start() - - // map zooms in and tilts - val mapTranslate = ctx.resources.dpToPx(-50) - val mapRotate = 50f - val mapScale = 1.5f - - binding.mapImageView.animate() - .setDuration(800) - .setInterpolator(AccelerateDecelerateInterpolator()) - .rotationX(mapRotate) - .scaleY(mapScale).scaleX(mapScale) - .translationY(mapTranslate) - .start() - - binding.mapLightingImageView.animate() - .setDuration(800) - .setInterpolator(AccelerateDecelerateInterpolator()) - .rotationX(mapRotate) - .alpha(0f) - .scaleY(mapScale).scaleX(mapScale) - .translationY(mapTranslate) - .start() - - // 1st text fade out - disappearText(binding.tutorialStepIntro) - - delay(200) - - // flashing GPS button appears - binding.tutorialGpsButton.state = LocationStateButton.State.SEARCHING - binding.tutorialGpsButton.animate() - .alpha(1f) - .setDuration(200) - .start() - - delay(400) - - // 2nd text fade in - appearText(binding.tutorialStepSolvingQuests) - - delay(1400) - - // ...and after a few seconds, stops flashing - binding.tutorialGpsButton.state = LocationStateButton.State.UPDATING - - delay(800) - - // quest pins fall into place - listOf(binding.questPin1, binding.questPin2, binding.questPin3).forEach { pin -> - delay(400) - - pin.translationY = ctx.resources.pxToDp(-200) - pin.animate() - .setInterpolator(BounceInterpolator()) - .setDuration(400) - .translationY(0f) - .alpha(1f) - .start() - } - - enableNextButton() - } - - private fun step2Transition() = viewLifecycleScope.launch { - updateIndicatorDots() - binding.nextButton.setText(R.string.letsgo) - - // 2nd text fade out - disappearText(binding.tutorialStepSolvingQuests) - - delay(400) - - // 3rd text fade in - appearText(binding.tutorialStepStaySafe) - - // quest pins fade out - listOf(binding.questPin1, binding.questPin2, binding.questPin3).forEach { pin -> - pin.animate() - .setInterpolator(AccelerateInterpolator()) - .setDuration(300) - .alpha(0f) - .start() } - - delay(1400) - // checkmark fades in and animates - - binding.checkmarkView.animate() - .setDuration(600) - .alpha(1f) - .start() - - (binding.checkmarkView.drawable as? AnimatedVectorDrawable)?.start() - - enableNextButton() - } - - private fun appearText(view: View) { - view.translationY = view.resources.pxToDp(-100) - view.animate() - .withStartAction { view.visibility = View.VISIBLE } - .setDuration(300) - .alpha(1f) - .translationY(0f) - .start() - } - - private fun disappearText(view: View) { - view.animate() - .setDuration(300) - .alpha(0f) - .translationY(view.resources.pxToDp(100)) - .withEndAction { view.visibility = View.GONE } - .start() - } - - private fun updateIndicatorDots() { - listOf(binding.dot1, binding.dot2, binding.dot3).forEachIndexed { index, dot -> - dot.setImageResource( - if (currentPage == index) { - R.drawable.indicator_dot_selected - } else { - R.drawable.indicator_dot_default - } - ) - } - } - - companion object { - private const val MAX_PAGE_INDEX = 2 - } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialScreen.kt new file mode 100644 index 00000000000..24e141f9621 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialScreen.kt @@ -0,0 +1,231 @@ +package de.westnordost.streetcomplete.screens.tutorial + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +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.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import kotlinx.coroutines.launch + +/** Generic multiple-page tutorial screen */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TutorialScreen( + pageCount: Int, + onFinished: () -> Unit, + illustration: @Composable BoxScope.(page: Int) -> Unit, + pageContent: @Composable (page: Int) -> Unit +) { + val state = rememberPagerState { pageCount } + val coroutineScope = rememberCoroutineScope() + BackHandler(state.currentPage > 0) { + coroutineScope.launch { + state.animateScrollToPage(state.currentPage - 1) + } + } + + TutorialScreenLayout( + illustration = { + illustration(state.currentPage) + }, + pageContent = { + HorizontalPager( + state = state, + modifier = Modifier.width(480.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + pageSpacing = 64.dp, + pageContent = { page -> + Box( + Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 96.dp) + ) { + pageContent(page) + } + } + ) + }, + controls = { + PagerControls( + state = state, + onLastPageFinished = onFinished, + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + .0f to Color.Transparent, + .5f to MaterialTheme.colors.surface + ) + ) + .padding(bottom = 16.dp) + ) + }, + modifier = Modifier.safeDrawingPadding() + ) +} + +@Composable +private fun TutorialScreenLayout( + modifier: Modifier = Modifier, + illustration: @Composable BoxScope.() -> Unit, + pageContent: @Composable () -> Unit, + controls: @Composable () -> Unit, +) { + BoxWithConstraints(modifier) { + if (maxHeight > maxWidth) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(0.4f) + .clipToBounds(), + contentAlignment = Alignment.BottomCenter + ) { + illustration() + } + Box( + modifier = Modifier.weight(0.6f), + contentAlignment = Alignment.Center + ) { + pageContent() + Box(Modifier.align(Alignment.BottomCenter)) { + controls() + } + } + } + } else { + Box(Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(0.4f) + .clipToBounds(), + contentAlignment = Alignment.CenterEnd + ) { + illustration() + } + Box( + modifier = Modifier.weight(0.6f), + contentAlignment = Alignment.Center + ) { + pageContent() + } + } + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(Modifier.weight(0.4f)) + Box( + Modifier + .weight(0.6f) + .align(Alignment.Bottom)) { + controls() + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PagerControls( + state: PagerState, + onLastPageFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + Row { + repeat(state.pageCount) { page -> + PagerIndicator( + isCurrentPage = state.currentPage == page, + onClick = { + coroutineScope.launch { state.animateScrollToPage(page) } + } + ) + } + } + Button(onClick = { + if (state.isOnLastPage()) { + onLastPageFinished() + } else { + coroutineScope.launch { state.animateScrollToPage(state.currentPage + 1) } + } + }) { + Text(stringResource(if (state.isOnLastPage()) R.string.letsgo else R.string.next)) + } + } +} + +@Composable +private fun PagerIndicator( + isCurrentPage: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val alpha by animateFloatAsState( + if (isCurrentPage) ContentAlpha.high else ContentAlpha.disabled + ) + Box( + modifier + .padding(4.dp) + .alpha(alpha) + .background(color = MaterialTheme.colors.onSurface, shape = CircleShape) + .size(12.dp) + .clickable { onClick() } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private fun PagerState.isOnLastPage(): Boolean = currentPage >= pageCount - 1 diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AnimatedTadaShine.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AnimatedTadaShine.kt index d1f60425651..2765cfbbdd7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AnimatedTadaShine.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AnimatedTadaShine.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview import de.westnordost.streetcomplete.R @Composable -fun AnimatedTadaShine() { +fun AnimatedTadaShine(modifier: Modifier = Modifier) { val infiniteTransition = rememberInfiniteTransition("ta-da shine rotation") val rotation by infiniteTransition.animateFloat( 0f, 360f, @@ -27,8 +27,8 @@ fun AnimatedTadaShine() { "ta-da shine rotation" ) - TadaShine(Modifier.rotate(rotation * 2f)) - TadaShine(Modifier.rotate(180f - rotation)) + TadaShine(modifier.rotate(rotation * 2f)) + TadaShine(modifier.rotate(180f - rotation)) } @Composable diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/Pin.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/Pin.kt new file mode 100644 index 00000000000..a09a277a077 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/Pin.kt @@ -0,0 +1,40 @@ +package de.westnordost.streetcomplete.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R + +@Composable +fun Pin( + iconPainter: Painter, + modifier: Modifier = Modifier +) { + Box(modifier) { + Image( + painter = painterResource(R.drawable.pin), + contentDescription = null, + modifier = Modifier.size(66.dp) + ) + Image( + painter = iconPainter, + contentDescription = null, + modifier = Modifier + .absoluteOffset(x = 20.dp, y = 7.dp) + .size(42.dp) + ) + } +} + +@Composable +@Preview +private fun PinPreview() { + Pin(painterResource(R.drawable.ic_quest_recycling)) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/PathParser.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/PathParser.kt new file mode 100644 index 00000000000..5ed0d8ddb8f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/PathParser.kt @@ -0,0 +1,7 @@ +package de.westnordost.streetcomplete.ui.util + +import androidx.compose.ui.graphics.vector.PathNode +import androidx.compose.ui.graphics.vector.PathParser + +fun svgPath(string: String): List = + PathParser().parsePathString(string).toNodes() diff --git a/app/src/main/res/drawable/ic_animated_checkmark_circle.xml b/app/src/main/res/drawable/ic_animated_checkmark_circle.xml deleted file mode 100644 index 7818bb713ec..00000000000 --- a/app/src/main/res/drawable/ic_animated_checkmark_circle.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_checkmark_circle.xml b/app/src/main/res/drawable/ic_checkmark_circle.xml deleted file mode 100644 index 46896c82660..00000000000 --- a/app/src/main/res/drawable/ic_checkmark_circle.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/indicator_dot_default.xml b/app/src/main/res/drawable/indicator_dot_default.xml deleted file mode 100644 index 70d1e0f8e8b..00000000000 --- a/app/src/main/res/drawable/indicator_dot_default.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/indicator_dot_selected.xml b/app/src/main/res/drawable/indicator_dot_selected.xml deleted file mode 100644 index 142c3bf6e9d..00000000000 --- a/app/src/main/res/drawable/indicator_dot_selected.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/overlay_osm_map.xml b/app/src/main/res/drawable/overlay_osm_map.xml deleted file mode 100644 index 05a2df8ca29..00000000000 --- a/app/src/main/res/drawable/overlay_osm_map.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/overlay_osm_map_animated.xml b/app/src/main/res/drawable/overlay_osm_map_animated.xml deleted file mode 100644 index d4ebb9fa259..00000000000 --- a/app/src/main/res/drawable/overlay_osm_map_animated.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/overlay_osm_map_edit.xml b/app/src/main/res/drawable/overlay_osm_map_edit.xml deleted file mode 100644 index caa639a008e..00000000000 --- a/app/src/main/res/drawable/overlay_osm_map_edit.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/overlay_osm_map_edit_animated.xml b/app/src/main/res/drawable/overlay_osm_map_edit_animated.xml deleted file mode 100644 index 3579a0c4dec..00000000000 --- a/app/src/main/res/drawable/overlay_osm_map_edit_animated.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/overlay_osm_map_edit_done_animated.xml b/app/src/main/res/drawable/overlay_osm_map_edit_done_animated.xml deleted file mode 100644 index 8ff68e4f6ff..00000000000 --- a/app/src/main/res/drawable/overlay_osm_map_edit_done_animated.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/paint_roller.xml b/app/src/main/res/drawable/paint_roller.xml deleted file mode 100644 index e20eaa9b5a7..00000000000 --- a/app/src/main/res/drawable/paint_roller.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/paint_roller_animated.xml b/app/src/main/res/drawable/paint_roller_animated.xml deleted file mode 100644 index 55c13212ccd..00000000000 --- a/app/src/main/res/drawable/paint_roller_animated.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/fragment_overlays_tutorial.xml b/app/src/main/res/layout/fragment_overlays_tutorial.xml deleted file mode 100644 index 78978d52f13..00000000000 --- a/app/src/main/res/layout/fragment_overlays_tutorial.xml +++ /dev/null @@ -1,268 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -