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"/> - - - - - - - - - - - - - - - - - - - - - - - - - - -