Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/org/wikipedia/dataclient/Service.kt
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ interface Service {
@Field("url") url: String,
): ShortenUrlResponse

@GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info&inprop=varianttitles|displaytitle&pilicense=any")
@GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info|pageviews&pvipdays=$GEO_SEARCH_PAGE_VIEWS_DAYS&inprop=varianttitles|displaytitle&pilicense=any")
suspend fun getGeoSearch(
@Query("ggscoord", encoded = true) coordinates: String,
@Query("ggsradius") radius: Int,
Expand Down Expand Up @@ -779,6 +779,8 @@ interface Service {
const val URL_FRAGMENT_FROM_COMMONS = "/wikipedia/commons/"
const val MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=html&errorsuselocal=1&"
const val PREFERRED_THUMB_SIZE = 330
// Days of pageview history fetched per page; summed as a popularity score in Places.
const val GEO_SEARCH_PAGE_VIEWS_DAYS = 30

// Maximum cache time for site-specific data, and other things not likely to change very often.
const val SITE_INFO_MAXAGE = 86400
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class NearbyPage(
val pageTitle: PageTitle,
val latitude: Double,
val longitude: Double,
val pageViews: Long = 0L,
var annotation: Symbol? = null,
var bitmap: Bitmap? = null
) {
Expand Down
44 changes: 40 additions & 4 deletions app/src/main/java/org/wikipedia/places/PlacesFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
Expand Down Expand Up @@ -99,6 +100,7 @@ import org.wikipedia.views.ViewUtil
import org.wikipedia.views.imageservice.ImageService
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ln

class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPreviewDialog.DismissCallback, MapLibreMap.OnMapClickListener {

Expand Down Expand Up @@ -130,6 +132,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
latitudeDiffToMeters(it.projection.visibleRegion.latLngBounds.latitudeSpan / 2)
} ?: 50
private var magnifiedMarker: Symbol? = null
private var maxPageViews: Long = 0L

private val locationPermissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
when {
Expand Down Expand Up @@ -245,6 +248,11 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
filterLauncher.launch(PlacesFilterActivity.newIntent(requireActivity()))
}

binding.sortButton.setOnClickListener {
PlacesEvent.logAction("sort_click", "search_bar_view")
showSortMenu(it)
}

binding.searchCloseBtn.setOnClickListener {
PlacesEvent.logAction("search_clear_click", "search_bar_view")
updateSearchText()
Expand Down Expand Up @@ -432,6 +440,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
binding.listEmptyContainer.isVisible = !isMapVisible && (binding.listRecyclerView.adapter?.itemCount ?: 0) == 0
binding.searchContainer.backgroundTintList = tintColor
binding.myLocationButton.isVisible = isMapVisible
binding.sortButton.isVisible = !isMapVisible
}

private fun showLinkPreview(pageTitle: PageTitle, location: Location) {
Expand All @@ -443,15 +452,38 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
LinkPreviewDialog.newInstance(entry, location, getLastKnownUserLocation()))
}

private fun showSortMenu(anchor: View) {
PopupMenu(requireContext(), anchor).apply {
menuInflater.inflate(R.menu.menu_places_sort, menu)
menu.findItem(viewModel.sortMode.menuId).isChecked = true
setOnMenuItemClickListener { item ->
val newMode = SortMode.entries.first { it.menuId == item.itemId }
if (viewModel.sortMode != newMode) {
viewModel.sortMode = newMode
viewModel.applySort()
PlacesEvent.logAction("sort_select", "search_bar_view", newMode.name.lowercase())
}
true
}
show()
}
}

private fun resetMagnifiedSymbol() {
// Reset the magnified marker to regular size
magnifiedMarker?.let {
it.iconSize = 1.0f
symbolManager?.update(it)
magnifiedMarker?.let { marker ->
val page = annotationCache.find { it.annotation == marker }
marker.iconSize = page?.let { popularityIconSize(it.pageViews) } ?: 1.0f
symbolManager?.update(marker)
}
viewModel.highlightedPageTitle = null
}

private fun popularityIconSize(pageViews: Long): Float {
if (maxPageViews <= 0L || pageViews <= 0L) return POPULARITY_MIN_SCALE
val ratio = (ln(pageViews.toDouble() + 1.0) / ln(maxPageViews.toDouble() + 1.0)).toFloat().coerceIn(0f, 1f)
return POPULARITY_MIN_SCALE + (POPULARITY_MAX_SCALE - POPULARITY_MIN_SCALE) * ratio
}

private fun setMagnifiedSymbol(symbol: Symbol?) {
magnifiedMarker?.symbolSortKey = 0f
magnifiedMarker = symbol
Expand Down Expand Up @@ -588,6 +620,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
}

private fun updateMapMarkers(pages: List<NearbyPage>) {
maxPageViews = pages.maxOfOrNull { it.pageViews } ?: 0L
symbolManager?.let { manager ->

pages.filter {
Expand All @@ -598,6 +631,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
.withLatLng(LatLng(it.latitude, it.longitude))
.withTextFont(MARKER_FONT_STACK)
.withIconImage(MARKER_DRAWABLE)
.withIconSize(popularityIconSize(it.pageViews))
)
if (viewModel.highlightedPageTitle?.prefixedText.orEmpty() == it.pageTitle.prefixedText) {
setMagnifiedSymbol(it.annotation)
Expand Down Expand Up @@ -810,6 +844,8 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi
const val MAX_ANNOTATIONS = 250
const val THUMB_SIZE = 120
const val ITEMS_PER_REQUEST = 75
const val POPULARITY_MIN_SCALE = 0.7f
const val POPULARITY_MAX_SCALE = 1.4f
const val CLUSTER_TEXT_LAYER_ID = "mapbox-android-cluster-text"
const val CLUSTER_CIRCLE_LAYER_ID = "mapbox-android-cluster-circle0"
const val ZOOM_IN_ANIMATION_DURATION = 1000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import org.wikipedia.Constants
import org.wikipedia.R
import org.wikipedia.dataclient.ServiceFactory
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.page.NearbyPage
Expand All @@ -16,14 +17,36 @@ import org.wikipedia.settings.Prefs
import org.wikipedia.util.ImageUrlUtil
import org.wikipedia.util.Resource

enum class SortMode(val menuId: Int) {
POPULARITY(R.id.menu_places_sort_popularity),
DISTANCE(R.id.menu_places_sort_distance)
}

internal fun sortNearbyPages(
pages: List<NearbyPage>,
sortMode: SortMode,
lastKnownLocation: Location?
): List<NearbyPage> = when (sortMode) {
SortMode.DISTANCE -> pages.sortedBy {
lastKnownLocation?.run { it.location.distanceTo(this) }
}
SortMode.POPULARITY -> pages.sortedByDescending { it.pageViews }
}

class PlacesFragmentViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val wikiSite: WikiSite get() = WikiSite.forLanguageCode(Prefs.placesWikiCode)
var location: Location? = savedStateHandle[PlacesActivity.EXTRA_LOCATION]
var highlightedPageTitle: PageTitle? = savedStateHandle[Constants.ARG_TITLE]

var lastKnownLocation: Location? = null
// Default to popularity: easier to discover interesting nearby places.
var sortMode: SortMode
get() = SortMode.entries.firstOrNull { it.name == Prefs.placesSortMode } ?: SortMode.POPULARITY
set(value) { Prefs.placesSortMode = value.name }
val nearbyPagesLiveData = MutableLiveData<Resource<List<NearbyPage>>>()

private var lastResults: List<NearbyPage> = emptyList()

fun fetchNearbyPages(latitude: Double, longitude: Double, radius: Int, maxResults: Int) {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
nearbyPagesLiveData.postValue(Resource.Error(throwable))
Expand All @@ -34,14 +57,17 @@ class PlacesFragmentViewModel(savedStateHandle: SavedStateHandle) : ViewModel()
.map {
NearbyPage(it.pageId, PageTitle(it.title, wikiSite,
if (it.thumbUrl().isNullOrEmpty()) null else ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl()!!, PlacesFragment.THUMB_SIZE),
it.description, it.displayTitle(wikiSite.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon)
}
.sortedBy {
lastKnownLocation?.run {
it.location.distanceTo(this)
}
it.description, it.displayTitle(wikiSite.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon,
// pageViewsMap is a date→count map; sum gives the N-day total.
it.pageViewsMap.values.filterNotNull().sum())
}
nearbyPagesLiveData.postValue(Resource.Success(pages))
lastResults = pages
applySort()
}
}

fun applySort() {
val sorted = sortNearbyPages(lastResults, sortMode, lastKnownLocation)
nearbyPagesLiveData.postValue(Resource.Success(sorted))
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/org/wikipedia/settings/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,10 @@ object Prefs {
get() = PrefsIoUtil.getString(R.string.preference_key_places_wiki_code, WikipediaApp.instance.appOrSystemLanguageCode).orEmpty()
set(value) = PrefsIoUtil.setString(R.string.preference_key_places_wiki_code, value)

var placesSortMode
get() = PrefsIoUtil.getString(R.string.preference_key_places_sort_mode, null).orEmpty()
set(value) = PrefsIoUtil.setString(R.string.preference_key_places_sort_mode, value)

var placesDefaultLocationLatLng
get(): String? {
val lanLng = PrefsIoUtil.getString(R.string.preference_key_default_places_location_latlng, null)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/layout/fragment_places.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/places_filter_title"/>

<ImageView
android:id="@+id/sortButton"
android:layout_width="48dp"
android:layout_height="match_parent"
android:padding="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/places_sort_button_description"
app:srcCompat="@drawable/ic_sort_white_24dp"
app:tint="?attr/primary_color"/>

<org.wikipedia.views.TabCountsView
android:id="@+id/tabsButton"
android:layout_width="48dp"
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/res/menu/menu_places_sort.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item
android:id="@+id/menu_places_sort_popularity"
android:title="@string/places_sort_popularity"/>
<item
android:id="@+id/menu_places_sort_distance"
android:title="@string/places_sort_distance"/>
</group>
</menu>
1 change: 1 addition & 0 deletions app/src/main/res/values/preference_keys.xml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
<string name="preference_key_show_recent_edits_feedback_form">showRecentEditsFeedbackForm</string>
<string name="preference_key_places_wiki_code">filterPlacesWikiCode</string>
<string name="preference_key_places_last_location_and_zoom_level">placesLastLocationAndZoomLevel</string>
<string name="preference_key_places_sort_mode">placesSortMode</string>
<string name="preference_key_recent_used_templates">recentUsedTemplates</string>
<string name="preference_key_donation_test_env">donationTestEnv</string>
<string name="preference_key_donation_results">donationResults</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,9 @@
<string name="places_filter_title">Filter language</string>
<string name="places_toggle_buttons_map">Map</string>
<string name="places_toggle_buttons_list">List</string>
<string name="places_sort_distance">Distance</string>
<string name="places_sort_popularity">Popularity</string>
<string name="places_sort_button_description">Sort nearby places</string>
<string name="places_empty_list"><![CDATA[This area is empty. Zoom out <a href="#">on the map</a>.]]></string>
<string name="places_card_title">Places nearby</string>
<string name="places_card_action">More places nearby</string>
Expand Down
83 changes: 83 additions & 0 deletions app/src/test/java/org/wikipedia/places/SortNearbyPagesTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.wikipedia.places

import android.location.Location
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.page.NearbyPage
import org.wikipedia.page.PageTitle

@RunWith(RobolectricTestRunner::class)
class SortNearbyPagesTest {

@Test
fun popularitySort_orderedByPageViewsDescending() {
val pages = listOf(
page(id = 1, lat = 0.0, lon = 0.0, views = 100),
page(id = 2, lat = 0.0, lon = 0.0, views = 9000),
page(id = 3, lat = 0.0, lon = 0.0, views = 50)
)

val sorted = sortNearbyPages(pages, SortMode.POPULARITY, lastKnownLocation = null)

assertEquals(listOf(2, 1, 3), sorted.map { it.pageId })
}

@Test
fun distanceSort_orderedByProximityToLastKnownLocation() {
val origin = location(0.0, 0.0)
val pages = listOf(
page(id = 1, lat = 1.0, lon = 0.0, views = 0),
page(id = 2, lat = 0.1, lon = 0.0, views = 0),
page(id = 3, lat = 0.5, lon = 0.0, views = 0)
)

val sorted = sortNearbyPages(pages, SortMode.DISTANCE, lastKnownLocation = origin)

assertEquals(listOf(2, 3, 1), sorted.map { it.pageId })
}

@Test
fun distanceSort_withoutLocation_isStableNoOp() {
val pages = listOf(
page(id = 1, lat = 5.0, lon = 5.0, views = 100),
page(id = 2, lat = 0.0, lon = 0.0, views = 9000)
)

val sorted = sortNearbyPages(pages, SortMode.DISTANCE, lastKnownLocation = null)

assertEquals(listOf(1, 2), sorted.map { it.pageId })
}

@Test
fun popularitySort_zeroViewsSortLast() {
val pages = listOf(
page(id = 1, lat = 0.0, lon = 0.0, views = 0),
page(id = 2, lat = 0.0, lon = 0.0, views = 1),
page(id = 3, lat = 0.0, lon = 0.0, views = 0)
)

val sorted = sortNearbyPages(pages, SortMode.POPULARITY, lastKnownLocation = null)

assertEquals(2, sorted.first().pageId)
}

private fun page(id: Int, lat: Double, lon: Double, views: Long) = NearbyPage(
pageId = id,
pageTitle = PageTitle("Page $id", WIKI),
latitude = lat,
longitude = lon,
pageViews = views
)

private fun location(lat: Double, lon: Double) = Location("test").apply {
latitude = lat
longitude = lon
}

companion object {
private val WIKI = WikiSite.forLanguageCode("en")
}
}