diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8b62cd842ce..8e364de16fb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,7 +5,7 @@ import java.util.Properties
plugins {
id("com.android.application")
kotlin("android")
- kotlin("plugin.serialization") version "1.9.22"
+ kotlin("plugin.serialization") version "1.9.23"
}
android {
@@ -52,6 +52,7 @@ android {
buildConfigField("boolean", "IS_GOOGLE_PLAY", "false")
}
getByName("debug") {
+ isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
applicationIdSuffix = ".debug"
buildConfigField("boolean", "IS_GOOGLE_PLAY", "false")
@@ -65,6 +66,11 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.12"
}
bundle {
@@ -140,7 +146,18 @@ dependencies {
implementation("androidx.viewpager:viewpager:1.0.0")
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
+ // Jetpack Compose
+ val composeBom = platform("androidx.compose:compose-bom:2024.04.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ implementation("androidx.compose.material:material")
+ // Jetpack Compose Previews
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// photos
implementation("androidx.exifinterface:exifinterface:1.3.7")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f81067607e0..1ede14ab910 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -89,9 +89,6 @@
-
(null)
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = composableContent {
+ val message by shownMessage.collectAsState()
+
+ val msg = message
+ if (msg is NewAchievementMessage) {
+ AchievementDialog(
+ msg.achievement,
+ msg.level,
+ onDismissRequest = { shownMessage.value = null }
+ )
+ }
+ }
fun showMessage(message: Message) {
val ctx = context ?: return
+ shownMessage.value = message
when (message) {
is OsmUnreadMessagesMessage -> {
OsmUnreadMessagesFragment
@@ -30,8 +53,7 @@ class MessagesContainerFragment : Fragment(R.layout.fragment_messages_container)
.show()
}
is NewAchievementMessage -> {
- val f: Fragment = childFragmentManager.findFragmentById(R.id.achievement_info_fragment)!!
- (f as AchievementInfoFragment).showNew(message.achievement, message.level)
+ shownMessage.value = message
}
is QuestSelectionHintMessage -> {
AlertDialog.Builder(ctx)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsFragment.kt
index 97b6df4526d..728085d8fb3 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsFragment.kt
@@ -19,7 +19,6 @@ import de.westnordost.streetcomplete.R
import de.westnordost.streetcomplete.databinding.DialogDeleteCacheBinding
import de.westnordost.streetcomplete.screens.HasTitle
import de.westnordost.streetcomplete.screens.TwoPaneListFragment
-import de.westnordost.streetcomplete.screens.settings.debug.ShowLinksActivity
import de.westnordost.streetcomplete.screens.settings.debug.ShowQuestFormsActivity
import de.westnordost.streetcomplete.util.ktx.format
import de.westnordost.streetcomplete.util.ktx.observe
@@ -70,11 +69,6 @@ class SettingsFragment : TwoPaneListFragment(), HasTitle {
startActivity(Intent(context, ShowQuestFormsActivity::class.java))
true
}
-
- findPreference("debug.links")?.setOnPreferenceClickListener {
- startActivity(Intent(context, ShowLinksActivity::class.java))
- true
- }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsModule.kt
index 1fc39928a8a..3ede1b8a8b8 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/SettingsModule.kt
@@ -1,7 +1,5 @@
package de.westnordost.streetcomplete.screens.settings
-import de.westnordost.streetcomplete.screens.settings.debug.ShowLinksActivityViewModel
-import de.westnordost.streetcomplete.screens.settings.debug.ShowLinksActivityViewModelImpl
import de.westnordost.streetcomplete.screens.settings.debug.ShowQuestFormsViewModel
import de.westnordost.streetcomplete.screens.settings.debug.ShowQuestFormsViewModelImpl
import de.westnordost.streetcomplete.screens.settings.questselection.QuestPresetsViewModel
@@ -19,5 +17,4 @@ val settingsModule = module {
viewModel { QuestSelectionViewModelImpl(get(), get(), get(), get(), get(named("CountryBoundariesLazy")), get()) }
viewModel { QuestPresetsViewModelImpl(get(), get(), get(), get()) }
viewModel { ShowQuestFormsViewModelImpl(get(), get()) }
- viewModel { ShowLinksActivityViewModelImpl(get(named("Links"))) }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivity.kt
deleted file mode 100644
index d5b2acda3e8..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivity.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package de.westnordost.streetcomplete.screens.settings.debug
-
-import android.os.Bundle
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.databinding.FragmentShowLinksBinding
-import de.westnordost.streetcomplete.screens.BaseActivity
-import de.westnordost.streetcomplete.screens.user.links.GroupedLinksAdapter
-import de.westnordost.streetcomplete.util.ktx.openUri
-import de.westnordost.streetcomplete.util.viewBinding
-import org.koin.androidx.viewmodel.ext.android.viewModel
-
-/** activity only used in debug, to show all achievement links */
-class ShowLinksActivity : BaseActivity() {
-
- private val binding by viewBinding(FragmentShowLinksBinding::inflate)
- private val viewModel by viewModel()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.fragment_show_links)
- binding.toolbarLayout.toolbar.navigationIcon = getDrawable(R.drawable.ic_close_24dp)
- binding.toolbarLayout.toolbar.setNavigationOnClickListener { onBackPressed() }
- binding.toolbarLayout.toolbar.title = "Show Achievement Links"
-
- binding.linksList.apply {
- addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
- layoutManager = LinearLayoutManager(context)
- adapter = GroupedLinksAdapter(viewModel.links, ::openUri)
- }
- }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivityViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivityViewModel.kt
deleted file mode 100644
index 616f5a5bb07..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/debug/ShowLinksActivityViewModel.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package de.westnordost.streetcomplete.screens.settings.debug
-
-import androidx.lifecycle.ViewModel
-import de.westnordost.streetcomplete.data.user.achievements.Link
-
-abstract class ShowLinksActivityViewModel : ViewModel() {
- abstract val links: List
-}
-
-class ShowLinksActivityViewModelImpl(override val links: List) : ShowLinksActivityViewModel()
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/CenteredLargeTitleHint.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/CenteredLargeTitleHint.kt
new file mode 100644
index 00000000000..0911be2351e
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/CenteredLargeTitleHint.kt
@@ -0,0 +1,28 @@
+package de.westnordost.streetcomplete.screens.user
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.ui.theme.hint
+import de.westnordost.streetcomplete.ui.theme.titleLarge
+
+@Composable
+fun CenteredLargeTitleHint(text: String, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(64.dp),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colors.hint,
+ )
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserActivity.kt
index 8f24b1d9a7c..166cbe87416 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserActivity.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserActivity.kt
@@ -8,11 +8,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import de.westnordost.streetcomplete.R
import de.westnordost.streetcomplete.data.osm.edits.EditType
-import de.westnordost.streetcomplete.data.user.achievements.Achievement
import de.westnordost.streetcomplete.screens.FragmentContainerActivity
import de.westnordost.streetcomplete.screens.HasTitle
-import de.westnordost.streetcomplete.screens.user.achievements.AchievementInfoFragment
-import de.westnordost.streetcomplete.screens.user.achievements.AchievementsFragment
import de.westnordost.streetcomplete.screens.user.login.LoginFragment
import de.westnordost.streetcomplete.screens.user.statistics.CountryInfoFragment
import de.westnordost.streetcomplete.screens.user.statistics.EditStatisticsFragment
@@ -24,11 +21,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
* This activity coordinates quite a number of fragments, which all call back to this one. In order
* of appearance:
* The LoginFragment, the UserFragment (which contains the viewpager with more
- * fragments) and the "fake" dialogs AchievementInfoFragment and QuestTypeInfoFragment.
+ * fragments) and the "fake" dialog QuestTypeInfoFragment.
* */
class UserActivity :
FragmentContainerActivity(R.layout.activity_user),
- AchievementsFragment.Listener,
EditStatisticsFragment.Listener {
private val viewModel by viewModel()
@@ -39,9 +35,6 @@ class UserActivity :
private val editTypeDetailsFragment get() =
supportFragmentManager.findFragmentById(R.id.editTypeDetailsFragment) as EditTypeInfoFragment?
- private val achievementDetailsFragment get() =
- supportFragmentManager.findFragmentById(R.id.achievementDetailsFragment) as AchievementInfoFragment?
-
private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) {
if (fragment.id == R.id.fragment_container && fragment is HasTitle) {
@@ -86,12 +79,6 @@ class UserActivity :
}
}
- /* ---------------------------- AchievementsFragment.Listener ------------------------------- */
-
- override fun onClickedAchievement(achievement: Achievement, level: Int, achievementBubbleView: View) {
- achievementDetailsFragment?.show(achievement, level, achievementBubbleView)
- }
-
/* --------------------------- QuestStatisticsFragment.Listener ----------------------------- */
override fun onClickedEditType(editType: EditType, editCount: Int, questBubbleView: View) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserScreenModule.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserScreenModule.kt
index 95b24aa3dd5..cbe86f44973 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserScreenModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserScreenModule.kt
@@ -15,7 +15,7 @@ import org.koin.dsl.module
val userScreenModule = module {
factory { ProfileViewModelImpl(
- get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory")), get()
+ get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory"))
) }
factory { LoginViewModelImpl(get(), get(), get(), get()) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementDialog.kt
new file mode 100644
index 00000000000..c7a61ebbda0
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementDialog.kt
@@ -0,0 +1,189 @@
+package de.westnordost.streetcomplete.screens.user.achievements
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.LocalContentColor
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewScreenSizes
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.coerceAtMost
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.min
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.user.achievements.Achievement
+import de.westnordost.streetcomplete.data.user.achievements.achievements
+import de.westnordost.streetcomplete.screens.user.links.LazyLinksColumn
+import de.westnordost.streetcomplete.ui.theme.AppTheme
+import de.westnordost.streetcomplete.ui.theme.headlineSmall
+import de.westnordost.streetcomplete.ui.theme.titleMedium
+import de.westnordost.streetcomplete.ui.util.backgroundWithPadding
+import de.westnordost.streetcomplete.util.ktx.openUri
+
+@Composable
+fun AchievementDialog(
+ achievement: Achievement,
+ level: Int,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ isNew: Boolean = true,
+) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ if (isNew) {
+ AnimatedTadaShine()
+ }
+ // center everything
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ // dismiss when clicking wherever - no ripple effect
+ .clickable(interactionSource, null) { onDismissRequest() },
+ contentAlignment = Alignment.Center
+ ) {
+ ContentWithIconPortraitOrLandscape(modifier.padding(16.dp)) { isLandscape, iconSize ->
+ AchievementIcon(achievement.icon, level, Modifier.size(iconSize))
+ AchievementDetails(
+ achievement, level,
+ horizontalAlignment = if (isLandscape) Alignment.Start else Alignment.CenterHorizontally,
+ showLinks = isNew
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContentWithIconPortraitOrLandscape(
+ modifier: Modifier = Modifier,
+ content: @Composable (isLandscape: Boolean, iconSize: Dp) -> Unit
+) {
+ // in landscape layout, dialog would become too tall to fit
+ BoxWithConstraints(modifier) {
+ val isLandscape = maxWidth > maxHeight
+
+ // scale down icon to fit small devices
+ val iconSize = (min(maxWidth, maxHeight) * 0.67f).coerceAtMost(320.dp)
+
+ val backgroundPadding =
+ if (isLandscape) PaddingValues(start = iconSize * 0.75f)
+ else PaddingValues(top = iconSize * 0.75f)
+
+
+ val dialogModifier = modifier
+ .backgroundWithPadding(
+ color = MaterialTheme.colors.surface,
+ padding = backgroundPadding,
+ shape = MaterialTheme.shapes.medium
+ )
+ .padding(24.dp)
+
+ val contentColor = contentColorFor(MaterialTheme.colors.surface)
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ if (isLandscape) {
+ Row(
+ modifier = dialogModifier.width(maxWidth.coerceAtMost(720.dp)),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ content(true, iconSize)
+ }
+ } else {
+ Column(
+ modifier = dialogModifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ content(false, iconSize)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AchievementDetails(
+ achievement: Achievement,
+ level: Int,
+ modifier: Modifier = Modifier,
+ horizontalAlignment: Alignment.Horizontal,
+ showLinks: Boolean,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = horizontalAlignment
+ ) {
+ Text(
+ text = stringResource(achievement.title),
+ modifier = Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ val description = achievement.description
+ if (description != null) {
+ val arg = achievement.getPointThreshold(level)
+ Text(
+ text = stringResource(description, arg),
+ modifier = Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.body2
+ )
+ }
+ val unlockedLinks = achievement.unlockedLinks[level].orEmpty()
+ if (unlockedLinks.isNotEmpty() && showLinks) {
+ val unlockedLinksText = stringResource(
+ if (unlockedLinks.size == 1) R.string.achievements_unlocked_link
+ else R.string.achievements_unlocked_links
+ )
+ Text(
+ text = unlockedLinksText,
+ modifier = Modifier.align(Alignment.Start),
+ style = MaterialTheme.typography.titleMedium
+ )
+ val context = LocalContext.current
+ LazyLinksColumn(
+ links = unlockedLinks,
+ onClickLink = { context.openUri(it) }
+ )
+ }
+ }
+}
+
+@Preview(device = Devices.NEXUS_5) // darn small device
+@PreviewScreenSizes
+@PreviewLightDark
+@Composable
+fun PreviewAchievementDetailsDialog() {
+ AppTheme {
+ AchievementDialog(
+ achievement = achievements.associateBy { it.id }["regular"]!!,
+ level = 7,
+ onDismissRequest = {}
+ )
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt
new file mode 100644
index 00000000000..7984ebb0d1a
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt
@@ -0,0 +1,94 @@
+package de.westnordost.streetcomplete.screens.user.achievements
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.ui.theme.titleLarge
+
+@Composable
+fun AchievementIcon(
+ icon: Int,
+ level: Int,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.aspectRatio(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(R.drawable.achievement_frame),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize(1f)
+ .shadow(elevation = 4.dp, shape = AchievementFrameShape)
+ )
+ Image(
+ painter = painterResource(icon),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(0.91f)
+ )
+ if (level > 1) {
+ Text(
+ text = level.toString(),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .levelLabelBackground(),
+ color = Color.White,
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ }
+}
+
+private fun Modifier.levelLabelBackground() =
+ drawBehind {
+ val radius = CornerRadius(32.dp.toPx(), 32.dp.toPx())
+ drawRoundRect(
+ color = Color(0xff9b51e0),
+ cornerRadius = radius
+ )
+ drawRoundRect(
+ color = Color(0xfffbbb00),
+ cornerRadius = radius,
+ style = Stroke(4.dp.toPx())
+ )
+ }
+ .padding(horizontal = 10.dp, vertical = 4.dp)
+
+object AchievementFrameShape : Shape {
+ override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
+ val pathStr = "m0.55404 0.97761c-0.029848 0.029846-0.078236 0.029846-0.10808 0l-0.42357-0.42357c-0.029848-0.029848-0.029848-0.078239 0-0.10808l0.42357-0.42357c0.029846-0.029846 0.078236-0.029846 0.10808 0l0.42357 0.42357c0.029846 0.029846 0.029846 0.078236 0 0.10808z"
+ val path = PathParser().parsePathString(pathStr).toPath()
+ path.transform(Matrix().apply { scale(size.width, size.height, 1f) })
+ return Outline.Generic(path)
+ }
+}
+
+@Preview
+@Composable
+fun PreviewAchievementIcon() {
+ AchievementIcon(icon = R.drawable.ic_achievement_first_edit, level = 8)
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIconView.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIconView.kt
deleted file mode 100644
index 97ac2d2140d..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIconView.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.achievements
-
-import android.content.Context
-import android.graphics.Outline
-import android.graphics.Path
-import android.graphics.drawable.Drawable
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewOutlineProvider
-import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.core.view.isInvisible
-import de.westnordost.streetcomplete.databinding.ViewAchievementIconBinding
-
-/** Shows an achievement icon with its frame and level indicator */
-class AchievementIconView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : ConstraintLayout(context, attrs, defStyleAttr) {
-
- private val binding = ViewAchievementIconBinding.inflate(LayoutInflater.from(context), this)
-
- var icon: Drawable?
- set(value) { binding.iconView.setImageDrawable(value) }
- get() = binding.iconView.drawable
-
- var level: Int
- set(value) {
- binding.levelText.text = value.toString()
- binding.levelText.isInvisible = value < 2
- }
- get() = binding.levelText.text.toString().toIntOrNull() ?: 0
-
- init {
- outlineProvider = AchievementFrameOutlineProvider
- }
-}
-
-object AchievementFrameOutlineProvider : ViewOutlineProvider() {
- private val points = arrayOf(
- 0.45, 0.98,
- 0.47, 0.99,
- 0.50, 1.00,
- 0.53, 0.99,
- 0.55, 0.98,
-
- 0.98, 0.55,
- 0.99, 0.53,
- 1.00, 0.50,
- 0.99, 0.47,
- 0.98, 0.45,
-
- 0.55, 0.02,
- 0.53, 0.01,
- 0.50, 0.00,
- 0.47, 0.01,
- 0.45, 0.02,
-
- 0.02, 0.45,
- 0.01, 0.47,
- 0.00, 0.50,
- 0.01, 0.53,
- 0.02, 0.55,
-
- 0.45, 0.98
- )
-
- override fun getOutline(view: View, outline: Outline) {
- val w = view.width
- val h = view.height
- if (w == 0 || h == 0) return
-
- val p = Path()
- p.moveTo((points[0] * w).toFloat(), (points[1] * h).toFloat())
- for (i in 2 until points.size step 2) {
- p.lineTo((points[i] * w).toFloat(), (points[i + 1] * h).toFloat())
- }
- outline.setConvexPath(p)
- }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementInfoFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementInfoFragment.kt
deleted file mode 100644
index d94fb3b7fb1..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementInfoFragment.kt
+++ /dev/null
@@ -1,333 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.achievements
-
-import android.animation.LayoutTransition
-import android.animation.LayoutTransition.APPEARING
-import android.animation.LayoutTransition.DISAPPEARING
-import android.animation.TimeAnimator
-import android.os.Bundle
-import android.view.View
-import android.view.ViewPropertyAnimator
-import android.view.animation.AccelerateDecelerateInterpolator
-import android.view.animation.AccelerateInterpolator
-import android.view.animation.DecelerateInterpolator
-import android.view.animation.OvershootInterpolator
-import androidx.activity.OnBackPressedCallback
-import androidx.core.view.isGone
-import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.LinearLayoutManager
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.data.user.achievements.Achievement
-import de.westnordost.streetcomplete.databinding.FragmentAchievementInfoBinding
-import de.westnordost.streetcomplete.screens.user.links.LinksAdapter
-import de.westnordost.streetcomplete.util.ktx.openUri
-import de.westnordost.streetcomplete.util.viewBinding
-import de.westnordost.streetcomplete.view.Transforms
-import de.westnordost.streetcomplete.view.ViewPropertyAnimatorsPlayer
-import de.westnordost.streetcomplete.view.animateFrom
-import de.westnordost.streetcomplete.view.animateTo
-import de.westnordost.streetcomplete.view.applyTransforms
-
-/** Shows details for a certain level of one achievement as a fake-dialog.
- * There are two modes:
- *
- * 1. Show details of a newly achieved achievement. The achievement icon animates in in a fancy way
- * and some shining animation is played. The unlocked links are shown.
- *
- * 2. Show details of an already achieved achievement. The achievement icon animates from another
- * view to its current position, no shining animation is played. Also, the unlocked links are
- * not shown because they can be looked at in the links screen.
- *
- * It is not a real dialog because a real dialog has its own window, or in other words, has a
- * different root view than the rest of the UI. However, for the calculation to animate the icon
- * from another view to the position in the "dialog", there must be a common root view.
- * */
-class AchievementInfoFragment : Fragment(R.layout.fragment_achievement_info) {
-
- private val binding by viewBinding(FragmentAchievementInfoBinding::bind)
-
- /** View from which the achievement icon is animated from (and back on dismissal)*/
- private var achievementIconBubble: View? = null
-
- private var animatorsPlayer: ViewPropertyAnimatorsPlayer? = null
- private var shineAnimation: TimeAnimator? = null
-
- private val layoutTransition: LayoutTransition = LayoutTransition()
-
- private val backPressedCallback = object : OnBackPressedCallback(false) {
- override fun handleOnBackPressed() {
- dismiss()
- }
- }
-
- /* ---------------------------------------- Lifecycle --------------------------------------- */
-
- init {
- layoutTransition.disableTransitionType(APPEARING)
- layoutTransition.disableTransitionType(DISAPPEARING)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.dialogAndBackgroundContainer.setOnClickListener { dismiss() }
- // in order to not show the scroll indicators
- binding.unlockedLinksList.isNestedScrollingEnabled = false
- binding.unlockedLinksList.layoutManager = object : LinearLayoutManager(requireContext(), VERTICAL, false) {
- override fun canScrollVertically() = false
- }
- requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- achievementIconBubble = null
- animatorsPlayer?.cancel()
- shineAnimation?.cancel()
- shineAnimation = null
- }
-
- /* ---------------------------------------- Interface --------------------------------------- */
-
- /** Show as details of a tapped view */
- fun show(achievement: Achievement, level: Int, achievementBubbleView: View): Boolean {
- if (animatorsPlayer != null) return false
- backPressedCallback.isEnabled = true
- this.achievementIconBubble = achievementBubbleView
-
- bind(achievement, level, false)
- animateInFromView(achievementBubbleView)
- return true
- }
-
- /** Show as new achievement achieved/unlocked */
- fun showNew(achievement: Achievement, level: Int): Boolean {
- if (animatorsPlayer != null) return false
- backPressedCallback.isEnabled = true
-
- bind(achievement, level, true)
- animateIn()
- return true
- }
-
- fun dismiss(): Boolean {
- if (animatorsPlayer != null) return false
- backPressedCallback.isEnabled = false
- animateOut(achievementIconBubble)
- return true
- }
-
- /* ----------------------------------- Animating in and out --------------------------------- */
-
- private fun bind(achievement: Achievement, level: Int, showLinks: Boolean) {
- binding.achievementIconView.icon = context?.getDrawable(achievement.icon)
- binding.achievementIconView.level = level
- binding.achievementTitleText.setText(achievement.title)
-
- binding.achievementDescriptionText.isGone = achievement.description == null
- if (achievement.description != null) {
- val arg = achievement.getPointThreshold(level)
- binding.achievementDescriptionText.text = resources.getString(achievement.description, arg)
- } else {
- binding.achievementDescriptionText.text = ""
- }
-
- val unlockedLinks = achievement.unlockedLinks[level].orEmpty()
- val hasNoUnlockedLinks = unlockedLinks.isEmpty() || !showLinks
- binding.unlockedLinkTitleText.isGone = hasNoUnlockedLinks
- binding.unlockedLinksList.isGone = hasNoUnlockedLinks
- if (hasNoUnlockedLinks) {
- binding.unlockedLinksList.adapter = null
- } else {
- binding.unlockedLinkTitleText.setText(
- if (unlockedLinks.size == 1) {
- R.string.achievements_unlocked_link
- } else {
- R.string.achievements_unlocked_links
- }
- )
- binding.unlockedLinksList.adapter = LinksAdapter(unlockedLinks, ::openUri)
- }
- }
-
- private fun animateIn() {
- binding.dialogAndBackgroundContainer.visibility = View.VISIBLE
-
- shineAnimation?.cancel()
- val anim = TimeAnimator()
- anim.setTimeListener { _, _, deltaTime ->
- binding.shineView1.rotation += deltaTime / 50f
- binding.shineView2.rotation -= deltaTime / 100f
- }
- anim.start()
- shineAnimation = anim
-
- playAll(
- *createShineFadeInAnimations().toTypedArray(),
- *createDialogPopInAnimations(DIALOG_APPEAR_DELAY_IN_MS).toTypedArray(),
- createFadeInBackgroundAnimation(),
- createAchievementIconPopInAnimation()
- )
- }
-
- private fun animateInFromView(questBubbleView: View) {
- questBubbleView.visibility = View.INVISIBLE
- binding.dialogAndBackgroundContainer.visibility = View.VISIBLE
-
- binding.shineView1.visibility = View.GONE
- binding.shineView2.visibility = View.GONE
-
- playAll(
- *createDialogPopInAnimations().toTypedArray(),
- createFadeInBackgroundAnimation(),
- createAchievementIconFlingInAnimation(questBubbleView)
- )
- }
-
- private fun animateOut(questBubbleView: View?) {
- binding.dialogContainer.layoutTransition = null
-
- val iconAnimator = if (questBubbleView != null) {
- createAchievementIconFlingOutAnimation(questBubbleView)
- } else {
- createAchievementIconPopOutAnimation()
- }
-
- playAll(
- *createShineFadeOutAnimations().toTypedArray(),
- *createDialogPopOutAnimations().toTypedArray(),
- createFadeOutBackgroundAnimation(),
- iconAnimator
- )
- }
-
- private fun createAchievementIconFlingInAnimation(sourceView: View): ViewPropertyAnimator =
- binding.achievementIconView.let {
- sourceView.visibility = View.INVISIBLE
- it.applyTransforms(Transforms.IDENTITY)
- it.alpha = 1f
- it.animateFrom(sourceView)
- .setDuration(ANIMATION_TIME_IN_MS)
- .setInterpolator(OvershootInterpolator())
- }
-
- private fun createAchievementIconFlingOutAnimation(targetView: View): ViewPropertyAnimator =
- binding.achievementIconView.animateTo(targetView)
- .setDuration(ANIMATION_TIME_OUT_MS)
- .setInterpolator(AccelerateDecelerateInterpolator())
- .withEndAction {
- targetView.visibility = View.VISIBLE
- achievementIconBubble = null
- }
-
- private fun createShineFadeInAnimations(): List =
- listOf(binding.shineView1, binding.shineView2).map {
- it.visibility = View.VISIBLE
- it.alpha = 0f
- it.animate()
- .alpha(1f)
- .setDuration(ANIMATION_TIME_IN_MS)
- .setInterpolator(DecelerateInterpolator())
- }
-
- private fun createShineFadeOutAnimations(): List =
- listOf(binding.shineView1, binding.shineView2).map {
- it.animate()
- .alpha(0f)
- .setDuration(ANIMATION_TIME_OUT_MS)
- .setInterpolator(AccelerateInterpolator())
- .withEndAction {
- shineAnimation?.cancel()
- shineAnimation = null
- it.visibility = View.GONE
- }
- }
-
- private fun createAchievementIconPopInAnimation(): ViewPropertyAnimator =
- binding.achievementIconView.let {
- it.alpha = 0f
- it.scaleX = 0f
- it.scaleY = 0f
- it.rotationY = -180f
- it.animate()
- .alpha(1f)
- .scaleX(1f).scaleY(1f)
- .rotationY(360f)
- .setDuration(ANIMATION_TIME_NEW_ACHIEVEMENT_IN_MS)
- .setInterpolator(DecelerateInterpolator())
- }
-
- private fun createAchievementIconPopOutAnimation(): ViewPropertyAnimator =
- binding.achievementIconView.animate()
- .alpha(0f)
- .scaleX(0.5f).scaleY(0.5f)
- .setDuration(ANIMATION_TIME_OUT_MS)
- .setInterpolator(AccelerateInterpolator())
-
- private fun createDialogPopInAnimations(startDelay: Long = 0): List =
- listOf(binding.dialogContentContainer, binding.dialogBubbleBackground).map {
- it.alpha = 0f
- it.scaleX = 0.5f
- it.scaleY = 0.5f
- it.translationY = 0f
- /* For the "show new achievement" mode, only the icon is shown first and only after a
- * delay, the dialog with the description etc.
- * This icon is in the center at first and should animate up while the dialog becomes
- * visible. This movement is solved via a (default) layout transition here for which the
- * APPEARING transition type is disabled because we animate the alpha ourselves. */
- it.isGone = startDelay > 0
- it.animate()
- .setStartDelay(startDelay)
- .withStartAction {
- if (startDelay > 0) {
- binding.dialogContainer.layoutTransition = layoutTransition
- it.visibility = View.VISIBLE
- }
- }
- .alpha(1f)
- .scaleX(1f).scaleY(1f)
- .setDuration(ANIMATION_TIME_IN_MS)
- .setInterpolator(OvershootInterpolator())
- }
-
- private fun createDialogPopOutAnimations(): List =
- listOf(binding.dialogContentContainer, binding.dialogBubbleBackground).map {
- it.animate()
- .alpha(0f)
- .setStartDelay(0)
- .scaleX(0.5f).scaleY(0.5f)
- .translationYBy(it.height * 0.2f)
- .setDuration(ANIMATION_TIME_OUT_MS)
- .setInterpolator(AccelerateInterpolator())
- }
-
- private fun createFadeInBackgroundAnimation(): ViewPropertyAnimator =
- binding.dialogBackground.let {
- it.alpha = 0f
- it.animate()
- .alpha(1f)
- .setDuration(ANIMATION_TIME_IN_MS)
- .setInterpolator(DecelerateInterpolator())
- }
-
- private fun createFadeOutBackgroundAnimation(): ViewPropertyAnimator =
- binding.dialogBackground.animate()
- .alpha(0f)
- .setDuration(ANIMATION_TIME_OUT_MS)
- .setInterpolator(AccelerateInterpolator())
- .withEndAction {
- binding.dialogAndBackgroundContainer.visibility = View.INVISIBLE
- }
-
- private fun playAll(vararg animators: ViewPropertyAnimator) {
- animatorsPlayer = ViewPropertyAnimatorsPlayer(animators.toMutableList()).also {
- it.onEnd = { animatorsPlayer = null }
- it.start()
- }
- }
-
- companion object {
- const val ANIMATION_TIME_NEW_ACHIEVEMENT_IN_MS = 1000L
- const val ANIMATION_TIME_IN_MS = 400L
- const val DIALOG_APPEAR_DELAY_IN_MS = 1600L
- const val ANIMATION_TIME_OUT_MS = 300L
- }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsFragment.kt
index d54db67713b..c158122c4a0 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsFragment.kt
@@ -4,99 +4,14 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.view.isGone
-import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.data.user.achievements.Achievement
-import de.westnordost.streetcomplete.databinding.CellAchievementBinding
-import de.westnordost.streetcomplete.databinding.FragmentAchievementsBinding
-import de.westnordost.streetcomplete.util.ktx.awaitLayout
-import de.westnordost.streetcomplete.util.ktx.dpToPx
-import de.westnordost.streetcomplete.util.ktx.observe
-import de.westnordost.streetcomplete.util.viewBinding
-import de.westnordost.streetcomplete.view.GridLayoutSpacingItemDecoration
-import de.westnordost.streetcomplete.view.ListAdapter
-import kotlinx.coroutines.launch
+import de.westnordost.streetcomplete.ui.util.composableContent
import org.koin.androidx.viewmodel.ext.android.viewModel
-/** Shows the icons for all achieved achievements and opens a AchievementInfoFragment to show the
- * details on click. */
-class AchievementsFragment : Fragment(R.layout.fragment_achievements) {
+class AchievementsFragment : Fragment() {
private val viewModel by viewModel()
- private val binding by viewBinding(FragmentAchievementsBinding::bind)
- private var actualCellWidth: Int = 0
-
- interface Listener {
- fun onClickedAchievement(achievement: Achievement, level: Int, achievementBubbleView: View)
- }
- private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- binding.emptyText.isGone = true
-
- observe(viewModel.isSynchronizingStatistics) { isSynchronizingStatistics ->
- binding.emptyText.setText(
- if (isSynchronizingStatistics) {
- R.string.stats_are_syncing
- } else {
- R.string.links_empty
- }
- )
- }
-
- observe(viewModel.achievements) { achievements ->
- binding.emptyText.isGone = achievements == null || achievements.isNotEmpty()
- if (achievements != null) {
- binding.achievementsList.adapter = AchievementsAdapter(achievements)
- }
- }
-
- viewLifecycleOwner.lifecycleScope.launch {
- view.awaitLayout()
-
- val minCellWidth = resources.dpToPx(144)
- val itemSpacing = resources.getDimensionPixelSize(R.dimen.achievements_item_margin)
- val spanCount = (view.width / (minCellWidth + itemSpacing)).toInt()
- actualCellWidth = (view.width.toFloat() / spanCount - itemSpacing).toInt()
-
- val layoutManager = GridLayoutManager(requireContext(), spanCount, RecyclerView.VERTICAL, false)
- binding.achievementsList.layoutManager = layoutManager
- binding.achievementsList.addItemDecoration(GridLayoutSpacingItemDecoration(itemSpacing))
- binding.achievementsList.clipToPadding = false
- }
- }
-
- private inner class AchievementsAdapter(
- achievements: List>
- ) : ListAdapter>(achievements) {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- val binding = CellAchievementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- binding.root.updateLayoutParams {
- width = actualCellWidth
- height = actualCellWidth
- }
- return ViewHolder(binding)
- }
-
- inner class ViewHolder(val binding: CellAchievementBinding) : ListAdapter.ViewHolder>(binding) {
- override fun onBind(with: Pair) {
- val achievement = with.first
- val level = with.second
- binding.achievementIconView.icon = context?.getDrawable(achievement.icon)
- binding.achievementIconView.level = level
- binding.achievementIconView.setOnClickListener {
- listener?.onClickedAchievement(achievement, level, binding.achievementIconView)
- }
- }
- }
- }
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ composableContent { AchievementsScreen(viewModel) }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsScreen.kt
new file mode 100644
index 00000000000..3dfaf8c0734
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementsScreen.kt
@@ -0,0 +1,55 @@
+package de.westnordost.streetcomplete.screens.user.achievements
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.user.achievements.Achievement
+import de.westnordost.streetcomplete.screens.user.CenteredLargeTitleHint
+
+/** Shows the icons for all achieved achievements and opens a dialog to show the details on click. */
+@Composable
+fun AchievementsScreen(viewModel: AchievementsViewModel) {
+ val isSynchronizingStatistics by viewModel.isSynchronizingStatistics.collectAsState()
+ val achievements by viewModel.achievements.collectAsState()
+
+ val showAchievement = remember { mutableStateOf?>(null) }
+
+ val allAchievements = achievements
+ if (allAchievements != null) {
+ if (allAchievements.isNotEmpty()) {
+ LazyAchievementsGrid(
+ achievements = allAchievements,
+ onClickAchievement = { achievement, level ->
+ showAchievement.value = achievement to level
+ },
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp)
+ )
+ } else {
+ CenteredLargeTitleHint(stringResource(
+ if (isSynchronizingStatistics) R.string.stats_are_syncing
+ else R.string.achievements_empty
+ ))
+ }
+ }
+
+ // TODO Compose: revisit animate-from-icon when androidx.compose.animation 1.7 is stable
+ // https://developer.android.com/develop/ui/compose/animation/shared-elements
+ showAchievement.value?.let { (achievement, level) ->
+ AchievementDialog(
+ achievement, level,
+ onDismissRequest = {
+ showAchievement.value = null
+ },
+ isNew = false
+ )
+ }
+}
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
new file mode 100644
index 00000000000..32ca8fa3707
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AnimatedTadaShine.kt
@@ -0,0 +1,51 @@
+package de.westnordost.streetcomplete.screens.user.achievements
+
+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.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import de.westnordost.streetcomplete.R
+
+@Composable
+fun AnimatedTadaShine() {
+ val infiniteTransition = rememberInfiniteTransition("ta-da shine rotation")
+ val rotation by infiniteTransition.animateFloat(
+ 0f, 360f,
+ infiniteRepeatable(tween(15000, 0, LinearEasing)),
+ "ta-da shine rotation"
+ )
+
+ TadaShine(Modifier.rotate(rotation * 2f))
+ TadaShine(Modifier.rotate(180f - rotation))
+}
+
+@Composable
+private fun TadaShine(modifier: Modifier = Modifier) {
+ Image(
+ painter = painterResource(R.drawable.shine),
+ contentDescription = null,
+ modifier = modifier
+ .fillMaxSize()
+ .scale(3.0f),
+ alignment = Alignment.Center,
+ contentScale = ContentScale.Crop,
+ )
+}
+
+@Preview
+@Composable
+fun PreviewAnimatedTadaShine() {
+ AnimatedTadaShine()
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/LazyAchievementsGrid.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/LazyAchievementsGrid.kt
new file mode 100644
index 00000000000..5c62bc3ff72
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/LazyAchievementsGrid.kt
@@ -0,0 +1,57 @@
+package de.westnordost.streetcomplete.screens.user.achievements
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.data.user.achievements.Achievement
+import de.westnordost.streetcomplete.data.user.achievements.achievements
+
+@Composable
+fun LazyAchievementsGrid(
+ achievements: List>,
+ onClickAchievement: (achievement: Achievement, level: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) {
+ // TODO Compose: revisit animate-in of list items when androidx.compose.animation 1.7 is stable
+ // probably Modifier.animateItem or Modifier.animateEnterExit
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 144.dp),
+ modifier = modifier,
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(achievements) { (achievement, level) ->
+ Box {
+ AchievementIcon(icon = achievement.icon, level = level)
+ // clickable area as separate box because the ripple should be on top of all of it
+ // while the icon should not be clipped within the achievement frame
+ Box(
+ Modifier
+ .matchParentSize()
+ .clip(AchievementFrameShape)
+ .clickable { onClickAchievement(achievement, level) }
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewLazyAchievementsGrid() {
+ LazyAchievementsGrid(
+ achievements = achievements.map { it to (1..20).random() },
+ onClickAchievement = { achievement, level -> }
+ )
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/GroupedLinksAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/GroupedLinksAdapter.kt
deleted file mode 100644
index 24557a79aef..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/GroupedLinksAdapter.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.links
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.core.view.isGone
-import androidx.recyclerview.widget.RecyclerView
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.data.user.achievements.Link
-import de.westnordost.streetcomplete.data.user.achievements.LinkCategory
-import de.westnordost.streetcomplete.databinding.RowLinkCategoryItemBinding
-import de.westnordost.streetcomplete.databinding.RowLinkItemBinding
-
-/** Adapter for a list of links, grouped by category */
-class GroupedLinksAdapter(links: List, private val onClickLink: (url: String) -> Unit) :
- RecyclerView.Adapter() {
-
- private val groupedLinks: List- = links
- .groupBy { it.category }
- .flatMap { entry ->
- val category = entry.key
- val linksInCategory = entry.value
- listOf(CategoryItem(category)) + linksInCategory.map { LinkItem(it) }
- }
-
- private val itemCount = groupedLinks.size
-
- override fun getItemCount(): Int = itemCount
-
- override fun getItemViewType(position: Int): Int = when (groupedLinks[position]) {
- is CategoryItem -> CATEGORY
- is LinkItem -> LINK
- }
-
- fun shouldItemSpanFullWidth(position: Int): Boolean = groupedLinks[position] is CategoryItem
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
- val inflater = LayoutInflater.from(parent.context)
- return when (viewType) {
- CATEGORY -> CategoryViewHolder(RowLinkCategoryItemBinding.inflate(inflater, parent, false))
- LINK -> LinkViewHolder(RowLinkItemBinding.inflate(inflater, parent, false))
- else -> throw IllegalStateException("Unexpected viewType $viewType")
- }
- }
-
- override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
- when (val item = groupedLinks[position]) {
- is CategoryItem -> (holder as CategoryViewHolder).onBind(item.category)
- is LinkItem -> (holder as LinkViewHolder).onBind(item.link)
- }
- }
-
- inner class LinkViewHolder(val binding: RowLinkItemBinding) : RecyclerView.ViewHolder(binding.root) {
- fun onBind(with: Link) {
- if (with.icon != null) {
- binding.linkIconImageView.setImageResource(with.icon)
- } else {
- binding.linkIconImageView.setImageDrawable(null)
- }
- binding.linkTitleTextView.text = with.title
- if (with.description != null) {
- binding.linkDescriptionTextView.setText(with.description)
- } else {
- binding.linkDescriptionTextView.text = ""
- }
- binding.root.setOnClickListener { onClickLink(with.url) }
- }
- }
-
- inner class CategoryViewHolder(val binding: RowLinkCategoryItemBinding) : RecyclerView.ViewHolder(binding.root) {
- fun onBind(with: LinkCategory) {
- binding.linkCategoryTitleTextView.setText(with.title)
- val description = with.description
- binding.linkCategoryDescriptionTextView.isGone = description == null
- if (description != null) {
- binding.linkCategoryDescriptionTextView.setText(description)
- } else {
- binding.linkCategoryDescriptionTextView.text = ""
- }
- }
- }
-
- companion object {
- private const val LINK = 0
- private const val CATEGORY = 1
- }
-}
-
-private sealed interface Item
-
-private data class CategoryItem(val category: LinkCategory) : Item
-private data class LinkItem(val link: Link) : Item
-
-private val LinkCategory.title: Int get() = when (this) {
- LinkCategory.INTRO -> R.string.link_category_intro_title
- LinkCategory.EDITORS -> R.string.link_category_editors_title
- LinkCategory.MAPS -> R.string.link_category_maps_title
- LinkCategory.SHOWCASE -> R.string.link_category_showcase_title
- LinkCategory.GOODIES -> R.string.link_category_goodies_title
-}
-
-private val LinkCategory.description: Int get() = when (this) {
- LinkCategory.INTRO -> R.string.link_category_intro_description
- LinkCategory.EDITORS -> R.string.link_category_editors_description
- LinkCategory.SHOWCASE -> R.string.link_category_showcase_description
- LinkCategory.MAPS -> R.string.link_category_maps_description
- LinkCategory.GOODIES -> R.string.link_category_goodies_description
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LazyLinksColumn.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LazyLinksColumn.kt
new file mode 100644
index 00000000000..8c035c2c836
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LazyLinksColumn.kt
@@ -0,0 +1,79 @@
+package de.westnordost.streetcomplete.screens.user.links
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.PreviewScreenSizes
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.data.user.achievements.Link
+import de.westnordost.streetcomplete.data.user.achievements.links
+
+@Composable
+fun LazyGroupedLinksColumn(
+ allLinks: List,
+ onClickLink: (url: String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) {
+ // TODO Compose: revisit animate-in of list items when androidx.compose.animation 1.7 is stable
+ // probably Modifier.animateItem or Modifier.animateEnterExit
+ LazyLinksGrid(modifier, contentPadding = contentPadding) {
+ val groupedLinks = allLinks.groupBy { it.category }.map { (k,v) -> k to v }
+ for ((category, links) in groupedLinks) {
+ item(
+ span = { GridItemSpan(maxLineSpan) },
+ contentType = category::class
+ ) {
+ LinkCategoryItem(category)
+ }
+ items(links, contentType = { it::class }) { link ->
+ LinkItem(link, onClickLink)
+ }
+ }
+ }
+}
+
+@Composable
+fun LazyLinksColumn(
+ links: List,
+ onClickLink: (url: String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) {
+ LazyLinksGrid(modifier, contentPadding = contentPadding) {
+ items(links) { link ->
+ LinkItem(link, onClickLink)
+ }
+ }
+}
+
+@Composable
+private fun LazyLinksGrid(
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ content: LazyGridScope.() -> Unit
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 280.dp),
+ modifier = modifier,
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ content = content
+ )
+}
+
+@PreviewScreenSizes
+@Composable
+fun PreviewLazyGroupedLinksColumn() {
+ LazyGroupedLinksColumn(links,
+ onClickLink = {},
+ contentPadding = PaddingValues(16.dp)
+ )
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinkItem.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinkItem.kt
new file mode 100644
index 00000000000..026e457fd69
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinkItem.kt
@@ -0,0 +1,93 @@
+package de.westnordost.streetcomplete.screens.user.links
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.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.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.user.achievements.Link
+import de.westnordost.streetcomplete.data.user.achievements.LinkCategory
+import de.westnordost.streetcomplete.ui.theme.titleLarge
+import de.westnordost.streetcomplete.ui.theme.titleMedium
+import de.westnordost.streetcomplete.ui.theme.titleSmall
+
+@Composable
+fun LinkCategoryItem(category: LinkCategory, modifier: Modifier = Modifier) {
+ Column {
+ Spacer(modifier = modifier.padding(top = 8.dp))
+ Text(stringResource(category.title), style = MaterialTheme.typography.titleLarge)
+ Text(stringResource(category.description), style = MaterialTheme.typography.body1)
+ }
+}
+
+private val LinkCategory.title: Int get() = when (this) {
+ LinkCategory.INTRO -> R.string.link_category_intro_title
+ LinkCategory.EDITORS -> R.string.link_category_editors_title
+ LinkCategory.MAPS -> R.string.link_category_maps_title
+ LinkCategory.SHOWCASE -> R.string.link_category_showcase_title
+ LinkCategory.GOODIES -> R.string.link_category_goodies_title
+}
+
+private val LinkCategory.description: Int get() = when (this) {
+ LinkCategory.INTRO -> R.string.link_category_intro_description
+ LinkCategory.EDITORS -> R.string.link_category_editors_description
+ LinkCategory.SHOWCASE -> R.string.link_category_showcase_description
+ LinkCategory.MAPS -> R.string.link_category_maps_description
+ LinkCategory.GOODIES -> R.string.link_category_goodies_description
+}
+
+@Composable
+fun LinkItem(link: Link, onClickLink: (url: String) -> Unit, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier
+ .clickable { onClickLink(link.url) }
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // if no icon, the space should still be filled
+ Box(Modifier.size(44.dp)) {
+ if (link.icon != null) {
+ Image(painterResource(link.icon), null, Modifier.fillMaxSize())
+ }
+ }
+ Column {
+ Text(link.title, style = MaterialTheme.typography.titleSmall)
+ if (link.description != null) {
+ Text(stringResource(link.description), style = MaterialTheme.typography.body2)
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun LinkCategoryItemPreview() {
+ LinkCategoryItem(LinkCategory.GOODIES)
+}
+
+@Preview
+@Composable
+fun LinkItemPreview() {
+ LinkItem(Link(
+ "wiki",
+ "https://wiki.openstreetmap.org",
+ "OpenStreetMap Wiki",
+ LinkCategory.INTRO,
+ R.drawable.ic_link_wiki,
+ R.string.link_wiki_description
+ ), {})
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksAdapter.kt
deleted file mode 100644
index e4a941d0a11..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksAdapter.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.links
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import de.westnordost.streetcomplete.data.user.achievements.Link
-import de.westnordost.streetcomplete.databinding.RowLinkItemBinding
-import de.westnordost.streetcomplete.view.ListAdapter
-
-/** Adapter for a list of links */
-class LinksAdapter(links: List, private val onClickLink: (url: String) -> Unit) :
- ListAdapter(links) {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
- ViewHolder(RowLinkItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
-
- inner class ViewHolder(val binding: RowLinkItemBinding) : ListAdapter.ViewHolder(binding) {
- override fun onBind(with: Link) {
- if (with.icon != null) {
- binding.linkIconImageView.setImageResource(with.icon)
- } else {
- binding.linkIconImageView.setImageDrawable(null)
- }
- binding.linkTitleTextView.text = with.title
- if (with.description != null) {
- binding.linkDescriptionTextView.setText(with.description)
- } else {
- binding.linkDescriptionTextView.text = ""
- }
- itemView.setOnClickListener { onClickLink(with.url) }
- }
- }
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksFragment.kt
index f0f6df3cbf6..174a516792e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksFragment.kt
@@ -1,72 +1,16 @@
package de.westnordost.streetcomplete.screens.user.links
import android.os.Bundle
+import android.view.LayoutInflater
import android.view.View
-import androidx.core.view.isGone
+import android.view.ViewGroup
import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.databinding.FragmentLinksBinding
-import de.westnordost.streetcomplete.util.ktx.awaitLayout
-import de.westnordost.streetcomplete.util.ktx.dpToPx
-import de.westnordost.streetcomplete.util.ktx.observe
-import de.westnordost.streetcomplete.util.ktx.openUri
-import de.westnordost.streetcomplete.util.viewBinding
-import de.westnordost.streetcomplete.view.GridLayoutSpacingItemDecoration
-import kotlinx.coroutines.launch
+import de.westnordost.streetcomplete.ui.util.composableContent
import org.koin.androidx.viewmodel.ext.android.viewModel
-/** Shows the user's unlocked links */
-class LinksFragment : Fragment(R.layout.fragment_links) {
-
+class LinksFragment : Fragment() {
private val viewModel by viewModel()
- private val binding by viewBinding(FragmentLinksBinding::bind)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- binding.emptyText.isGone = true
-
- observe(viewModel.isSynchronizingStatistics) { isSynchronizingStatistics ->
- binding.emptyText.setText(
- if (isSynchronizingStatistics) {
- R.string.stats_are_syncing
- } else {
- R.string.links_empty
- }
- )
- }
-
- viewLifecycleOwner.lifecycleScope.launch {
- view.awaitLayout()
-
- val minCellWidth = resources.dpToPx(280)
- val spanCount = (view.width / minCellWidth).toInt()
- observe(viewModel.links) { links ->
- binding.emptyText.isGone = links == null || links.isNotEmpty()
- if (links != null) {
- val adapter = GroupedLinksAdapter(links, ::openUri)
- // headers should span the whole width
- val spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
- override fun getSpanSize(position: Int): Int =
- if (adapter.shouldItemSpanFullWidth(position)) spanCount else 1
- }
- spanSizeLookup.isSpanGroupIndexCacheEnabled = true
- spanSizeLookup.isSpanIndexCacheEnabled = true
- // vertical grid layout
- val layoutManager = GridLayoutManager(requireContext(), spanCount, RecyclerView.VERTICAL, false)
- layoutManager.spanSizeLookup = spanSizeLookup
- // spacing *between* the items
- val itemSpacing = resources.getDimensionPixelSize(R.dimen.links_item_margin)
- binding.linksList.addItemDecoration(GridLayoutSpacingItemDecoration(itemSpacing))
- binding.linksList.layoutManager = layoutManager
- binding.linksList.adapter = adapter
- binding.linksList.clipToPadding = false
- }
- }
- }
- }
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ composableContent { LinksScreen(viewModel) }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksScreen.kt
new file mode 100644
index 00000000000..aafedc6901f
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/links/LinksScreen.kt
@@ -0,0 +1,39 @@
+package de.westnordost.streetcomplete.screens.user.links
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.screens.user.CenteredLargeTitleHint
+import de.westnordost.streetcomplete.util.ktx.openUri
+
+/** Shows the user's unlocked links */
+@Composable
+fun LinksScreen(viewModel: LinksViewModel) {
+ val isSynchronizingStatistics by viewModel.isSynchronizingStatistics.collectAsState()
+ val links by viewModel.links.collectAsState()
+
+ val allLinks = links
+ if (allLinks != null) {
+ if (allLinks.isNotEmpty()) {
+ val context = LocalContext.current
+ LazyGroupedLinksColumn(
+ allLinks = allLinks,
+ onClickLink = { context.openUri(it) },
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp)
+ )
+ } else {
+ CenteredLargeTitleHint(stringResource(
+ if (isSynchronizingStatistics) R.string.stats_are_syncing
+ else R.string.links_empty
+ ))
+ }
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveDrawable.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveDrawable.kt
deleted file mode 100644
index cddbbcf28bf..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveDrawable.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.profile
-
-import android.graphics.Canvas
-import android.graphics.ColorFilter
-import android.graphics.Paint
-import android.graphics.PixelFormat
-import android.graphics.drawable.Drawable
-import de.westnordost.streetcomplete.util.ktx.systemTimeNow
-import kotlinx.datetime.DateTimeUnit
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.minus
-import kotlinx.datetime.toLocalDateTime
-import java.text.DateFormatSymbols
-import kotlin.math.ceil
-import kotlin.math.floor
-
-/** Draws a github-style like days-active graphic */
-class DatesActiveDrawable(
- private val datesActive: Set,
- private val datesActiveRange: Int,
- private val boxSize: Float,
- private val padding: Float,
- private val roundRectRadius: Float,
- textColor: Int
-) : Drawable() {
-
- private val dayOffset = 7 - systemTimeNow().toLocalDateTime(TimeZone.UTC).dayOfWeek.value
- private val weekdaysWidth: Float
- private val textHeight: Float = boxSize * 0.8f
-
- private val greenBoxPaint = Paint().apply { setARGB(255, 128, 177, 88) }
- private val emptyBoxPaint = Paint().apply { setARGB(60, 128, 128, 128) }
- private val textPaint = Paint().apply {
- color = textColor
- textSize = textHeight
- }
-
- private val weekdays: List
- private val months: List
-
- init {
- val symbols = DateFormatSymbols.getInstance()
- weekdays = Array(7) { symbols.shortWeekdays[1 + (it + 1) % 7] }.toList()
- months = symbols.shortMonths.toList()
- weekdaysWidth = weekdays.maxOf { textPaint.measureText(it) }
- }
-
- override fun draw(canvas: Canvas) {
- var time = systemTimeNow()
-
- val width = ceil((dayOffset + datesActiveRange) / 7.0).toInt()
- val height = 7
-
- // weekdays
- for (i in 0 until 7) {
- val top = textHeight + textHeight + i * (boxSize + padding)
- canvas.drawText(weekdays[i], 0f, top, textPaint)
- }
-
- // grid + months
- for (i in 0..datesActiveRange) {
- val date = time.toLocalDateTime(TimeZone.UTC).date
-
- val y = (height - 1) - (i + dayOffset) % height
- val x = (width - 1) - floor(((i + dayOffset) / height).toDouble()).toInt()
-
- val left = getLeft(x)
- val top = getTop(y)
-
- canvas.drawRoundRect(
- left,
- top,
- left + boxSize,
- top + boxSize,
- roundRectRadius,
- roundRectRadius,
- if (date in datesActive) greenBoxPaint else emptyBoxPaint
- )
-
- if (date.dayOfMonth == 1) {
- // center text within month
- val right = getLeft(x + 4)
- val text = months[date.month.value - 1]
- val textWidth = textPaint.measureText(text)
- val start = left + (right - left - textWidth) / 2f
- canvas.drawText(months[date.month.value - 1], start, textHeight, textPaint)
- }
-
- time = time.minus(1, DateTimeUnit.DAY, TimeZone.UTC)
- }
- }
-
- private fun getLeft(x: Int): Float =
- weekdaysWidth + padding * 2 + x * (boxSize + padding)
-
- private fun getTop(y: Int): Float =
- textHeight + padding * 2 + y * (boxSize + padding)
-
- override fun getIntrinsicWidth(): Int {
- val gridWidth = ceil((dayOffset + datesActiveRange) / 7.0).toInt()
- return getLeft(gridWidth).toInt()
- }
-
- override fun getIntrinsicHeight(): Int {
- val gridHeight = 7
- return getTop(gridHeight).toInt()
- }
-
- override fun setAlpha(alpha: Int) {
- // not supported
- }
-
- override fun setColorFilter(colorFilter: ColorFilter?) {
- // not supported
- }
-
- @Deprecated("Deprecated in Java")
- override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveTable.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveTable.kt
new file mode 100644
index 00000000000..965da3fe22f
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/DatesActiveTable.kt
@@ -0,0 +1,133 @@
+package de.westnordost.streetcomplete.screens.user.profile
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import de.westnordost.streetcomplete.ui.theme.GrassGreen
+import de.westnordost.streetcomplete.ui.theme.surfaceContainer
+import de.westnordost.streetcomplete.ui.util.pxToDp
+import de.westnordost.streetcomplete.util.ktx.systemTimeNow
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.minus
+import kotlinx.datetime.toLocalDateTime
+import java.text.DateFormatSymbols
+import kotlin.math.ceil
+
+/** Draws a github-style like days-active graphic */
+@Composable
+fun DatesActiveTable(
+ datesActive: Set,
+ datesActiveRange: Int,
+ modifier: Modifier = Modifier,
+ boxColor: Color = GrassGreen,
+ emptyBoxColor: Color = MaterialTheme.colors.surfaceContainer,
+ cellPadding: Dp = 2.dp,
+ cellCornerRadius: Dp = 6.dp,
+) {
+ BoxWithConstraints(modifier) {
+ // no data, no table
+ if (datesActiveRange <= 0) return@BoxWithConstraints
+
+ val dayOffset = 7 - systemTimeNow().toLocalDateTime(TimeZone.UTC).dayOfWeek.value
+
+ val verticalCells = 7 // days in a week
+ val horizontalCells = ceil((dayOffset + datesActiveRange).toDouble() / verticalCells).toInt()
+
+ val textMeasurer = rememberTextMeasurer()
+
+ val textStyle = MaterialTheme.typography.body2
+ val symbols = DateFormatSymbols.getInstance()
+ val weekdays = Array(7) { symbols.shortWeekdays[1 + (it + 1) % 7] }
+ val months = symbols.shortMonths
+
+ val weekdayColumnWidth = weekdays.maxOf { textMeasurer.measure(it, textStyle).size.width }.pxToDp()
+ val textHeight = textMeasurer.measure(months[0]).size.height.pxToDp()
+
+ // stretch 100% width and determine available box size and then the height from that
+ val cellSize = (maxWidth - weekdayColumnWidth - cellPadding * 2) / horizontalCells - cellPadding
+ val height = textHeight + cellPadding * 2 + (cellSize + cellPadding) * verticalCells
+
+ val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+ val marginLeft = if (isLtr) weekdayColumnWidth else 0.dp
+
+ fun getLeft(x: Int) = marginLeft + cellPadding * 2 + (cellSize + cellPadding) * x
+ fun getTop(y: Int) = textHeight + cellPadding * 2 + (cellSize + cellPadding) * y
+
+ Canvas(Modifier.size(maxWidth, height)) {
+ // weekdays
+ for (i in 0 until 7) {
+ val top = getTop(i)
+ val bottom = (getTop(i + 1) - cellPadding)
+ val centerTop = top + (bottom - top - textHeight) / 2 // center text vertically
+ val left = if (isLtr) 0.dp else getLeft(horizontalCells)
+ drawText(
+ textMeasurer,
+ size = Size(weekdayColumnWidth.toPx(), textHeight.toPx()),
+ text = weekdays[i],
+ topLeft = Offset(left.toPx(), centerTop.toPx()),
+ style = textStyle,
+ )
+ }
+ if (horizontalCells < 1) return@Canvas
+ // grid + months
+ for (i in 0 ..< datesActiveRange) {
+ val time = systemTimeNow().minus(i, DateTimeUnit.DAY, TimeZone.UTC)
+ val date = time.toLocalDateTime(TimeZone.UTC).date
+
+ val y = (verticalCells - 1) - (i + dayOffset) % verticalCells
+ val xLtr = (horizontalCells - 1) - (i + dayOffset) / verticalCells
+ val x = if (isLtr) xLtr else (horizontalCells - 1) - xLtr
+
+ val left = getLeft(x).toPx()
+ val top = getTop(y).toPx()
+
+ drawRoundRect(
+ color = if (date in datesActive) boxColor else emptyBoxColor,
+ topLeft = Offset(left, top),
+ size = Size(cellSize.toPx(), cellSize.toPx()),
+ cornerRadius = CornerRadius(cellCornerRadius.toPx(), cellCornerRadius.toPx())
+ )
+
+ if (date.dayOfMonth == 1) {
+ drawText(
+ textMeasurer,
+ size = Size(Float.NaN, textHeight.toPx()),
+ text = months[date.month.value - 1],
+ topLeft = Offset(left, 0f),
+ style = textStyle
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Preview(locale = "ar", fontScale = 1.8f) // right-to-left and large text
+@Preview(device = Devices.NEXUS_7) // large screen
+@Composable
+fun DatesActivePreview() {
+ DatesActiveTable(
+ datesActive = IntArray(30) { (0..90).random() }.map {
+ systemTimeNow().minus(it, DateTimeUnit.DAY, TimeZone.UTC).toLocalDateTime(TimeZone.UTC).date
+ }.toSet(),
+ datesActiveRange = 90
+ )
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreath.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreath.kt
new file mode 100644
index 00000000000..b4bffecf0e3
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreath.kt
@@ -0,0 +1,80 @@
+package de.westnordost.streetcomplete.screens.user.profile
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.inset
+import androidx.compose.ui.graphics.drawscope.withTransform
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.ui.theme.LeafGreen
+
+@Composable
+fun LaurelWreath(
+ modifier: Modifier = Modifier,
+ color: Color = LeafGreen,
+ progress: Float = 1f
+) {
+ if (progress < 0.1f) return
+
+ val leafPair = painterResource(R.drawable.laurel_leaf_pair)
+ val leafPairGrowing = painterResource(R.drawable.laurel_leaf_pair)
+ val leafSingle = painterResource(R.drawable.laurel_leaf_ending)
+
+ Box(modifier.aspectRatio(1f).drawBehind {
+ val maxLeafs = 10f
+ val leafs = (maxLeafs * progress).toInt()
+ val leafSize = size * 2f / maxLeafs
+ val leafY = center.y - leafSize.center.y
+ val maxLeafAngle = 160.0f
+
+ fun drawLeaf(leaf: Painter, angle: Float, scale: Float = 1f) {
+ val offset = (1f - scale) * leafSize.width
+ for (scaleX in listOf(1f, -1f)) {
+ withTransform({
+ scale(scaleX, 1f)
+ rotate(angle)
+ translate(top = leafY + offset, left = offset / 2f)
+ }) {
+ with(leaf) { draw(leafSize * scale, colorFilter = ColorFilter.tint(color)) }
+ }
+ }
+ }
+
+ // stalk
+ inset(leafSize.width / 2.1f) {
+ drawArc(
+ color = color,
+ startAngle = 90f - maxLeafAngle * progress,
+ sweepAngle = maxLeafAngle * progress * 2,
+ useCenter = false,
+ style = Stroke(leafSize.width * 0.1f),
+ )
+ }
+
+ // leaves left and right
+ for (i in 0 ..< leafs) {
+ drawLeaf(
+ leaf = if (i == leafs -1) leafPairGrowing else leafPair,
+ angle = (i + 1f) * maxLeafAngle / maxLeafs - 90f,
+ scale = if (i == leafs - 1) maxLeafs * progress % 1f else 1f
+ )
+ }
+
+ // leading leaf
+ drawLeaf(leafSingle, maxLeafAngle * progress - 90f)
+ })
+}
+
+@Preview @Composable
+fun LaurelWreathBadgePreview() {
+ LaurelWreath(progress = 1.0f)
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreathBadge.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreathBadge.kt
new file mode 100644
index 00000000000..430a1b9e9af
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/LaurelWreathBadge.kt
@@ -0,0 +1,82 @@
+package de.westnordost.streetcomplete.screens.user.profile
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.drawBehind
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import de.westnordost.streetcomplete.ui.theme.GrassGray
+import de.westnordost.streetcomplete.ui.theme.GrassGreen
+import de.westnordost.streetcomplete.ui.theme.White
+import de.westnordost.streetcomplete.ui.theme.titleLarge
+import de.westnordost.streetcomplete.ui.util.toDp
+
+@Composable
+fun LaurelWreathBadge(
+ label: String,
+ value: String,
+ progress: Float,
+ modifier: Modifier = Modifier,
+ animationDuration: Int = 2000,
+ animationDelay: Int = 0,
+ startBackgroundColor: Color = GrassGray,
+ finalBackgroundColor: Color = GrassGreen,
+) {
+ Column(
+ modifier = modifier.padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val animation = remember { Animatable(0f) }
+ LaunchedEffect(progress) {
+ animation.animateTo(progress, tween(
+ durationMillis = animationDuration,
+ delayMillis = animationDelay)
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .size(128.sp.toDp()) // scale size with font scaling
+ .drawBehind {
+ drawCircle(color = startBackgroundColor)
+ drawCircle(color = finalBackgroundColor, alpha = animation.value)
+ }
+ .padding(4.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ LaurelWreath(progress = animation.value)
+ Text(
+ text = value,
+ color = White,
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ Text(
+ text = label,
+ style = MaterialTheme.typography.body2,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Preview
+@Composable
+fun BadgePreview() {
+ LaurelWreathBadge(label = "Label\ntext", value = "#12", progress = 1.0f)
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
index 53f1e171ed1..aa3b9c455af 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileFragment.kt
@@ -1,218 +1,16 @@
package de.westnordost.streetcomplete.screens.user.profile
-import android.animation.Animator
-import android.animation.ValueAnimator
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
import android.os.Bundle
+import android.view.LayoutInflater
import android.view.View
-import android.view.animation.AccelerateDecelerateInterpolator
-import android.widget.TextView
-import androidx.core.view.isGone
+import android.view.ViewGroup
import androidx.fragment.app.Fragment
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics
-import de.westnordost.streetcomplete.databinding.FragmentProfileBinding
-import de.westnordost.streetcomplete.util.ktx.createBitmap
-import de.westnordost.streetcomplete.util.ktx.dpToPx
-import de.westnordost.streetcomplete.util.ktx.getLocationInWindow
-import de.westnordost.streetcomplete.util.ktx.observe
-import de.westnordost.streetcomplete.util.ktx.openUri
-import de.westnordost.streetcomplete.util.ktx.pxToDp
-import de.westnordost.streetcomplete.util.viewBinding
-import de.westnordost.streetcomplete.view.LaurelWreathDrawable
+import de.westnordost.streetcomplete.ui.util.composableContent
import org.koin.androidx.viewmodel.ext.android.viewModel
-import java.util.Locale
-import kotlin.math.max
-import kotlin.math.min
-
-/** Shows the user profile: username, avatar, star count and a hint regarding unpublished changes */
-class ProfileFragment : Fragment(R.layout.fragment_profile) {
-
- private lateinit var anonAvatar: Bitmap
-
- private val animations = ArrayList()
+class ProfileFragment : Fragment() {
private val viewModel by viewModel()
- private val binding by viewBinding(FragmentProfileBinding::bind)
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- anonAvatar = context.getDrawable(R.drawable.ic_osm_anon_avatar)!!.createBitmap()
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- binding.localRankText.background = LaurelWreathDrawable(resources)
- binding.globalRankText.background = LaurelWreathDrawable(resources)
- binding.daysActiveText.background = LaurelWreathDrawable(resources)
- binding.achievementLevelsText.background = LaurelWreathDrawable(resources)
- binding.currentWeekLocalRankText.background = LaurelWreathDrawable(resources)
- binding.currentWeekGlobalRankText.background = LaurelWreathDrawable(resources)
-
- binding.logoutButton.setOnClickListener { viewModel.logOutUser() }
- binding.profileButton.setOnClickListener {
- openUri("https://www.openstreetmap.org/user/" + viewModel.userName.value)
- }
-
- observe(viewModel.userName) { name ->
- binding.userNameTextView.text = name
- }
- observe(viewModel.userAvatarFile) { file ->
- val avatar = if (file.exists()) BitmapFactory.decodeFile(file.path) else anonAvatar
- binding.userAvatarImageView.setImageBitmap(avatar)
- }
- observe(viewModel.editCount) { count ->
- binding.editCountText.text = count.toString()
- }
- observe(viewModel.editCountCurrentWeek) { count ->
- binding.currentWeekEditCountText.text = count.toString()
- }
- observe(viewModel.achievementLevels) { levels ->
- binding.achievementLevelsContainer.isGone = levels <= 0
- binding.achievementLevelsText.text = levels.toString()
- binding.achievementLevelsText.background.level = min(levels / 2, 100) * 100
- }
- observe(viewModel.unsyncedChangesCount) { count ->
- binding.unpublishedEditCountText.text = getString(R.string.unsynced_quests_description, count)
- binding.unpublishedEditCountText.isGone = count <= 0
- }
- observe(viewModel.datesActive) { (datesActive, range) ->
- val context = requireContext()
- binding.datesActiveView.setImageDrawable(DatesActiveDrawable(
- datesActive.toSet(),
- range,
- context.resources.dpToPx(18),
- context.resources.dpToPx(2),
- context.resources.dpToPx(4),
- context.resources.getColor(R.color.hint_text)
- ))
- }
- observe(viewModel.daysActive) { daysActive ->
- binding.daysActiveContainer.isGone = daysActive <= 0
- binding.daysActiveText.text = daysActive.toString()
- binding.daysActiveText.background.level = min(daysActive + 20, 100) * 100
- }
- observe(viewModel.rank) { rank ->
- updateRank(rank, viewModel.editCount.value)
- }
- observe(viewModel.rankCurrentWeek) { rank ->
- updateRankCurrentWeek(rank, viewModel.editCountCurrentWeek.value)
- }
- observe(viewModel.biggestSolvedCountCountryStatistics) { statistics ->
- updateLocalRank(statistics)
- }
- observe(viewModel.biggestSolvedCountCurrentWeekCountryStatistics) { statistics ->
- updateLocalRankCurrentWeek(statistics)
- }
- }
-
- override fun onPause() {
- super.onPause()
- animations.forEach { it.pause() }
- }
-
- override fun onResume() {
- super.onResume()
- animations.forEach { it.resume() }
- }
-
- override fun onStop() {
- super.onStop()
- animations.forEach { it.end() }
- animations.clear()
- }
-
- private fun updateRank(rank: Int, editCount: Int) {
- val showRank = rank > 0 && editCount > 100
- binding.globalRankContainer.isGone = !showRank
- if (showRank) {
- updateRank(
- rank,
- viewModel.lastShownGlobalUserRank,
- ::getScaledGlobalRank,
- binding.globalRankText
- )
- viewModel.lastShownGlobalUserRank = rank
- }
- }
-
- private fun updateRankCurrentWeek(rank: Int, editCount: Int) {
- val showRank = rank > 0 && editCount > 100
- binding.currentWeekGlobalRankContainer.isGone = !showRank
- if (showRank) {
- updateRank(
- rank,
- viewModel.lastShownGlobalUserRankCurrentWeek,
- ::getScaledGlobalRank,
- binding.currentWeekGlobalRankText
- )
- }
- viewModel.lastShownGlobalUserRankCurrentWeek = rank
- }
-
- private fun updateLocalRank(statistics: CountryStatistics?) {
- val showRank = statistics?.rank != null && statistics.count > 50
- binding.localRankContainer.isGone = !showRank
- if (showRank) {
- updateRank(
- statistics?.rank ?: 0,
- viewModel.lastShownUserLocalCountryStatistics?.rank,
- ::getScaledLocalRank,
- binding.localRankText
- )
- viewModel.lastShownUserLocalCountryStatistics = statistics
- binding.localRankLabel.text = getLocalRankText(statistics?.countryCode)
- }
- }
-
- private fun updateLocalRankCurrentWeek(statistics: CountryStatistics?) {
- val showRank = statistics?.rank != null && statistics.count > 5
- binding.currentWeekLocalRankContainer.isGone = !showRank
- if (showRank) {
- updateRank(
- statistics?.rank ?: 0,
- viewModel.lastShownUserLocalCountryStatisticsCurrentWeek?.rank,
- ::getScaledLocalRank,
- binding.currentWeekLocalRankText
- )
- viewModel.lastShownUserLocalCountryStatisticsCurrentWeek = statistics
- binding.currentWeekLocalRankLabel.text = getLocalRankText(statistics?.countryCode)
- }
- }
-
- private fun getLocalRankText(countryCode: String?): String =
- getString(R.string.user_profile_local_rank, Locale("", countryCode ?: "").displayCountry)
-
- private fun updateRank(
- rank: Int,
- previousRank: Int?,
- getLevel: (rank: Int) -> Int,
- circle: TextView
- ) {
- val updateRank = { r: Int ->
- circle.text = "#$r"
- circle.background.level = getLevel(r)
- }
-
- if (previousRank == null || previousRank < rank) {
- updateRank(rank)
- } else {
- animate(previousRank, rank, circle, updateRank)
- }
- }
- private fun animate(previous: Int, now: Int, view: View, block: (value: Int) -> Unit) {
- block(previous)
- val anim = ValueAnimator.ofInt(previous, now)
- anim.duration = 3000
- anim.addUpdateListener { block(it.animatedValue as Int) }
- val p = view.getLocationInWindow()
- anim.startDelay = max(0, view.resources.pxToDp(p.y).toLong() * 12 - 2000)
- anim.interpolator = AccelerateDecelerateInterpolator()
- anim.start()
- animations.add(anim)
- }
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ composableContent { ProfileScreen(viewModel) }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileScreen.kt
new file mode 100644
index 00000000000..49acc88ad89
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileScreen.kt
@@ -0,0 +1,289 @@
+package de.westnordost.streetcomplete.screens.user.profile
+
+import android.graphics.BitmapFactory
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Button
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.coerceAtMost
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.ui.theme.headlineLarge
+import de.westnordost.streetcomplete.ui.theme.titleLarge
+import de.westnordost.streetcomplete.ui.util.toDp
+import de.westnordost.streetcomplete.util.ktx.openUri
+import java.util.Locale
+
+/** Shows the user profile: username, avatar, star count and a hint regarding unpublished changes */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun ProfileScreen(viewModel: ProfileViewModel) {
+ val userName by viewModel.userName.collectAsState()
+ val userAvatarFile by viewModel.userAvatarFile.collectAsState()
+
+ val editCount by viewModel.editCount.collectAsState()
+ val editCountCurrentWeek by viewModel.editCountCurrentWeek.collectAsState()
+ val unsyncedChangesCount by viewModel.unsyncedChangesCount.collectAsState()
+
+ val achievementLevels by viewModel.achievementLevels.collectAsState()
+ val rank by viewModel.rank.collectAsState()
+ val rankCurrentWeek by viewModel.rankCurrentWeek.collectAsState()
+ val biggestSolvedCountCountryStatistics by viewModel.biggestSolvedCountCountryStatistics.collectAsState()
+ val biggestSolvedCountCurrentWeekCountryStatistics by viewModel.biggestSolvedCountCurrentWeekCountryStatistics.collectAsState()
+
+ val daysActive by viewModel.daysActive.collectAsState()
+ val datesActive by viewModel.datesActive.collectAsState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Basic user info
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Image(
+ painter = getAvatarPainter(userAvatarFile.path)
+ ?: painterResource(R.drawable.ic_osm_anon_avatar),
+ contentDescription = null,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = userName.orEmpty(),
+ style = MaterialTheme.typography.headlineLarge
+ )
+ StarCount(editCount)
+ if (unsyncedChangesCount > 0) {
+ Text(
+ text = stringResource(
+ R.string.unsynced_quests_description,
+ unsyncedChangesCount
+ ),
+ style = MaterialTheme.typography.body2
+ )
+ }
+ }
+ }
+
+ // User button row
+
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ val context = LocalContext.current
+ Button(onClick = {
+ context.openUri("https://www.openstreetmap.org/user/" + viewModel.userName.value)
+ }) {
+ Icon(painterResource(R.drawable.ic_open_in_browser_24dp), null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.osm_profile).uppercase())
+ }
+ OutlinedButton(onClick = { viewModel.logOutUser() }) {
+ Text(stringResource(R.string.user_logout).uppercase())
+ }
+ }
+
+ Divider()
+
+ // Statistics
+
+ var delay = 0
+
+ if (editCount > 0) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceAround,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+
+ val localStats = biggestSolvedCountCountryStatistics
+ if (localStats?.rank != null) {
+ LocalRankBadge(localStats.rank, localStats.countryCode, getAnimationDelay(delay++))
+ }
+ if (rank > 0) {
+ RankBadge(rank, getAnimationDelay(delay++))
+ }
+ if (daysActive > 0) {
+ DaysActiveBadge(daysActive, getAnimationDelay(delay++))
+ }
+ if (achievementLevels > 0) {
+ AchievementLevelsBadge(achievementLevels, getAnimationDelay(delay++))
+ }
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.user_profile_current_week_title),
+ style = MaterialTheme.typography.titleLarge
+ )
+ StarCount(editCountCurrentWeek)
+ }
+ if (editCountCurrentWeek > 0) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceAround,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ val localStats = biggestSolvedCountCurrentWeekCountryStatistics
+ if (localStats?.rank != null) {
+ LocalRankCurrentWeekBadge(localStats.rank, localStats.countryCode, getAnimationDelay(delay++))
+ }
+ if (rankCurrentWeek > 0) {
+ RankCurrentWeekBadge(rankCurrentWeek, getAnimationDelay(delay++))
+ }
+ }
+ }
+ Text(
+ text = stringResource(R.string.user_profile_dates_mapped),
+ style = MaterialTheme.typography.titleLarge
+ )
+ BoxWithConstraints {
+ DatesActiveTable(
+ datesActive = datesActive.datesActive.toSet(),
+ datesActiveRange = datesActive.range,
+ modifier = Modifier.width(maxWidth.coerceAtMost(640.dp))
+ )
+ }
+ }
+}
+
+@Composable
+private fun LocalRankBadge(rank: Int, countryCode: String?, delay: Int) {
+ LaurelWreathBadge(
+ label = getLocalRankText(countryCode),
+ value = "#${rank}",
+ // 2024-05: rank 850 is about top 50% of users (~20 edits), rank 200 top 5% (~1500 edits)
+ // in Italy, which is the top 5 country in terms of contributions
+ progress = getRankProgress(rank, maxProgressAtRank = 200, minProgressAtRank = 850),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun RankBadge(rank: Int, delay: Int) {
+ LaurelWreathBadge(
+ label = stringResource(R.string.user_profile_global_rank),
+ value = "#$rank",
+ // 2024-05: rank 5000 is about top 50% of users (~200 edits), rank 1500 top 5% (~5000 edits)
+ progress = getRankProgress(rank, maxProgressAtRank = 1500, minProgressAtRank = 5000),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun DaysActiveBadge(days: Int, delay: Int) {
+ LaurelWreathBadge(
+ label = stringResource(R.string.user_profile_days_active),
+ value = days.toString(),
+ progress = ((days + 20) / 100f).coerceAtMost(1f),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun AchievementLevelsBadge(levels: Int, delay: Int) {
+ LaurelWreathBadge(
+ label = stringResource(R.string.user_profile_achievement_levels),
+ value = levels.toString(),
+ progress = ((levels / 2) / 100f).coerceAtMost(1f),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun LocalRankCurrentWeekBadge(rank: Int, countryCode: String?, delay: Int) {
+ LaurelWreathBadge(
+ label = getLocalRankText(countryCode),
+ value = "#$rank",
+ // 2024-05: rank 50 is about top 50% of users (~20 edits), rank 10 top 10% (~250 edits)
+ // in Italy, which is the top 5 country in terms of contributions
+ progress = getRankProgress(rank, maxProgressAtRank = 10, minProgressAtRank = 50),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun RankCurrentWeekBadge(rank: Int, delay: Int) {
+ LaurelWreathBadge(
+ label = stringResource(R.string.user_profile_global_rank),
+ value = "#$rank",
+ // 2024-05: rank 370 is about top 50% of users (~20 edits), rank 100 top 5% (~300 edits)
+ progress = getRankProgress(rank, maxProgressAtRank = 100, minProgressAtRank = 370),
+ animationDelay = delay
+ )
+}
+
+@Composable
+private fun StarCount(count: Int) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_star_48dp),
+ contentDescription = null,
+ modifier = Modifier.size(32.sp.toDp()) // icon should scale with the text
+ )
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+}
+
+private fun getAvatarPainter(filename: String?): Painter? =
+ filename?.let { BitmapFactory.decodeFile(it) }?.asImageBitmap()?.let { BitmapPainter(it) }
+
+private fun getAnimationDelay(step: Int) = step * 500
+
+@Composable
+private fun getLocalRankText(countryCode: String?): String =
+ stringResource(R.string.user_profile_local_rank, Locale("", countryCode ?: "").displayCountry)
+
+/** Translate the user's actual rank to a value from 0 (bad) to 1 (the best) */
+private fun getRankProgress(rank: Int, maxProgressAtRank: Int, minProgressAtRank: Int): Float =
+ ((minProgressAtRank - rank).toFloat() / (minProgressAtRank - maxProgressAtRank))
+ .coerceIn(0f, 1f)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModel.kt
index c9c628785f9..2497242675e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModel.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModel.kt
@@ -1,8 +1,7 @@
package de.westnordost.streetcomplete.screens.user.profile
+import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
-import com.russhwolf.settings.ObservableSettings
-import de.westnordost.streetcomplete.Prefs
import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource
import de.westnordost.streetcomplete.data.user.UserDataSource
import de.westnordost.streetcomplete.data.user.UserLoginStatusController
@@ -17,8 +16,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
import java.io.File
abstract class ProfileViewModel : ViewModel() {
@@ -41,14 +38,11 @@ abstract class ProfileViewModel : ViewModel() {
abstract val biggestSolvedCountCountryStatistics: StateFlow
abstract val biggestSolvedCountCurrentWeekCountryStatistics: StateFlow
- abstract var lastShownGlobalUserRank: Int?
- abstract var lastShownGlobalUserRankCurrentWeek: Int?
- abstract var lastShownUserLocalCountryStatistics: CountryStatistics?
- abstract var lastShownUserLocalCountryStatisticsCurrentWeek: CountryStatistics?
abstract fun logOutUser()
}
+@Immutable
data class DatesActiveInRange(val datesActive: List, val range: Int)
class ProfileViewModelImpl(
@@ -58,8 +52,7 @@ class ProfileViewModelImpl(
private val statisticsSource: StatisticsSource,
private val achievementsSource: AchievementsSource,
private val unsyncedChangesCountSource: UnsyncedChangesCountSource,
- private val avatarsCacheDirectory: File,
- private val prefs: ObservableSettings
+ private val avatarsCacheDirectory: File
) : ProfileViewModel() {
override val userName = MutableStateFlow(null)
@@ -75,37 +68,6 @@ class ProfileViewModelImpl(
override val biggestSolvedCountCountryStatistics = MutableStateFlow(null)
override val biggestSolvedCountCurrentWeekCountryStatistics = MutableStateFlow(null)
- override var lastShownGlobalUserRank: Int?
- set(value) {
- if (value != null) {
- prefs.putInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK, value)
- } else {
- prefs.remove(Prefs.LAST_SHOWN_USER_GLOBAL_RANK)
- }
- }
- get() = prefs.getIntOrNull(Prefs.LAST_SHOWN_USER_GLOBAL_RANK)
-
- override var lastShownGlobalUserRankCurrentWeek: Int?
- set(value) {
- if (value != null) {
- prefs.putInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK_CURRENT_WEEK, value)
- } else {
- prefs.remove(Prefs.LAST_SHOWN_USER_GLOBAL_RANK_CURRENT_WEEK)
- }
- }
- get() = prefs.getIntOrNull(Prefs.LAST_SHOWN_USER_GLOBAL_RANK_CURRENT_WEEK)
-
- override var lastShownUserLocalCountryStatistics: CountryStatistics?
- set(value) {
- prefs.putString(Prefs.LAST_SHOWN_USER_LOCAL_RANK, Json.encodeToString(value))
- }
- get() = prefs.getStringOrNull(Prefs.LAST_SHOWN_USER_LOCAL_RANK)?.let { Json.decodeFromString(it) }
-
- override var lastShownUserLocalCountryStatisticsCurrentWeek: CountryStatistics?
- set(value) {
- prefs.putString(Prefs.LAST_SHOWN_USER_LOCAL_RANK_CURRENT_WEEK, Json.encodeToString(value))
- }
- get() = prefs.getStringOrNull(Prefs.LAST_SHOWN_USER_LOCAL_RANK_CURRENT_WEEK)?.let { Json.decodeFromString(it) }
override fun logOutUser() {
launch { userLoginStatusController.logOut() }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/RankLevel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/RankLevel.kt
deleted file mode 100644
index 41fd517b748..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/RankLevel.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.westnordost.streetcomplete.screens.user.profile
-
-import kotlin.math.max
-import kotlin.math.min
-
-fun getScaledGlobalRank(rank: Int): Int {
- // note that global rank merges multiple people with the same score
- // in case that 1000 people made 11 edits all will have the same rank (say, 3814)
- // in case that 1000 people made 10 edits all will have the same rank (in this case - 3815)
- return getScaledRank(rank, 1000, 3800)
-}
-
-fun getScaledLocalRank(rank: Int): Int {
- // very tricky as area may have thousands of users or just few
- // lets say that being one of two active people in a given area is also praiseworthy
- return getScaledRank(rank, 10, 100)
-}
-
-/** Translate the user's actual rank to a value from 0 (bad) to 10000 (the best) */
-private fun getScaledRank(rank: Int, rankEnoughForFullMarks: Int, rankEnoughToStartGrowingReward: Int): Int {
- val ranksAboveThreshold = max(rankEnoughToStartGrowingReward - rank, 0)
- return min(10000, (ranksAboveThreshold * 10000.0 / (rankEnoughToStartGrowingReward - rankEnoughForFullMarks)).toInt())
-}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt
new file mode 100644
index 00000000000..1dfb0651eb6
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt
@@ -0,0 +1,81 @@
+package de.westnordost.streetcomplete.ui.theme
+
+import androidx.compose.material.Colors
+import androidx.compose.material.darkColors
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+
+/* Colors as they could be found on (illustrations of) traffic signs. */
+val TrafficRed = Color(0xffc1121c)
+val TrafficBlue = Color(0xff2255bb)
+val TrafficGreen = Color(0xff008351)
+val TrafficYellow = Color(0xffffd520)
+val TrafficBrown = Color(0xff73411f)
+val TrafficWhite = Color(0xffffffff)
+val TrafficBlack = Color(0xff000000)
+val TrafficGrayA = Color(0xff8e9291)
+val TrafficGrayB = Color(0xff4f5250)
+
+/* Colors for the teams in team mode. */
+val Team0 = Color(0xfff44336)
+val Team1 = Color(0xff529add)
+val Team2 = Color(0xffffdd55)
+val Team3 = Color(0xffca72e2)
+val Team4 = Color(0xff9bbe55)
+val Team5 = Color(0xfff4900c)
+val Team6 = Color(0xff9aa0ad)
+val Team7 = Color(0xff6390a0)
+val Team8 = Color(0xffa07a43)
+val Team9 = Color(0xff494EAD)
+val Team10 = Color(0xffAA335D)
+val Team11 = Color(0xff655555)
+
+val White = Color(0xffffffff)
+
+val GrassGreen = Color(0xff80b158)
+val GrassGray = Color(0xffb1b1b1)
+val LeafGreen = Color(0xff006a00)
+
+
+val LightColors = lightColors(
+ primary = Color(0xff4141ba),
+ primaryVariant = Color(0xff3939a3),
+ secondary = Color(0xffD14000),
+ secondaryVariant = Color(0xffF44336),
+ onPrimary = Color.White,
+ onSecondary = Color.White
+)
+
+val DarkColors = darkColors(
+ primary = Color(0xff4141ba),
+ primaryVariant = Color(0xff3939a3),
+ secondary = Color(0xffff6600),
+ secondaryVariant = Color(0xffF44336),
+ onPrimary = Color.White,
+ onSecondary = Color.White
+)
+
+val Colors.hint @Composable get() =
+ if (isLight) Color(0xff666666) else Color(0xff999999)
+
+val Colors.surfaceContainer @Composable get() =
+ if (isLight) Color(0xffdddddd) else Color(0xff222222)
+
+// use lighter tones (200) for increased contrast with dark background
+
+val Colors.logVerbose @Composable get() =
+ if (isLight) Color(0xff666666) else Color(0xff999999)
+
+val Colors.logDebug @Composable get() =
+ if (isLight) Color(0xff2196f3) else Color(0xff90caf9)
+
+val Colors.logInfo @Composable get() =
+ if (isLight) Color(0xff4caf50) else Color(0xffa5d6a7)
+
+val Colors.logWarning @Composable get() =
+ if (isLight) Color(0xffff9800) else Color(0xffffcc80)
+
+val Colors.logError @Composable get() =
+ if (isLight) Color(0xfff44336) else Color(0xffef9a9a)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Shapes.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Shapes.kt
new file mode 100644
index 00000000000..1fce497268f
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Shapes.kt
@@ -0,0 +1,11 @@
+package de.westnordost.streetcomplete.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes = Shapes(
+ // theme is more speech-bubbly than default
+ medium = RoundedCornerShape(10.dp),
+ large = RoundedCornerShape(10.dp)
+)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Theme.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Theme.kt
new file mode 100644
index 00000000000..d0793a15db6
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Theme.kt
@@ -0,0 +1,19 @@
+package de.westnordost.streetcomplete.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+
+@Composable
+fun AppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colors = if (darkTheme) DarkColors else LightColors
+ MaterialTheme(
+ colors = colors,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Typography.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Typography.kt
new file mode 100644
index 00000000000..5b3acf3da3d
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Typography.kt
@@ -0,0 +1,25 @@
+package de.westnordost.streetcomplete.ui.theme
+
+import androidx.compose.material.Typography
+import androidx.compose.ui.text.font.DeviceFontFamilyName
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+
+val Typography = Typography()
+
+val Typography.titleLarge get() = h6.copy(
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
+)
+val Typography.titleMedium get() = subtitle1.copy(
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
+)
+val Typography.titleSmall get() = subtitle2.copy(
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
+)
+
+val Typography.headlineLarge get() = h4.copy(fontWeight = FontWeight.Bold)
+val Typography.headlineSmall get() = h5.copy(fontWeight = FontWeight.Bold)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/ComposeView.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/ComposeView.kt
new file mode 100644
index 00000000000..9375ad2d86f
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/ComposeView.kt
@@ -0,0 +1,9 @@
+package de.westnordost.streetcomplete.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ComposeView
+import de.westnordost.streetcomplete.ui.theme.AppTheme
+
+fun ComposeView.content(content: @Composable () -> Unit) {
+ setContent { AppTheme { content() } }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/Fragment.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Fragment.kt
new file mode 100644
index 00000000000..2b4da0f0441
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Fragment.kt
@@ -0,0 +1,13 @@
+package de.westnordost.streetcomplete.ui.util
+
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.fragment.app.Fragment
+
+fun Fragment.composableContent(content: @Composable () -> Unit): View =
+ ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle))
+ content { content() }
+ }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/Modifiers.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Modifiers.kt
new file mode 100644
index 00000000000..e87e14007d2
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Modifiers.kt
@@ -0,0 +1,37 @@
+package de.westnordost.streetcomplete.ui.util
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.addOutline
+import androidx.compose.ui.unit.Density
+
+fun Modifier.backgroundWithPadding(
+ color: Color,
+ padding: PaddingValues,
+ shape: Shape = RectangleShape
+) = drawBehind {
+ val paddingLeft = padding.calculateLeftPadding(layoutDirection).toPx()
+ val paddingRight = padding.calculateRightPadding(layoutDirection).toPx()
+ val paddingTop = padding.calculateTopPadding().toPx()
+ val paddingBottom = padding.calculateBottomPadding().toPx()
+ val outline = shape.createOutline(
+ size = Size(
+ size.width - paddingLeft - paddingRight,
+ size.height - paddingTop - paddingBottom
+ ),
+ layoutDirection = layoutDirection,
+ density = Density(density)
+ )
+ val path = Path()
+ path.addOutline(outline)
+ path.translate(Offset(paddingLeft, paddingTop))
+
+ drawPath(path, color = color)
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/Utils.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Utils.kt
new file mode 100644
index 00000000000..67976bfaaf4
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Utils.kt
@@ -0,0 +1,20 @@
+package de.westnordost.streetcomplete.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.TextUnit
+
+@Composable
+fun Int.pxToDp() = with(LocalDensity.current) {
+ this@pxToDp.toDp()
+}
+
+@Composable
+fun Int.pxToSp() = with(LocalDensity.current) {
+ this@pxToSp.toSp()
+}
+
+@Composable
+fun TextUnit.toDp() = with(LocalDensity.current) {
+ this@toDp.toDp()
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/LaurelWreathDrawable.kt b/app/src/main/java/de/westnordost/streetcomplete/view/LaurelWreathDrawable.kt
deleted file mode 100644
index 150b9781277..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/view/LaurelWreathDrawable.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package de.westnordost.streetcomplete.view
-
-import android.content.res.Resources
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.ColorFilter
-import android.graphics.Paint
-import android.graphics.PixelFormat
-import android.graphics.drawable.Drawable
-import androidx.core.graphics.withRotation
-import de.westnordost.streetcomplete.R
-import de.westnordost.streetcomplete.util.ktx.flipHorizontally
-import de.westnordost.streetcomplete.util.ktx.getBitmapDrawable
-import kotlin.math.min
-
-/** Drawable providing decoration, suitable for a circular background
- * depends on what is set in level of drawable that ranges from 0 to 10000
- * for all values - colour of background circle is affected
- *
- * there can be also laurel wreath decoration:
- * for 10000 - fully grown wreath with all pretty elements
- * for values between 9999 and 1000 - may be losing elements as it gets smaller,
- * with the first loss at it goes down from 10000 to 9999
- * below 1000: guaranteed to have no decorative leaves at all
- */
-class LaurelWreathDrawable(private val resources: Resources) : Drawable() {
- private val pairOfLaurelLeafs = resources.getBitmapDrawable(R.drawable.ic_laurel_leaf_pair)
- private val horizontalEndingLeaf = resources.getBitmapDrawable(R.drawable.ic_laurel_leaf_ending)
- private val backgroundPaint = Paint()
-
- private val antiAliasPaint: Paint = Paint().apply {
- isAntiAlias = true
- isFilterBitmap = true
- }
-
- override fun onLevelChange(level: Int) = true
-
- override fun draw(canvas: Canvas) {
- val canvasWidth: Int = bounds.width()
- val canvasHeight: Int = bounds.height()
- val circleRadius: Float = min(canvasWidth, canvasHeight).toFloat() / 2f
-
- backgroundPaint.color = Color.HSVToColor(floatArrayOf(93f, level / 10000f * 0.5f, 0.72f))
-
- canvas.drawCircle((canvasWidth / 2).toFloat(), (canvasHeight / 2).toFloat(), circleRadius, backgroundPaint)
-
- if (level < 1000) return
-
- val decorationSegmentImageWidth = pairOfLaurelLeafs.intrinsicWidth // width is the same as intrinsicWidth
-
- val maximumDecorationSegmentCount = 11f
-
- val circleCenterX = canvasWidth / 2f
- val shownSegments = ((maximumDecorationSegmentCount - 1) * level / 10000).toInt()
- val howDistantIsDecorationFromCircleCenter = 0.78f
-
- for (i in 1..shownSegments) {
- var bitmap = pairOfLaurelLeafs.bitmap
- if (i == shownSegments) {
- bitmap = horizontalEndingLeaf.bitmap
- }
-
- // left side
- canvas.withRotation(i * 180.0f / maximumDecorationSegmentCount, canvasWidth / 2f, canvasHeight / 2f) {
- // drawBitmap takes corner of the bitmap, we care about centering segments
- canvas.drawBitmap(bitmap, circleCenterX - decorationSegmentImageWidth / 2f, canvasHeight * howDistantIsDecorationFromCircleCenter, antiAliasPaint)
- }
-
- // right side
- val flippedBitmap = bitmap.flipHorizontally()
- canvas.withRotation(-i * 180.0f / maximumDecorationSegmentCount, canvasWidth / 2f, canvasHeight / 2f) {
- canvas.drawBitmap(flippedBitmap, circleCenterX - decorationSegmentImageWidth / 2f, canvasHeight * howDistantIsDecorationFromCircleCenter, antiAliasPaint)
- }
- }
- }
-
- override fun setAlpha(alpha: Int) {
- // This method is required
- }
-
- override fun setColorFilter(colorFilter: ColorFilter?) {
- // This method is required
- }
-
- @Deprecated("Deprecated in Java")
- override fun getOpacity(): Int = PixelFormat.OPAQUE
-}
diff --git a/app/src/main/res/drawable/achievement_level_frame.xml b/app/src/main/res/drawable/achievement_level_frame.xml
deleted file mode 100644
index b5daa66d6a1..00000000000
--- a/app/src/main/res/drawable/achievement_level_frame.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_laurel_leaf_ending.xml b/app/src/main/res/drawable/ic_laurel_leaf_ending.xml
deleted file mode 100644
index 78dc60110ee..00000000000
--- a/app/src/main/res/drawable/ic_laurel_leaf_ending.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_laurel_leaf_pair.xml b/app/src/main/res/drawable/ic_laurel_leaf_pair.xml
deleted file mode 100644
index 10b8a455280..00000000000
--- a/app/src/main/res/drawable/ic_laurel_leaf_pair.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/laurel_leaf_ending.xml b/app/src/main/res/drawable/laurel_leaf_ending.xml
new file mode 100644
index 00000000000..6f72f479fec
--- /dev/null
+++ b/app/src/main/res/drawable/laurel_leaf_ending.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/laurel_leaf_pair.xml b/app/src/main/res/drawable/laurel_leaf_pair.xml
new file mode 100644
index 00000000000..53d0af48f00
--- /dev/null
+++ b/app/src/main/res/drawable/laurel_leaf_pair.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/shine.xml b/app/src/main/res/drawable/shine.xml
index 096e592f3ad..d67bb88f5c5 100644
--- a/app/src/main/res/drawable/shine.xml
+++ b/app/src/main/res/drawable/shine.xml
@@ -1,5 +1,5 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_user.xml b/app/src/main/res/layout/activity_user.xml
index 72abafbee1e..3cdf29cc33a 100644
--- a/app/src/main/res/layout/activity_user.xml
+++ b/app/src/main/res/layout/activity_user.xml
@@ -15,13 +15,6 @@
android:layout_below="@id/toolbar"
android:layout_alignParentBottom="true"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/cell_achievement.xml b/app/src/main/res/layout/cell_achievement.xml
deleted file mode 100644
index 10747136560..00000000000
--- a/app/src/main/res/layout/cell_achievement.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/dialog_undo.xml b/app/src/main/res/layout/dialog_undo.xml
index f2701224b12..9b29dfb45ea 100644
--- a/app/src/main/res/layout/dialog_undo.xml
+++ b/app/src/main/res/layout/dialog_undo.xml
@@ -56,7 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dialog_horizontal_margin"
android:layout_toEndOf="@id/icon"
- android:textAppearance="@style/TextAppearance.Title.Hint"
+ android:textAppearance="@style/TextAppearance.TitleLarge.Hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toBottomOf="@id/titleText"
diff --git a/app/src/main/res/layout/fragment_achievement_info.xml b/app/src/main/res/layout/fragment_achievement_info.xml
deleted file mode 100644
index cd0d2ef71bc..00000000000
--- a/app/src/main/res/layout/fragment_achievement_info.xml
+++ /dev/null
@@ -1,151 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_achievements.xml b/app/src/main/res/layout/fragment_achievements.xml
deleted file mode 100644
index fd27ecd1163..00000000000
--- a/app/src/main/res/layout/fragment_achievements.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_country_info_dialog.xml b/app/src/main/res/layout/fragment_country_info_dialog.xml
index 285cdb620c5..438f451f670 100644
--- a/app/src/main/res/layout/fragment_country_info_dialog.xml
+++ b/app/src/main/res/layout/fragment_country_info_dialog.xml
@@ -100,7 +100,7 @@
android:gravity="center"
android:maxLines="1"
android:textAlignment="gravity"
- android:textAppearance="@style/TextAppearance.Title"
+ android:textAppearance="@style/TextAppearance.TitleLarge"
android:textSize="48dp"
tools:text="1021" />
diff --git a/app/src/main/res/layout/fragment_credits.xml b/app/src/main/res/layout/fragment_credits.xml
index b7309b3c154..2bb4862009d 100644
--- a/app/src/main/res/layout/fragment_credits.xml
+++ b/app/src/main/res/layout/fragment_credits.xml
@@ -37,7 +37,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/credits_author_title"
- android:textAppearance="@style/TextAppearance.Title" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
+ android:textAppearance="@style/TextAppearance.TitleLarge" />
diff --git a/app/src/main/res/layout/fragment_edit_type_info_dialog.xml b/app/src/main/res/layout/fragment_edit_type_info_dialog.xml
index a7b399a1bee..42d2cc0aaea 100644
--- a/app/src/main/res/layout/fragment_edit_type_info_dialog.xml
+++ b/app/src/main/res/layout/fragment_edit_type_info_dialog.xml
@@ -73,7 +73,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"
- android:textAppearance="@style/TextAppearance.Title"
+ android:textAppearance="@style/TextAppearance.TitleLarge"
tools:text="@string/quest_maxheight_title" />
diff --git a/app/src/main/res/layout/fragment_links.xml b/app/src/main/res/layout/fragment_links.xml
deleted file mode 100644
index b95b751d8cd..00000000000
--- a/app/src/main/res/layout/fragment_links.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml
index cd974aa4b41..853f7012946 100644
--- a/app/src/main/res/layout/fragment_login.xml
+++ b/app/src/main/res/layout/fragment_login.xml
@@ -15,7 +15,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_above="@id/loginButton"
- android:textAppearance="@style/TextAppearance.Title"
+ android:textAppearance="@style/TextAppearance.TitleLarge"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingStart="@dimen/activity_horizontal_margin"
diff --git a/app/src/main/res/layout/fragment_messages_container.xml b/app/src/main/res/layout/fragment_messages_container.xml
deleted file mode 100644
index 7b1873dd89b..00000000000
--- a/app/src/main/res/layout/fragment_messages_container.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
diff --git a/app/src/main/res/layout/fragment_move_node.xml b/app/src/main/res/layout/fragment_move_node.xml
index 2eca97a1ef1..d0c36c90f3a 100644
--- a/app/src/main/res/layout/fragment_move_node.xml
+++ b/app/src/main/res/layout/fragment_move_node.xml
@@ -86,7 +86,7 @@
android:text="@string/node_moved_not_far_enough"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:textAppearance="@style/TextAppearance.Title"
+ android:textAppearance="@style/TextAppearance.TitleLarge"
android:layout_marginBottom="8dp"/>
+ android:textAppearance="@style/TextAppearance.TitleLarge.Hint"/>
diff --git a/app/src/main/res/layout/fragment_overlays_tutorial.xml b/app/src/main/res/layout/fragment_overlays_tutorial.xml
index 3e19938cddc..78978d52f13 100644
--- a/app/src/main/res/layout/fragment_overlays_tutorial.xml
+++ b/app/src/main/res/layout/fragment_overlays_tutorial.xml
@@ -153,7 +153,7 @@
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:gravity="center"
- android:textAppearance="@style/TextAppearance.Headline"
+ android:textAppearance="@style/TextAppearance.HeadlineSmall"
android:text="@string/overlays_tutorial_title"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_quest_answer.xml b/app/src/main/res/layout/fragment_quest_answer.xml
index c123b4594af..48ad6b98559 100644
--- a/app/src/main/res/layout/fragment_quest_answer.xml
+++ b/app/src/main/res/layout/fragment_quest_answer.xml
@@ -63,13 +63,13 @@
android:id="@+id/titleLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:textAppearance="@style/TextAppearance.Title"/>
+ android:textAppearance="@style/TextAppearance.TitleLarge"/>
diff --git a/app/src/main/res/layout/fragment_quest_selection.xml b/app/src/main/res/layout/fragment_quest_selection.xml
index 5de154e9b71..acc0d18edcc 100644
--- a/app/src/main/res/layout/fragment_quest_selection.xml
+++ b/app/src/main/res/layout/fragment_quest_selection.xml
@@ -49,7 +49,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
- android:textAppearance="@style/TextAppearance.Title"
+ android:textAppearance="@style/TextAppearance.TitleLarge"
android:visibility="invisible"
android:textColor="@color/hint_text"
android:text="@string/no_search_results"
diff --git a/app/src/main/res/layout/fragment_show_links.xml b/app/src/main/res/layout/fragment_show_links.xml
deleted file mode 100644
index 2cd3205fdfb..00000000000
--- a/app/src/main/res/layout/fragment_show_links.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_tutorial.xml b/app/src/main/res/layout/fragment_tutorial.xml
index 74a5689a4ae..98d99334940 100644
--- a/app/src/main/res/layout/fragment_tutorial.xml
+++ b/app/src/main/res/layout/fragment_tutorial.xml
@@ -188,7 +188,7 @@
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:gravity="center"
- android:textAppearance="@style/TextAppearance.Headline"
+ android:textAppearance="@style/TextAppearance.HeadlineSmall"
android:text="@string/tutorial_welcome_to_osm"/>
diff --git a/app/src/main/res/layout/row_link_category_item.xml b/app/src/main/res/layout/row_link_category_item.xml
deleted file mode 100644
index 74589557859..00000000000
--- a/app/src/main/res/layout/row_link_category_item.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/row_link_item.xml b/app/src/main/res/layout/row_link_item.xml
deleted file mode 100644
index 11890c21d6a..00000000000
--- a/app/src/main/res/layout/row_link_item.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/view_achievement_icon.xml b/app/src/main/res/layout/view_achievement_icon.xml
deleted file mode 100644
index 77a438568b0..00000000000
--- a/app/src/main/res/layout/view_achievement_icon.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values-sw400dp/textStyles.xml b/app/src/main/res/values-sw400dp/textStyles.xml
index bd2d80a7dd3..435b91e18fb 100644
--- a/app/src/main/res/values-sw400dp/textStyles.xml
+++ b/app/src/main/res/values-sw400dp/textStyles.xml
@@ -1,7 +1,7 @@
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-sw600dp/textStyles.xml b/app/src/main/res/values-sw600dp/textStyles.xml
index e5d204e7a7c..4d8f022fdee 100644
--- a/app/src/main/res/values-sw600dp/textStyles.xml
+++ b/app/src/main/res/values-sw600dp/textStyles.xml
@@ -1,7 +1,7 @@
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 968f472528d..6680f130dff 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -55,14 +55,6 @@
220dp
120dp
- 320dp
- 220dp
-
-
-
- 8dp
- 16dp
-
320dp
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 69732146c4f..dc9df82e371 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -10,7 +10,7 @@
diff --git a/app/src/main/res/values/textStyles.xml b/app/src/main/res/values/textStyles.xml
index 3af99130fe2..36c3c90725a 100644
--- a/app/src/main/res/values/textStyles.xml
+++ b/app/src/main/res/values/textStyles.xml
@@ -1,25 +1,25 @@
-
-
-
-