diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 608e03b659..e2904f3e20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,6 +139,7 @@ dependencies { implementation("androidx.viewpager:viewpager:1.0.0") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") // photos implementation("androidx.exifinterface:exifinterface:1.3.7") diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDaoTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDaoTest.kt index 56e77130da..69da4b46c9 100644 --- a/app/src/androidTest/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDaoTest.kt +++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDaoTest.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.ApplicationDbTestCase import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction import de.westnordost.streetcomplete.data.osm.edits.create.RevertCreateNodeAction @@ -43,7 +44,7 @@ class ElementEditsDaoTest : ApplicationDbTestCase() { @BeforeTest fun createDao() { val list = listOf(1 to TEST_QUEST_TYPE, 2 to TEST_QUEST_TYPE2) val list2 = listOf(1 to TestOverlay) - dao = ElementEditsDao(database, QuestTypeRegistry(list), OverlayRegistry(list2)) + dao = ElementEditsDao(database, AllEditTypes(listOf(QuestTypeRegistry(list), OverlayRegistry(list2)))) } @Test fun addGet_UpdateElementTagsEdit() { diff --git a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt index 641e0496e4..534335e1ae 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt @@ -9,6 +9,7 @@ import androidx.work.WorkManager import de.westnordost.streetcomplete.data.CacheTrimmer import de.westnordost.streetcomplete.data.CleanerWorker import de.westnordost.streetcomplete.data.Preloader +import de.westnordost.streetcomplete.data.allEditTypesModule import de.westnordost.streetcomplete.data.dbModule import de.westnordost.streetcomplete.data.download.downloadModule import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController @@ -39,11 +40,13 @@ import de.westnordost.streetcomplete.data.visiblequests.questPresetsModule import de.westnordost.streetcomplete.overlays.overlaysModule import de.westnordost.streetcomplete.quests.oneway_suspects.data.trafficFlowSegmentsModule import de.westnordost.streetcomplete.quests.questsModule +import de.westnordost.streetcomplete.screens.about.aboutScreenModule import de.westnordost.streetcomplete.screens.main.mainModule import de.westnordost.streetcomplete.screens.main.map.mapModule import de.westnordost.streetcomplete.screens.measure.arModule import de.westnordost.streetcomplete.screens.settings.ResurveyIntervalsUpdater import de.westnordost.streetcomplete.screens.settings.settingsModule +import de.westnordost.streetcomplete.screens.user.userScreenModule import de.westnordost.streetcomplete.util.CrashReportExceptionHandler import de.westnordost.streetcomplete.util.getDefaultTheme import de.westnordost.streetcomplete.util.getSelectedLocale @@ -92,6 +95,8 @@ class StreetCompleteApplication : Application() { modules( achievementsModule, appModule, + aboutScreenModule, + userScreenModule, createdElementsModule, dbModule, logsModule, @@ -113,6 +118,7 @@ class StreetCompleteApplication : Application() { preferencesModule, questModule, questPresetsModule, + allEditTypesModule, questsModule, settingsModule, statisticsModule, diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt new file mode 100644 index 0000000000..c6fb89f06a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt @@ -0,0 +1,16 @@ +package de.westnordost.streetcomplete.data + +import de.westnordost.streetcomplete.data.osm.edits.EditType + +class AllEditTypes( + registries: List> +) : AbstractCollection() { + + private val byName = registries.flatten().associateByTo(LinkedHashMap()) { it.name } + + override val size: Int get() = byName.size + + override fun iterator(): Iterator = byName.values.iterator() + + fun getByName(typeName: String): EditType? = byName[typeName] +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypesModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypesModule.kt new file mode 100644 index 0000000000..cca3c1c6d4 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypesModule.kt @@ -0,0 +1,14 @@ +package de.westnordost.streetcomplete.data + +import de.westnordost.streetcomplete.data.overlays.OverlayRegistry +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import org.koin.dsl.module + +val allEditTypesModule = module { + single { + AllEditTypes(listOf( + get(), + get() + )) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/logs/LogsFilters.kt b/app/src/main/java/de/westnordost/streetcomplete/data/logs/LogsFilters.kt index db9984ac91..5a0619c5d7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/logs/LogsFilters.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/logs/LogsFilters.kt @@ -2,9 +2,7 @@ package de.westnordost.streetcomplete.data.logs import de.westnordost.streetcomplete.util.ktx.toEpochMilli import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.Serializable -@Serializable data class LogsFilters( val levels: Set = LogLevel.entries.toSet(), val messageContains: String? = null, diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt index 9bea4c7bef..a6bcdae6dc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.osm.edits +import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.CursorPosition import de.westnordost.streetcomplete.data.Database import de.westnordost.streetcomplete.data.osm.edits.ElementEditsTable.Columns.ACTION @@ -23,9 +24,6 @@ import de.westnordost.streetcomplete.data.osm.edits.move.RevertMoveNodeAction import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.RevertUpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction -import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType -import de.westnordost.streetcomplete.data.overlays.OverlayRegistry -import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -35,8 +33,7 @@ import kotlinx.serialization.modules.subclass class ElementEditsDao( private val db: Database, - private val questTypeRegistry: QuestTypeRegistry, - private val overlayRegistry: OverlayRegistry + private val allEditTypes: AllEditTypes, ) { private val json = Json { serializersModule = SerializersModule { @@ -111,8 +108,7 @@ class ElementEditsDao( private fun CursorPosition.toElementEdit() = ElementEdit( getLong(ID), - questTypeRegistry.getByName(getString(QUEST_TYPE)) as? OsmElementQuestType<*> - ?: overlayRegistry.getByName(getString(QUEST_TYPE))!!, + allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType, json.decodeFromString(getString(GEOMETRY)), getString(SOURCE), getLong(CREATED_TIMESTAMP), diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt index b3f9824486..a7e1123932 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt @@ -14,7 +14,7 @@ val elementEditsModule = module { factory { ChangesetAutoCloser(get()) } factory { ElementEditUploader(get(), get(), get()) } - factory { ElementEditsDao(get(), get(), get()) } + factory { ElementEditsDao(get(), get()) } factory { ElementIdProviderDao(get()) } factory { LastEditTimeStore(get()) } factory { OpenChangesetsDao(get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsController.kt index 211f3a3019..731c93c26c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsController.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsController.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.data.user.achievements +import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.overlays.OverlayRegistry import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource @@ -11,8 +12,7 @@ class AchievementsController( private val statisticsSource: StatisticsSource, private val userAchievementsDao: UserAchievementsDao, private val userLinksDao: UserLinksDao, - private val questTypeRegistry: QuestTypeRegistry, - private val overlayRegistry: OverlayRegistry, + private val allEditTypes: AllEditTypes, private val allAchievements: List, allLinks: List ) : AchievementsSource { @@ -159,12 +159,10 @@ class AchievementsController( } private fun isContributingToAchievement(editType: String, achievementId: String): Boolean = - (questTypeRegistry.getByName(editType) ?: overlayRegistry.getByName(editType)) - ?.achievements?.anyHasId(achievementId) == true + allEditTypes.getByName(editType)?.achievements?.anyHasId(achievementId) == true private fun getEditTypesContributingToAchievement(achievementId: String): List = - questTypeRegistry.filter { it.achievements.anyHasId(achievementId) }.map { it.name } + - overlayRegistry.filter { it.achievements.anyHasId(achievementId) }.map { it.name } + allEditTypes.filter { it.achievements.anyHasId(achievementId) }.map { it.name } } private fun List.anyHasId(achievementId: String) = any { it.id == achievementId } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsModule.kt index 401ff62415..5dc7f57797 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/user/achievements/AchievementsModule.kt @@ -42,7 +42,7 @@ val achievementsModule = module { factory { UserLinksDao(get()) } single { get() } - single { AchievementsController(get(), get(), get(), get(), get(), get(named("Achievements")), get(named("Links"))) } + single { AchievementsController(get(), get(), get(), get(), get(named("Achievements")), get(named("Links"))) } } // list of (quest) synonyms (this alternate name is mentioned to aid searching for this code) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreenModule.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreenModule.kt new file mode 100644 index 0000000000..07c014b062 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/AboutScreenModule.kt @@ -0,0 +1,8 @@ +package de.westnordost.streetcomplete.screens.about + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val aboutScreenModule = module { + viewModel { LogsViewModelImpl(get()) } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsAdapter.kt index 8ea22d3f3e..074288ba94 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsAdapter.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsAdapter.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.screens.about import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import de.westnordost.streetcomplete.data.logs.LogMessage import de.westnordost.streetcomplete.databinding.RowLogMessageBinding @@ -12,7 +13,8 @@ import kotlinx.datetime.toLocalDateTime class LogsAdapter : RecyclerView.Adapter() { - class ViewHolder(private val binding: RowLogMessageBinding) : RecyclerView.ViewHolder(binding.root) { + class ViewHolder(private val binding: RowLogMessageBinding) : + RecyclerView.ViewHolder(binding.root) { fun onBind(with: LogMessage) { binding.messageTextView.text = with.toString() @@ -29,8 +31,18 @@ class LogsAdapter : RecyclerView.Adapter() { var messages: List get() = _messages set(value) { + val result = DiffUtil.calculateDiff( + object : DiffUtil.Callback() { + override fun getOldListSize() = _messages.size + override fun getNewListSize() = value.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + _messages[oldItemPosition].timestamp == value[newItemPosition].timestamp + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + _messages[oldItemPosition] == _messages[newItemPosition] + } + ) _messages = value.toMutableList() - notifyDataSetChanged() + result.dispatchUpdatesTo(this) } private var _messages: MutableList = mutableListOf() diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsFragment.kt index 8a93e278e4..a69b462763 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsFragment.kt @@ -8,43 +8,24 @@ import androidx.recyclerview.widget.DividerItemDecoration import de.westnordost.streetcomplete.BuildConfig import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.logs.LogMessage -import de.westnordost.streetcomplete.data.logs.LogsController -import de.westnordost.streetcomplete.data.logs.LogsFilters import de.westnordost.streetcomplete.data.logs.format import de.westnordost.streetcomplete.databinding.FragmentLogsBinding import de.westnordost.streetcomplete.screens.TwoPaneDetailFragment import de.westnordost.streetcomplete.util.ktx.now -import de.westnordost.streetcomplete.util.ktx.systemTimeNow -import de.westnordost.streetcomplete.util.ktx.toEpochMilli -import de.westnordost.streetcomplete.util.ktx.toLocalDate +import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.viewBinding -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel /** Shows the app logs */ class LogsFragment : TwoPaneDetailFragment(R.layout.fragment_logs) { - private val logsController: LogsController by inject() private val binding by viewBinding(FragmentLogsBinding::bind) - private val adapter = LogsAdapter() - - private var filters: LogsFilters - - init { - val startOfToday = LocalDateTime(systemTimeNow().toLocalDate(), LocalTime(0, 0, 0)) - filters = LogsFilters(timestampNewerThan = startOfToday) - } + private val viewModel by viewModel() - private val logsControllerListener = object : LogsController.Listener { - override fun onAdded(message: LogMessage) { viewLifecycleScope.launch { onMessageAdded(message) } } - } + private val adapter = LogsAdapter() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -55,27 +36,11 @@ class LogsFragment : TwoPaneDetailFragment(R.layout.fragment_logs) { binding.logsList.itemAnimator = null // default animations are too slow when logging many messages quickly binding.logsList.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) - if (savedInstanceState != null) { - onLoadInstanceState(savedInstanceState) + observe(viewModel.logs) { logs -> + adapter.messages = logs + binding.toolbar.root.title = getString(R.string.about_title_logs, logs.size) + binding.logsList.scrollToPosition(logs.lastIndex) } - - showLogs() - - logsController.addListener(logsControllerListener) - } - - private fun onLoadInstanceState(savedInstanceState: Bundle) { - filters = Json.decodeFromString(savedInstanceState.getString(FILTERS_DATA)!!) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(FILTERS_DATA, Json.encodeToString(filters)) - } - - override fun onDestroyView() { - super.onDestroyView() - logsController.removeListener(logsControllerListener) } private fun createOptionsMenu(toolbar: Toolbar) { @@ -97,7 +62,7 @@ class LogsFragment : TwoPaneDetailFragment(R.layout.fragment_logs) { } private fun onClickShare() = viewLifecycleScope.launch { - val logText = getLogs().format() + val logText = viewModel.logs.value.format() val logTimestamp = LocalDateTime.now().toString() val logTitle = "${BuildConfig.APPLICATION_ID}_${BuildConfig.VERSION_NAME}_$logTimestamp.log" @@ -111,46 +76,21 @@ class LogsFragment : TwoPaneDetailFragment(R.layout.fragment_logs) { } private fun onClickFilter() { - LogsFiltersDialog(requireContext(), filters) { newFilters -> + LogsFiltersDialog(requireContext(), viewModel.filters.value) { newFilters -> if (newFilters != null) { - filters = newFilters - showLogs() + viewModel.setFilters(newFilters) } }.show() } private fun onMessageAdded(message: LogMessage) { - if (filters.matches(message)) { - adapter.add(message) - binding.toolbar.root.title = getString(R.string.about_title_logs, adapter.messages.size) + adapter.add(message) + binding.toolbar.root.title = getString(R.string.about_title_logs, adapter.messages.size) - if (hasScrolledToBottom()) { - binding.logsList.scrollToPosition(adapter.messages.lastIndex) - } + if (binding.logsList.hasScrolledToBottom()) { + binding.logsList.scrollToPosition(adapter.messages.lastIndex) } } - - private fun showLogs() { - viewLifecycleScope.launch { - val logs = getLogs() - adapter.messages = logs - binding.toolbar.root.title = getString(R.string.about_title_logs, logs.size) - binding.logsList.scrollToPosition(logs.lastIndex) - } - } - - private suspend fun getLogs(): List = withContext(Dispatchers.IO) { - logsController.getLogs( - levels = filters.levels, - messageContains = filters.messageContains, - newerThan = filters.timestampNewerThan?.toEpochMilli(), - olderThan = filters.timestampOlderThan?.toEpochMilli() - ) - } - - private fun hasScrolledToBottom(): Boolean = !binding.logsList.canScrollVertically(1) - - companion object { - private const val FILTERS_DATA = "filters_data" - } } + +private fun View.hasScrolledToBottom(): Boolean = !canScrollVertically(1) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModel.kt new file mode 100644 index 0000000000..0e9c9787d6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModel.kt @@ -0,0 +1,14 @@ +package de.westnordost.streetcomplete.screens.about + +import androidx.lifecycle.ViewModel +import de.westnordost.streetcomplete.data.logs.LogMessage +import de.westnordost.streetcomplete.data.logs.LogsFilters +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +abstract class LogsViewModel : ViewModel() { + abstract val filters: StateFlow + abstract val logs: StateFlow> + + abstract fun setFilters(filters: LogsFilters) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModelImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModelImpl.kt new file mode 100644 index 0000000000..8f2f83970f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/about/LogsViewModelImpl.kt @@ -0,0 +1,79 @@ +package de.westnordost.streetcomplete.screens.about + +import androidx.lifecycle.viewModelScope +import de.westnordost.streetcomplete.data.logs.LogMessage +import de.westnordost.streetcomplete.data.logs.LogsController +import de.westnordost.streetcomplete.data.logs.LogsFilters +import de.westnordost.streetcomplete.util.ktx.systemTimeNow +import de.westnordost.streetcomplete.util.ktx.toEpochMilli +import de.westnordost.streetcomplete.util.ktx.toLocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.plus +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +class LogsViewModelImpl( + private val logsController: LogsController, +) : LogsViewModel() { + + override val filters = MutableStateFlow(LogsFilters( + timestampNewerThan = LocalDateTime(systemTimeNow().toLocalDate(), LocalTime(0, 0, 0)) + )) + + /** + * Produce a call back flow of all incoming logs matching the given [filters]. + */ + private fun getIncomingLogs(filters: LogsFilters) = callbackFlow { + // Listener that sends the messages matching the filters to the observer + val listener = object : LogsController.Listener { + override fun onAdded(message: LogMessage) { + if (filters.matches(message)) { + trySend(message) // Send it to the observer + } + } + } + logsController.addListener(listener) + awaitClose { logsController.removeListener(listener) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val _logs: SharedFlow> = + filters.transformLatest { filters -> + val logs = logsController.getLogs(filters).toMutableList() + + emit(logs) + + getIncomingLogs(filters).collect { + logs.add(it) + emit(logs) + } + }.shareIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, 1) + + + override val logs: StateFlow> = object : + StateFlow>, + SharedFlow> by _logs { + override val value: List get() = replayCache[0] + } + + override fun setFilters(filters: LogsFilters) { + this.filters.value = filters + } +} + +private fun LogsController.getLogs(filters: LogsFilters) = + getLogs( + levels = filters.levels, + messageContains = filters.messageContains, + newerThan = filters.timestampNewerThan?.toEpochMilli(), + olderThan = filters.timestampOlderThan?.toEpochMilli() + ) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt index c0f56d5730..de941e23e1 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/MoveNodeFragment.kt @@ -11,6 +11,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import de.westnordost.countryboundaries.CountryBoundaries import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.location.RecentLocationStore import de.westnordost.streetcomplete.data.location.checkIsSurvey import de.westnordost.streetcomplete.data.location.confirmIsSurvey @@ -25,9 +26,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.Node import de.westnordost.streetcomplete.data.osm.mapdata.key -import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType -import de.westnordost.streetcomplete.data.overlays.OverlayRegistry -import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.databinding.FragmentMoveNodeBinding import de.westnordost.streetcomplete.overlays.IsShowingElement import de.westnordost.streetcomplete.screens.measure.MeasureDisplayUnit @@ -55,8 +53,7 @@ class MoveNodeFragment : private val binding by viewBinding(FragmentMoveNodeBinding::bind) private val elementEditsController: ElementEditsController by inject() - private val questTypeRegistry: QuestTypeRegistry by inject() - private val overlayRegistry: OverlayRegistry by inject() + private val allEditTypes: AllEditTypes by inject() private val countryBoundaries: Lazy by inject(named("CountryBoundariesLazy")) private val countryInfos: CountryInfos by inject() private val recentLocationStore: RecentLocationStore by inject() @@ -83,8 +80,7 @@ class MoveNodeFragment : super.onCreate(savedInstanceState) val args = requireArguments() node = Json.decodeFromString(args.getString(ARG_NODE)!!) - editType = questTypeRegistry.getByName(args.getString(ARG_QUEST_TYPE)!!) as? OsmElementQuestType<*> - ?: overlayRegistry.getByName(args.getString(ARG_QUEST_TYPE)!!)!! + editType = allEditTypes.getByName(args.getString(ARG_QUEST_TYPE)!!) as ElementEditType val isFeetAndInch = countryInfos.getByLocation( countryBoundaries.value, diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt index 87c4fa7b18..b9d0d09c4d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/bottom_sheet/SplitWayFragment.kt @@ -18,6 +18,7 @@ import androidx.core.view.isInvisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.AllEditTypes import de.westnordost.streetcomplete.data.location.RecentLocationStore import de.westnordost.streetcomplete.data.location.checkIsSurvey import de.westnordost.streetcomplete.data.location.confirmIsSurvey @@ -33,9 +34,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osm.mapdata.Way import de.westnordost.streetcomplete.data.osm.mapdata.key -import de.westnordost.streetcomplete.data.osm.osmquests.OsmElementQuestType -import de.westnordost.streetcomplete.data.overlays.OverlayRegistry -import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.databinding.FragmentSplitWayBinding import de.westnordost.streetcomplete.overlays.IsShowingElement import de.westnordost.streetcomplete.screens.main.map.ShowsGeometryMarkers @@ -71,8 +69,8 @@ class SplitWayFragment : private val binding by viewBinding(FragmentSplitWayBinding::bind) private val elementEditsController: ElementEditsController by inject() - private val questTypeRegistry: QuestTypeRegistry by inject() - private val overlayRegistry: OverlayRegistry by inject() + + private val allEditTypes: AllEditTypes by inject() private val soundFx: SoundFx by inject() private val recentLocationStore: RecentLocationStore by inject() @@ -102,8 +100,7 @@ class SplitWayFragment : super.onCreate(savedInstanceState) val args = requireArguments() way = Json.decodeFromString(args.getString(ARG_WAY)!!) - editType = questTypeRegistry.getByName(args.getString(ARG_QUESTTYPE)!!) as? OsmElementQuestType<*> - ?: overlayRegistry.getByName(args.getString(ARG_QUESTTYPE)!!)!! + editType = allEditTypes.getByName(args.getString(ARG_QUESTTYPE)!!) as ElementEditType geometry = Json.decodeFromString(args.getString(ARG_GEOMETRY)!!) } 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 new file mode 100644 index 0000000000..3517ee1082 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/UserScreenModule.kt @@ -0,0 +1,16 @@ +package de.westnordost.streetcomplete.screens.user + +import de.westnordost.streetcomplete.screens.user.profile.ProfileViewModel +import de.westnordost.streetcomplete.screens.user.profile.ProfileViewModelImpl +import de.westnordost.streetcomplete.screens.user.statistics.EditStatisticsViewModel +import de.westnordost.streetcomplete.screens.user.statistics.EditStatisticsViewModelImpl +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val userScreenModule = module { + factory { ProfileViewModelImpl( + get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory")), get() + ) } + + factory { EditStatisticsViewModelImpl(get(), get()) } +} 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 deecea3e43..27ad8eee71 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 @@ -11,94 +11,32 @@ import android.view.animation.AccelerateDecelerateInterpolator import android.widget.TextView import androidx.core.view.isGone import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource -import de.westnordost.streetcomplete.data.user.UserDataSource -import de.westnordost.streetcomplete.data.user.UserLoginStatusController -import de.westnordost.streetcomplete.data.user.UserUpdater -import de.westnordost.streetcomplete.data.user.achievements.Achievement -import de.westnordost.streetcomplete.data.user.achievements.AchievementsSource import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics -import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource 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.ktx.viewLifecycleScope -import de.westnordost.streetcomplete.util.prefs.Preferences import de.westnordost.streetcomplete.util.viewBinding import de.westnordost.streetcomplete.view.LaurelWreathDrawable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.koin.android.ext.android.inject -import org.koin.core.qualifier.named -import java.io.File import java.util.Locale import kotlin.math.max import kotlin.math.min +import org.koin.androidx.viewmodel.ext.android.viewModel /** Shows the user profile: username, avatar, star count and a hint regarding unpublished changes */ class ProfileFragment : Fragment(R.layout.fragment_profile) { - private val userDataSource: UserDataSource by inject() - private val userLoginStatusController: UserLoginStatusController by inject() - private val userUpdater: UserUpdater by inject() - private val statisticsSource: StatisticsSource by inject() - private val achievementsSource: AchievementsSource by inject() - private val unsyncedChangesCountSource: UnsyncedChangesCountSource by inject() - private val avatarsCacheDirectory: File by inject(named("AvatarsCacheDirectory")) - - private val prefs: Preferences by inject() - private lateinit var anonAvatar: Bitmap private val animations = ArrayList() + private val viewModel by viewModel() private val binding by viewBinding(FragmentProfileBinding::bind) - private val unsyncedChangesCountListener = object : UnsyncedChangesCountSource.Listener { - override fun onIncreased() { viewLifecycleScope.launch { updateUnpublishedEditsText() } } - override fun onDecreased() { viewLifecycleScope.launch { updateUnpublishedEditsText() } } - } - private val statisticsListener = object : StatisticsSource.Listener { - override fun onAddedOne(type: String) { - viewLifecycleScope.launch { updateEditCountTexts() } - } - override fun onSubtractedOne(type: String) { - viewLifecycleScope.launch { updateEditCountTexts() } - } - override fun onUpdatedAll() { - viewLifecycleScope.launch { updateStatisticsTexts() } - } - override fun onCleared() { - viewLifecycleScope.launch { updateStatisticsTexts() } - } - override fun onUpdatedDaysActive() { - viewLifecycleScope.launch { updateDaysActiveText() } - } - } - private val achievementsListener = object : AchievementsSource.Listener { - override fun onAchievementUnlocked(achievement: Achievement, level: Int) { - viewLifecycleScope.launch { updateAchievementLevelsText() } - } - - override fun onAllAchievementsUpdated() { - viewLifecycleScope.launch { updateAchievementLevelsText() } - } - } - private val userListener = object : UserDataSource.Listener { - override fun onUpdated() { viewLifecycleScope.launch { updateUserName() } } - } - private val userAvatarListener = object : UserUpdater.Listener { - override fun onUserAvatarUpdated() { viewLifecycleScope.launch { updateAvatar() } } - } - override fun onAttach(context: Context) { super.onAttach(context) anonAvatar = context.getDrawable(R.drawable.ic_osm_anon_avatar)!!.createBitmap() @@ -114,33 +52,60 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) { binding.currentWeekLocalRankText.background = LaurelWreathDrawable(resources) binding.currentWeekGlobalRankText.background = LaurelWreathDrawable(resources) - binding.logoutButton.setOnClickListener { - userLoginStatusController.logOut() - } + binding.logoutButton.setOnClickListener { viewModel.logOutUser() } binding.profileButton.setOnClickListener { - openUri("https://www.openstreetmap.org/user/" + userDataSource.userName) + openUri("https://www.openstreetmap.org/user/" + viewModel.userName.value) } - } - - override fun onStart() { - super.onStart() - viewLifecycleScope.launch { - userDataSource.addListener(userListener) - userUpdater.addUserAvatarListener(userAvatarListener) - statisticsSource.addListener(statisticsListener) - unsyncedChangesCountSource.addListener(unsyncedChangesCountListener) - achievementsSource.addListener(achievementsListener) - - updateUserName() - updateAvatar() - updateEditCountTexts() - updateUnpublishedEditsText() - updateDaysActiveText() - updateGlobalRankTexts() - updateLocalRankTexts() - updateAchievementLevelsText() - updateDatesActiveView() + 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.dpToPx(18), + context.dpToPx(2), + context.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) } } @@ -156,181 +121,90 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) { override fun onStop() { super.onStop() - unsyncedChangesCountSource.removeListener(unsyncedChangesCountListener) - statisticsSource.removeListener(statisticsListener) - userDataSource.removeListener(userListener) - userUpdater.removeUserAvatarListener(userAvatarListener) - achievementsSource.removeListener(achievementsListener) - animations.forEach { it.end() } animations.clear() } - private fun updateUserName() { - binding.userNameTextView.text = userDataSource.userName - } - - private fun updateAvatar() { - val avatarFile = File(avatarsCacheDirectory.toString() + File.separator + userDataSource.userId) - val avatar = if (avatarFile.exists()) BitmapFactory.decodeFile(avatarFile.path) else anonAvatar - binding.userAvatarImageView.setImageBitmap(avatar) - } - - private suspend fun updateStatisticsTexts() { - updateEditCountTexts() - updateDaysActiveText() - updateGlobalRankTexts() - updateLocalRankTexts() - updateDatesActiveView() - } - - private suspend fun updateDatesActiveView() { - val context = context ?: return - - val datesActive = withContext(Dispatchers.IO) { statisticsSource.getActiveDates() }.toSet() - binding.datesActiveView.setImageDrawable(DatesActiveDrawable( - datesActive, - statisticsSource.activeDatesRange, - context.dpToPx(18), - context.dpToPx(2), - context.dpToPx(4), - context.resources.getColor(R.color.hint_text) - )) - } - - private suspend fun updateEditCountTexts() { - binding.editCountText.text = withContext(Dispatchers.IO) { statisticsSource.getEditCount().toString() } - binding.currentWeekEditCountText.text = withContext(Dispatchers.IO) { statisticsSource.getCurrentWeekEditCount().toString() } - } - private suspend fun updateUnpublishedEditsText() { - val unsyncedChanges = unsyncedChangesCountSource.getCount() - binding.unpublishedEditCountText.text = getString(R.string.unsynced_quests_description, unsyncedChanges) - binding.unpublishedEditCountText.isGone = unsyncedChanges <= 0 - } - - private fun updateDaysActiveText() { - val daysActive = statisticsSource.daysActive - binding.daysActiveContainer.isGone = daysActive <= 0 - binding.daysActiveText.text = daysActive.toString() - binding.daysActiveText.background.level = min(daysActive + 20, 100) * 100 - } - - private fun updateGlobalRankTexts() { - val rank = statisticsSource.rank - updateGlobalRankText( - rank, - prefs.getInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK, -1), - binding.globalRankContainer, - binding.globalRankText - ) - prefs.putInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK, rank) - - val rankCurrentWeek = statisticsSource.currentWeekRank - updateGlobalRankText( - rankCurrentWeek, - prefs.getInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK_CURRENT_WEEK, -1), - binding.currentWeekGlobalRankContainer, - binding.currentWeekGlobalRankText - ) - prefs.putInt(Prefs.LAST_SHOWN_USER_GLOBAL_RANK_CURRENT_WEEK, rankCurrentWeek) + 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 updateGlobalRankText(rank: Int, previousRank: Int, container: View, circle: TextView) { - val shouldHide = rank <= 0 || statisticsSource.getEditCount() <= 100 - container.isGone = shouldHide - if (shouldHide) return - - val updateRank = { r: Int -> - circle.text = "#$r" - circle.background.level = getScaledGlobalRank(r) + 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 + ) } - - if (previousRank <= 0 || previousRank < rank) { - updateRank(rank) - } else { - animate(previousRank, rank, container, updateRank) + 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.lastShownLocalUserRank?.rank, + ::getScaledLocalRank, + binding.localRankText + ) + viewModel.lastShownLocalUserRank = statistics + binding.localRankLabel.text = getLocalRankText(statistics?.countryCode) } } - private 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) - } - - private 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()) + private fun updateLocalRankCurrentWeek(statistics: CountryStatistics?) { + val showRank = statistics?.rank != null && statistics.count > 5 + binding.currentWeekLocalRankContainer.isGone = !showRank + if (showRank) { + updateRank( + statistics?.rank ?: 0, + viewModel.lastShownLocalUserRankCurrentWeek?.rank, + ::getScaledLocalRank, + binding.currentWeekLocalRankText + ) + viewModel.lastShownLocalUserRankCurrentWeek = statistics + binding.currentWeekLocalRankLabel.text = getLocalRankText(statistics?.countryCode) + } } - private suspend fun updateLocalRankTexts() { - val localRank = withContext(Dispatchers.IO) { statisticsSource.getCountryStatisticsOfCountryWithBiggestSolvedCount() } - updateLocalRankText( - localRank, - prefs.getStringOrNull(Prefs.LAST_SHOWN_USER_LOCAL_RANK)?.let { Json.decodeFromString(it) }, - 50, - binding.localRankContainer, - binding.localRankLabel, - binding.localRankText - ) - prefs.putString(Prefs.LAST_SHOWN_USER_LOCAL_RANK, Json.encodeToString(localRank)) - - val localRankCurrentWeek = withContext(Dispatchers.IO) { statisticsSource.getCurrentWeekCountryStatisticsOfCountryWithBiggestSolvedCount() } - updateLocalRankText( - localRankCurrentWeek, - prefs.getStringOrNull(Prefs.LAST_SHOWN_USER_LOCAL_RANK_CURRENT_WEEK)?.let { Json.decodeFromString(it) }, - 5, - binding.currentWeekLocalRankContainer, - binding.currentWeekLocalRankLabel, - binding.currentWeekLocalRankText - ) - prefs.putString(Prefs.LAST_SHOWN_USER_LOCAL_RANK_CURRENT_WEEK, Json.encodeToString(localRankCurrentWeek)) - } + private fun getLocalRankText(countryCode: String?): String = + getString(R.string.user_profile_local_rank, Locale("", countryCode ?: "").displayCountry) - private fun updateLocalRankText( - statistics: CountryStatistics?, - previousStatistics: CountryStatistics?, - min: Int, - container: View, - label: TextView, + private fun updateRank( + rank: Int, + previousRank: Int?, + getLevel: (rank: Int) -> Int, circle: TextView ) { - val rank = statistics?.rank ?: 0 - val shouldShow = statistics != null && rank > 0 && statistics.count > min - container.isGone = !shouldShow - if (!shouldShow) return - - val countryLocale = Locale("", statistics?.countryCode ?: "") - label.text = getString(R.string.user_profile_local_rank, countryLocale.displayCountry) - val updateRank = { r: Int -> circle.text = "#$r" - circle.background.level = getScaledLocalRank(r) + circle.background.level = getLevel(r) } - if (statistics?.countryCode != previousStatistics?.countryCode || - previousStatistics?.rank == null || rank > previousStatistics.rank) { + + if (previousRank == null || previousRank < rank) { updateRank(rank) } else { - animate(previousStatistics.rank, rank, container, updateRank) + animate(previousRank, rank, circle, updateRank) } } - private suspend fun updateAchievementLevelsText() { - val levels = withContext(Dispatchers.IO) { achievementsSource.getAchievements().sumOf { it.second } } - binding.achievementLevelsContainer.isGone = levels <= 0 - binding.achievementLevelsText.text = levels.toString() - binding.achievementLevelsText.background.level = min(levels / 2, 100) * 100 - } - private fun animate(previous: Int, now: Int, view: View, block: (value: Int) -> Unit) { block(previous) val anim = ValueAnimator.ofInt(previous, now) 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 new file mode 100644 index 0000000000..2079c98df6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModel.kt @@ -0,0 +1,37 @@ +package de.westnordost.streetcomplete.screens.user.profile + +import androidx.lifecycle.ViewModel +import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.LocalDate +import java.io.File + +abstract class ProfileViewModel : ViewModel() { + abstract val userName: StateFlow + abstract val userAvatarFile: StateFlow + + abstract val achievementLevels: StateFlow + + abstract val unsyncedChangesCount: StateFlow + + abstract val datesActive: StateFlow + abstract val daysActive: StateFlow + + abstract val editCount: StateFlow + abstract val editCountCurrentWeek: StateFlow + + abstract val rank: StateFlow + abstract val rankCurrentWeek: StateFlow + + abstract val biggestSolvedCountCountryStatistics: StateFlow + abstract val biggestSolvedCountCurrentWeekCountryStatistics: StateFlow + + abstract var lastShownGlobalUserRank: Int? + abstract var lastShownGlobalUserRankCurrentWeek: Int? + abstract var lastShownLocalUserRank: CountryStatistics? + abstract var lastShownLocalUserRankCurrentWeek: CountryStatistics? + + abstract fun logOutUser() +} + +data class DatesActiveInRange(val datesActive: List, val range: Int) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModelImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModelImpl.kt new file mode 100644 index 0000000000..4ed0c00287 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/ProfileViewModelImpl.kt @@ -0,0 +1,172 @@ +package de.westnordost.streetcomplete.screens.user.profile + +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 +import de.westnordost.streetcomplete.data.user.UserUpdater +import de.westnordost.streetcomplete.data.user.achievements.Achievement +import de.westnordost.streetcomplete.data.user.achievements.AchievementsSource +import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics +import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource +import de.westnordost.streetcomplete.util.ktx.launch +import de.westnordost.streetcomplete.util.prefs.Preferences +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + + +class ProfileViewModelImpl( + private val userDataSource: UserDataSource, + private val userLoginStatusController: UserLoginStatusController, + private val userUpdater: UserUpdater, + private val statisticsSource: StatisticsSource, + private val achievementsSource: AchievementsSource, + private val unsyncedChangesCountSource: UnsyncedChangesCountSource, + private val avatarsCacheDirectory: File, + private val prefs: Preferences +) : ProfileViewModel() { + + override val userName = MutableStateFlow(null) + override val userAvatarFile = MutableStateFlow(getUserAvatarFile()) + override val achievementLevels = MutableStateFlow(0) + override val unsyncedChangesCount = MutableStateFlow(0) + override val datesActive = MutableStateFlow(DatesActiveInRange(emptyList(), 0)) + override val daysActive = MutableStateFlow(0) + override val editCount = MutableStateFlow(0) + override val editCountCurrentWeek = MutableStateFlow(0) + override val rank = MutableStateFlow(-1) + override val rankCurrentWeek = MutableStateFlow(-1) + 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 lastShownLocalUserRank: 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 lastShownLocalUserRankCurrentWeek: 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() } + } + + private val unsyncedChangesCountListener = object : UnsyncedChangesCountSource.Listener { + override fun onIncreased() { unsyncedChangesCount.update { it + 1 } } + override fun onDecreased() { unsyncedChangesCount.update { it -1 } } + } + private val statisticsListener = object : StatisticsSource.Listener { + override fun onAddedOne(type: String) { + editCount.update { it + 1 } + editCountCurrentWeek.update { it + 1 } + } + override fun onSubtractedOne(type: String) { + editCount.update { it - 1 } + editCountCurrentWeek.update { it - 1 } + } + override fun onUpdatedAll() { updateStatistics() } + override fun onCleared() { updateStatistics() } + override fun onUpdatedDaysActive() { updateDatesActive() } + } + private val achievementsListener = object : AchievementsSource.Listener { + override fun onAchievementUnlocked(achievement: Achievement, level: Int) { updateAchievementLevels() } + override fun onAllAchievementsUpdated() { updateAchievementLevels() } + } + private val userListener = object : UserDataSource.Listener { + override fun onUpdated() { + userName.value = userDataSource.userName + userAvatarFile.value = getUserAvatarFile() + } + } + private val userAvatarListener = object : UserUpdater.Listener { + override fun onUserAvatarUpdated() { + userAvatarFile.value = getUserAvatarFile() + } + } + + init { + userName.value = userDataSource.userName + updateAchievementLevels() + updateUnsyncedChangesCount() + updateStatistics() + + userDataSource.addListener(userListener) + userUpdater.addUserAvatarListener(userAvatarListener) + statisticsSource.addListener(statisticsListener) + unsyncedChangesCountSource.addListener(unsyncedChangesCountListener) + achievementsSource.addListener(achievementsListener) + } + + private fun updateStatistics() { + updateEditCounts() + updateRanks() + updateDatesActive() + } + + private fun updateRanks() { + launch(IO) { + rank.value = statisticsSource.rank + rankCurrentWeek.value = statisticsSource.currentWeekRank + biggestSolvedCountCountryStatistics.value = + statisticsSource.getCountryStatisticsOfCountryWithBiggestSolvedCount() + biggestSolvedCountCurrentWeekCountryStatistics.value = + statisticsSource.getCurrentWeekCountryStatisticsOfCountryWithBiggestSolvedCount() + } + } + + private fun updateEditCounts() { + launch(IO) { + editCount.update { statisticsSource.getEditCount() } + editCountCurrentWeek.update { statisticsSource.getCurrentWeekEditCount() } + } + } + + private fun updateAchievementLevels() { + launch(IO) { + achievementLevels.value = achievementsSource.getAchievements().sumOf { it.second } + } + } + + private fun updateDatesActive() { + launch(IO) { + daysActive.value = statisticsSource.daysActive + datesActive.value = DatesActiveInRange( + statisticsSource.getActiveDates(), + statisticsSource.activeDatesRange + ) + } + } + + private fun updateUnsyncedChangesCount() { + launch(IO) { + unsyncedChangesCount.update { unsyncedChangesCountSource.getCount() } + } + } + + private fun getUserAvatarFile(): File = + File(avatarsCacheDirectory, userDataSource.userId.toString()) + + override fun onCleared() { + unsyncedChangesCountSource.removeListener(unsyncedChangesCountListener) + statisticsSource.removeListener(statisticsListener) + userDataSource.removeListener(userListener) + userUpdater.removeUserAvatarListener(userAvatarListener) + achievementsSource.removeListener(achievementsListener) + } +} 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 new file mode 100644 index 0000000000..41fd517b74 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/profile/RankLevel.kt @@ -0,0 +1,23 @@ +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/screens/user/statistics/EditStatisticsFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsFragment.kt index 876a4c8944..511ef1532d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsFragment.kt @@ -10,12 +10,15 @@ import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.edits.EditType import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource import de.westnordost.streetcomplete.databinding.FragmentEditStatisticsBinding +import de.westnordost.streetcomplete.screens.user.profile.ProfileViewModel +import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.viewBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel /** Shows the user's edits of each type in some kind of ball pit. Clicking on each opens * a EditTypeInfoFragment that shows the quest's details. */ @@ -23,7 +26,6 @@ class EditStatisticsFragment : Fragment(R.layout.fragment_edit_statistics), StatisticsByEditTypeFragment.Listener, StatisticsByCountryFragment.Listener { - private val statisticsSource: StatisticsSource by inject() interface Listener { fun onClickedEditType(editType: EditType, editCount: Int, questBubbleView: View) @@ -31,15 +33,12 @@ class EditStatisticsFragment : } private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener + private val viewModel by viewModel() private val binding by viewBinding(FragmentEditStatisticsBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewLifecycleScope.launch { - binding.emptyText.isGone = withContext(Dispatchers.IO) { statisticsSource.getEditCount() != 0 } - } - binding.byEditTypeButton.setOnClickListener { v -> binding.selectorButton.check(v.id) } binding.byCountryButton.setOnClickListener { v -> binding.selectorButton.check(v.id) } @@ -51,15 +50,14 @@ class EditStatisticsFragment : } } } - } - - override fun onStart() { - super.onStart() - - if (statisticsSource.isSynchronizing) { - binding.emptyText.setText(R.string.stats_are_syncing) - } else { - binding.emptyText.setText(R.string.quests_empty) + observe(viewModel.hasEdits) { hasEdits -> + binding.emptyText.isGone = hasEdits + } + observe(viewModel.isSynchronizingStatistics) { isSynchronizing -> + binding.emptyText.setText( + if (isSynchronizing) R.string.stats_are_syncing + else R.string.quests_empty + ) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModel.kt new file mode 100644 index 0000000000..625246a8a5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModel.kt @@ -0,0 +1,18 @@ +package de.westnordost.streetcomplete.screens.user.statistics + +import androidx.lifecycle.ViewModel +import de.westnordost.streetcomplete.data.osm.edits.EditType +import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics +import kotlinx.coroutines.flow.StateFlow + +abstract class EditStatisticsViewModel: ViewModel() { + abstract val hasEdits: StateFlow + abstract val isSynchronizingStatistics: StateFlow + abstract val countryStatistics: StateFlow> + abstract val editTypeStatistics: StateFlow> + + abstract fun queryCountryStatistics() + abstract fun queryEditTypeStatistics() +} + +data class EditTypeObjStatistics(val type: EditType, val count: Int) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModelImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModelImpl.kt new file mode 100644 index 0000000000..269c6ec95d --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/EditStatisticsViewModelImpl.kt @@ -0,0 +1,41 @@ +package de.westnordost.streetcomplete.screens.user.statistics + +import de.westnordost.streetcomplete.data.AllEditTypes +import de.westnordost.streetcomplete.data.user.statistics.CountryStatistics +import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource +import de.westnordost.streetcomplete.util.ktx.launch +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.MutableStateFlow + +class EditStatisticsViewModelImpl( + private val statisticsSource: StatisticsSource, + private val allEditTypes: AllEditTypes, +) : EditStatisticsViewModel() { + + override val hasEdits = MutableStateFlow(true) + override val isSynchronizingStatistics = MutableStateFlow(false) + override val countryStatistics = MutableStateFlow>(emptyList()) + override val editTypeStatistics = MutableStateFlow>(emptyList()) + + // no updating of data implemented (because actually not needed. Not possible to add edits + // while in this screen) + + init { + isSynchronizingStatistics.value = statisticsSource.isSynchronizing + launch(IO) { hasEdits.value = statisticsSource.getEditCount() > 0 } + } + + override fun queryCountryStatistics() { + launch(IO) { countryStatistics.value = statisticsSource.getCountryStatistics() } + } + + override fun queryEditTypeStatistics() { + launch(IO) { editTypeStatistics.value = getEditTypeStatistics() } + } + + private fun getEditTypeStatistics(): Collection = + statisticsSource.getEditTypeStatistics().mapNotNull { + val editType = allEditTypes.getByName(it.type) ?: return@mapNotNull null + EditTypeObjStatistics(editType, it.count) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByCountryFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByCountryFragment.kt index 87d9778acf..36219e93b6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByCountryFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByCountryFragment.kt @@ -5,19 +5,14 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource import de.westnordost.streetcomplete.databinding.FragmentStatisticsBallPitBinding import de.westnordost.streetcomplete.util.ktx.dpToPx -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.viewBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel /** Shows the user's solved quests of each type in some kind of ball pit. */ class StatisticsByCountryFragment : Fragment(R.layout.fragment_statistics_ball_pit) { - private val statisticsSource: StatisticsSource by inject() interface Listener { fun onClickedCountryFlag(countryCode: String, solvedCount: Int, rank: Int?, countryBubbleView: View) @@ -25,18 +20,24 @@ class StatisticsByCountryFragment : Fragment(R.layout.fragment_statistics_ball_p private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener private val binding by viewBinding(FragmentStatisticsBallPitBinding::bind) + private val viewModel by viewModel(ownerProducer = { requireParentFragment() }) + private var hasCreatedBallPit: Boolean = false // only add it once, no update + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.queryCountryStatistics() + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - lifecycle.addObserver(binding.ballPitView) - - viewLifecycleScope.launch { - val countriesStatistics = withContext(Dispatchers.IO) { statisticsSource.getCountryStatistics() } - - binding.ballPitView.setViews(countriesStatistics.map { - createCountryBubbleView(it.countryCode, it.count, it.rank) to it.count - }) + observe(viewModel.countryStatistics) { countryStatistics -> + if (countryStatistics.isNotEmpty() && !hasCreatedBallPit) { + binding.ballPitView.setViews(countryStatistics.map { + createCountryBubbleView(it.countryCode, it.count, it.rank) to it.count + }) + hasCreatedBallPit = true + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByEditTypeFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByEditTypeFragment.kt index 0c8c9fb799..ff035c8f9b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByEditTypeFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/statistics/StatisticsByEditTypeFragment.kt @@ -9,25 +9,16 @@ import android.widget.ImageView import androidx.fragment.app.Fragment import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.osm.edits.EditType -import de.westnordost.streetcomplete.data.overlays.OverlayRegistry -import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry -import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource import de.westnordost.streetcomplete.databinding.FragmentStatisticsBallPitBinding import de.westnordost.streetcomplete.util.ktx.dpToPx -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope +import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.viewBinding import de.westnordost.streetcomplete.view.CircularOutlineProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel /** Shows the user's solved quests of each type in some kind of ball pit. Clicking on each opens * a QuestTypeInfoFragment that shows the quest's details. */ class StatisticsByEditTypeFragment : Fragment(R.layout.fragment_statistics_ball_pit) { - private val statisticsSource: StatisticsSource by inject() - private val questTypeRegistry: QuestTypeRegistry by inject() - private val overlayRegistry: OverlayRegistry by inject() interface Listener { fun onClickedQuestType(editType: EditType, solvedCount: Int, questBubbleView: View) @@ -35,21 +26,24 @@ class StatisticsByEditTypeFragment : Fragment(R.layout.fragment_statistics_ball_ private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener private val binding by viewBinding(FragmentStatisticsBallPitBinding::bind) + private val viewModel by viewModel(ownerProducer = { requireParentFragment() }) + private var hasCreatedBallPit: Boolean = false // only add it once, no update - /* --------------------------------------- Lifecycle ---------------------------------------- */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.queryEditTypeStatistics() + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - lifecycle.addObserver(binding.ballPitView) - - viewLifecycleScope.launch { - val statistics = withContext(Dispatchers.IO) { statisticsSource.getEditTypeStatistics() } - binding.ballPitView.setViews(statistics.mapNotNull { statistic -> - val editType = questTypeRegistry.getByName(statistic.type) - ?: overlayRegistry.getByName(statistic.type) ?: return@mapNotNull null - createEditTypeBubbleView(editType, statistic.count) to statistic.count - }) + observe(viewModel.editTypeStatistics) { editTypeStatistics -> + if (editTypeStatistics.isNotEmpty() && !hasCreatedBallPit) { + binding.ballPitView.setViews(editTypeStatistics.map { + createEditTypeBubbleView(it.type, it.count) to it.count + }) + hasCreatedBallPit = true + } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt index f858e3f695..c8900816a6 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Fragment.kt @@ -3,8 +3,14 @@ package de.westnordost.streetcomplete.util.ktx import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import de.westnordost.streetcomplete.screens.HasTitle +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch fun Fragment.openUri(uri: String) = context?.openUri(uri) ?: false @@ -31,3 +37,13 @@ fun Fragment.setUpToolbarTitleAndIcon(toolbar: Toolbar) { toolbar.navigationIcon = backIcon } + +fun Fragment.observe(flow: SharedFlow, collector: FlowCollector) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect { + collector.emit(it) + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/ViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/ViewModel.kt new file mode 100644 index 0000000000..42072bae75 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/ViewModel.kt @@ -0,0 +1,26 @@ +package de.westnordost.streetcomplete.util.ktx + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +// convenience shortcuts + +fun ViewModel.launch( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit +): Job = viewModelScope.launch(context, start, block) + +fun ViewModel.async( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> T +): Deferred = viewModelScope.async(context, start, block) diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/prefs/AndroidPreferences.kt b/app/src/main/java/de/westnordost/streetcomplete/util/prefs/AndroidPreferences.kt index 509030f25d..8b8968b24c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/prefs/AndroidPreferences.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/prefs/AndroidPreferences.kt @@ -64,6 +64,10 @@ class AndroidPreferences(private val prefs: SharedPreferences) : Preferences { override fun getStringOrNull(key: String): String? = prefs.getString(key, null) + override fun getIntOrNull(key: String): Int? { + return if (prefs.contains(key)) prefs.getInt(key, 0) else null + } + override fun remove(key: String) { prefs.edit { remove(key) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/prefs/Preferences.kt b/app/src/main/java/de/westnordost/streetcomplete/util/prefs/Preferences.kt index 5a01ba9bef..5767891308 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/prefs/Preferences.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/prefs/Preferences.kt @@ -15,7 +15,9 @@ interface Preferences { fun getLong(key: String, defaultValue: Long): Long fun getFloat(key: String, defaultValue: Float): Float fun getDouble(key: String, defaultValue: Double): Double + fun getStringOrNull(key: String): String? + fun getIntOrNull(key: String): Int? fun remove(key: String)