From b3dac2a53131120704e75b504bf3498d319a08f2 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 22 Feb 2026 12:26:49 +0100 Subject: [PATCH] chore: Migrate Location picker to composables, with custom zoom controls and night mode support active in dark mode Signed-off-by: Andy Scherzinger --- .../talk/dagger/modules/ViewModelModule.kt | 6 + .../talk/location/GeocodingActivity.kt | 8 +- .../talk/location/LocationPickerActivity.kt | 591 ++---------- .../components/LocationPickerScreen.kt | 885 ++++++++++++++++++ .../viewmodels/LocationPickerViewModel.kt | 253 +++++ .../ic_baseline_location_on_red_24.xml | 16 +- app/src/main/res/layout/activity_location.xml | 136 --- app/src/main/res/menu/menu_locationpicker.xml | 19 - app/src/main/res/values/setup.xml | 1 + app/src/main/res/values/strings.xml | 3 + 10 files changed, 1222 insertions(+), 696 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/location/components/LocationPickerScreen.kt create mode 100644 app/src/main/java/com/nextcloud/talk/viewmodels/LocationPickerViewModel.kt delete mode 100644 app/src/main/res/layout/activity_location.xml delete mode 100644 app/src/main/res/menu/menu_locationpicker.xml diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index a48e0edde00..e4f394fa589 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -34,6 +34,7 @@ import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel import com.nextcloud.talk.translate.viewmodels.TranslateViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel +import com.nextcloud.talk.viewmodels.LocationPickerViewModel import dagger.Binds import dagger.MapKey import dagger.Module @@ -100,6 +101,11 @@ abstract class ViewModelModule { @ViewModelKey(CallRecordingViewModel::class) abstract fun callRecordingViewModel(viewModel: CallRecordingViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(LocationPickerViewModel::class) + abstract fun locationPickerViewModel(viewModel: LocationPickerViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(RaiseHandViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt index f9d89848d83..fb0286eaf76 100644 --- a/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/GeocodingActivity.kt @@ -76,12 +76,8 @@ class GeocodingActivity : BaseActivity() { private fun navigateToLocationPicker(address: Address) { val geocodingResult = GeocodingResult(address.latitude, address.longitude, address.displayName) - val intent = Intent(this, LocationPickerActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - intent.putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) - intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion) - intent.putExtra(BundleKeys.KEY_GEOCODING_RESULT, geocodingResult) - startActivity(intent) + val result = Intent().putExtra(BundleKeys.KEY_GEOCODING_RESULT, geocodingResult) + setResult(RESULT_OK, result) finish() } diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index 3733c2ee312..4ce1fb94634 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -6,584 +6,115 @@ */ package com.nextcloud.talk.location -import android.Manifest -import android.app.Activity -import android.app.SearchManager -import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager import android.os.Bundle -import android.text.InputType -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.EditorInfo import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.SearchView -import androidx.core.content.PermissionChecker -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.toDrawable -import androidx.core.view.MenuItemCompat +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.MaterialTheme +import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import autodagger.AutoInjector -import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity -import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.databinding.ActivityLocationBinding -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.extensions.getParcelableExtraProvider +import com.nextcloud.talk.location.components.LocationPickerScreen import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CHAT_API_VERSION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_GEOCODING_RESULT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN -import fr.dudie.nominatim.client.TalkJsonNominatimClient -import fr.dudie.nominatim.model.Address -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient +import com.nextcloud.talk.viewmodels.LocationPickerViewModel import org.osmdroid.config.Configuration.getInstance -import org.osmdroid.events.DelayedMapListener -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.overlay.CopyrightOverlay -import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class LocationPickerActivity : - BaseActivity(), - SearchView.OnQueryTextListener, - LocationListener { - - private lateinit var binding: ActivityLocationBinding - - @Inject - lateinit var ncApi: NcApi - - @Inject - lateinit var userManager: UserManager +class LocationPickerActivity : BaseActivity() { @Inject - lateinit var okHttpClient: OkHttpClient + lateinit var viewModelFactory: ViewModelProvider.Factory - var nominatimClient: TalkJsonNominatimClient? = null - - lateinit var roomToken: String + private lateinit var viewModel: LocationPickerViewModel + private lateinit var roomToken: String private var chatApiVersion: Int = 1 - var geocodingResult: GeocodingResult? = null - - var myLocation: GeoPoint = GeoPoint(COORDINATE_ZERO, COORDINATE_ZERO) - private var locationManager: LocationManager? = null - private lateinit var locationOverlay: MyLocationNewOverlay - - var moveToCurrentLocation: Boolean = true - var readyToShareLocation: Boolean = false - - private var mapCenterLat: Double = 0.0 - private var mapCenterLon: Double = 0.0 - var searchItem: MenuItem? = null - var searchView: SearchView? = null + private val geocodingLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val geocodingResult: GeocodingResult? = result.data?.getParcelableExtraProvider(KEY_GEOCODING_RESULT) + if (geocodingResult != null) { + viewModel.onGeocodingResultReceived(geocodingResult) + } + } + } private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } + @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! chatApiVersion = intent.getIntExtra(KEY_CHAT_API_VERSION, 1) - geocodingResult = intent.getParcelableExtra(KEY_GEOCODING_RESULT) - - if (savedInstanceState != null) { - moveToCurrentLocation = savedInstanceState.getBoolean("moveToCurrentLocation") == true - mapCenterLat = savedInstanceState.getDouble("mapCenterLat") - mapCenterLon = savedInstanceState.getDouble("mapCenterLon") - geocodingResult = savedInstanceState.getParcelable("geocodingResult") - } - - binding = ActivityLocationBinding.inflate(layoutInflater) - setupActionBar() - setContentView(binding.root) - initSystemBars() - - getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context)) - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - override fun onStart() { - super.onStart() - initMap() - } - - override fun onResume() { - super.onResume() - - if (geocodingResult != null) { - moveToCurrentLocation = false - } - - setLocationDescription(false, geocodingResult != null) - binding.shareLocation.isClickable = false - binding.shareLocation.setOnClickListener { - if (readyToShareLocation) { - shareLocation( - binding.map.mapCenter?.latitude, - binding.map.mapCenter?.longitude, - binding.placeName.text.toString() - ) - } else { - Log.w(TAG, "readyToShareLocation was false while user tried to share location.") - } - } - } - - override fun onSaveInstanceState(bundle: Bundle) { - super.onSaveInstanceState(bundle) - bundle.putBoolean("moveToCurrentLocation", moveToCurrentLocation) - bundle.putDouble("mapCenterLat", binding.map.mapCenter.latitude) - bundle.putDouble("mapCenterLon", binding.map.mapCenter.longitude) - bundle.putParcelable("geocodingResult", geocodingResult) - } - - private fun setupActionBar() { - setSupportActionBar(binding.locationPickerToolbar) - binding.locationPickerToolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setIcon(resources!!.getColor(android.R.color.transparent, null).toDrawable()) - supportActionBar?.title = context.getString(R.string.nc_share_location) - viewThemeUtils.material.themeToolbar(binding.locationPickerToolbar) - } + viewModel = ViewModelProvider(this, viewModelFactory)[LocationPickerViewModel::class.java] - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - menuInflater.inflate(R.menu.menu_locationpicker, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - super.onPrepareOptionsMenu(menu) - searchItem = menu.findItem(R.id.location_action_search) - initSearchView() - return true - } - - @Suppress("Detekt.TooGenericExceptionCaught") - override fun onStop() { - super.onStop() - - try { - locationManager!!.removeUpdates(this) - } catch (e: Exception) { - Log.e(TAG, "error when trying to remove updates for location Manager", e) - } - - locationOverlay.disableMyLocation() - } - - private fun initSearchView() { - val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager - if (searchItem != null) { - searchView = MenuItemCompat.getActionView(searchItem) as SearchView - searchView?.maxWidth = Int.MAX_VALUE - searchView?.inputType = InputType.TYPE_TEXT_VARIATION_FILTER - var imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN - if (appPreferences!!.isKeyboardIncognito) { - imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING - } - searchView?.imeOptions = imeOptions - searchView?.queryHint = resources!!.getString(R.string.nc_search) - searchView?.setSearchableInfo(searchManager.getSearchableInfo(componentName)) - searchView?.setOnQueryTextListener(this) - } - } - - override fun onQueryTextSubmit(query: String?): Boolean { - if (!query.isNullOrEmpty()) { - val intent = Intent(this, GeocodingActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - intent.putExtra(BundleKeys.KEY_GEOCODING_QUERY, query) - intent.putExtra(KEY_ROOM_TOKEN, roomToken) - intent.putExtra(KEY_CHAT_API_VERSION, chatApiVersion) - startActivity(intent) - } - return true - } - - override fun onQueryTextChange(newText: String?): Boolean = true - - @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.ComplexMethod", "Detekt.LongMethod") - private fun initMap() { - binding.map.setTileSource(TileSourceFactory.MAPNIK) - binding.map.onResume() - - locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager - - if (!isLocationPermissionsGranted()) { - requestLocationPermissions() - } else { - requestLocationUpdates() - } - - val copyrightOverlay = CopyrightOverlay(context) - binding.map.overlays.add(copyrightOverlay) - - binding.map.setMultiTouchControls(true) - binding.map.isTilesScaledToDpi = true - - locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map) - locationOverlay.enableMyLocation() - locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y) - locationOverlay.setPersonIcon( - DisplayUtils.getBitmap( - ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)!! - ) - ) - binding.map.overlays.add(locationOverlay) - - val mapController = binding.map.controller - - if (geocodingResult != null) { - mapController.setZoom(ZOOM_LEVEL_RECEIVED_RESULT) + val geocodingResult: GeocodingResult? = if (savedInstanceState != null) { + savedInstanceState.getParcelable("geocodingResult") } else { - mapController.setZoom(ZOOM_LEVEL_DEFAULT) + intent.getParcelableExtraProvider(KEY_GEOCODING_RESULT) } - if (mapCenterLat != 0.0 && mapCenterLon != 0.0) { - mapController.setCenter(GeoPoint(mapCenterLat, mapCenterLon)) - } + val moveToCurrentLocation = savedInstanceState?.getBoolean("moveToCurrentLocation") ?: true + val mapCenterLat = savedInstanceState?.getDouble("mapCenterLat") ?: 0.0 + val mapCenterLon = savedInstanceState?.getDouble("mapCenterLon") ?: 0.0 - val zoomToCurrentPositionOnFirstFix = geocodingResult == null && moveToCurrentLocation - locationOverlay.runOnFirstFix { - if (locationOverlay.myLocation != null) { - myLocation = locationOverlay.myLocation - if (zoomToCurrentPositionOnFirstFix) { - runOnUiThread { - mapController.setZoom(ZOOM_LEVEL_DEFAULT) - mapController.setCenter(myLocation) - } - } - } else { - // locationOverlay.myLocation was null. might be an osmdroid bug? - // However that seems to be okay because runOnFirstFix is called twice somehow and the second time - // locationOverlay.myLocation is not null. - } - } + viewModel.initState(geocodingResult, moveToCurrentLocation, mapCenterLat, mapCenterLon) - geocodingResult?.let { - if (it.lat != COORDINATE_ZERO && it.lon != COORDINATE_ZERO) { - mapController.setCenter(GeoPoint(it.lat, it.lon)) - } - } + val baseUrl = getString(R.string.osm_geocoder_url) + val email = getString(R.string.osm_geocoder_contact) + viewModel.initGeocoder(baseUrl, email) - binding.centerMapButton.setOnClickListener { - if (myLocation.latitude == COORDINATE_ZERO && myLocation.longitude == COORDINATE_ZERO) { - Snackbar.make( - binding.root, - context.getString(R.string.nc_location_unknown), - Snackbar.LENGTH_LONG - ).show() - } else { - mapController.animateTo(myLocation) - moveToCurrentLocation = true - } - } - - binding.map.addMapListener( - delayedMapListener() - ) - } - - private fun delayedMapListener() = - DelayedMapListener( - object : MapListener { - @Suppress("Detekt.TooGenericExceptionCaught") - override fun onScroll(paramScrollEvent: ScrollEvent): Boolean { - try { - when { - moveToCurrentLocation -> { - setLocationDescription(isGpsLocation = true, isGeocodedResult = false) - moveToCurrentLocation = false - } - - geocodingResult != null -> { - binding.shareLocation.isClickable = true - setLocationDescription(isGpsLocation = false, isGeocodedResult = true) - geocodingResult = null - } - - else -> { - binding.shareLocation.isClickable = true - setLocationDescription(isGpsLocation = false, isGeocodedResult = false) - } - } - } catch (e: NullPointerException) { - Log.d(TAG, "UI already closed") - } - - readyToShareLocation = true - return true - } - - override fun onZoom(event: ZoomEvent): Boolean = false - } - ) - - @Suppress("Detekt.TooGenericExceptionCaught") - private fun requestLocationUpdates() { - try { - when { - locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> { - locationManager!!.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - MIN_LOCATION_UPDATE_TIME, - MIN_LOCATION_UPDATE_DISTANCE, - this - ) - } - - locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER) -> { - locationManager!!.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - MIN_LOCATION_UPDATE_TIME, - MIN_LOCATION_UPDATE_DISTANCE, - this - ) - Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER") - } - - else -> { - Log.e( - TAG, - "Error requesting location updates. Probably this is a phone without google services" + - " and there is no alternative like UnifiedNlp installed. Furthermore no GPS is " + - "supported." - ) - Snackbar.make(binding.root, context.getString(R.string.nc_location_unknown), Snackbar.LENGTH_LONG) - .show() - } - } - } catch (e: SecurityException) { - Log.e(TAG, "Error when requesting location updates. Permissions may be missing.", e) - Snackbar.make(binding.root, context.getString(R.string.nc_location_unknown), Snackbar.LENGTH_LONG).show() - } catch (e: Exception) { - Log.e(TAG, "Error when requesting location updates.", e) - Snackbar.make(binding.root, context.getString(R.string.nc_common_error_sorry), Snackbar.LENGTH_LONG).show() - } - } - - private fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) { - when { - isGpsLocation -> { - binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_current_location) - binding.placeName.visibility = View.GONE - binding.placeName.text = "" - } - - isGeocodedResult -> { - binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location) - binding.placeName.visibility = View.VISIBLE - binding.placeName.text = geocodingResult?.displayName - } - - else -> { - binding.shareLocationDescription.text = context!!.getText(R.string.nc_share_this_location) - binding.placeName.visibility = View.GONE - binding.placeName.text = "" - } - } - } - - private fun shareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) { - if (selectedLat != null || selectedLon != null) { - val name = locationName - if (name.isNullOrEmpty()) { - initGeocoder() - searchPlaceNameForCoordinates(selectedLat!!, selectedLon!!) - } else { - executeShareLocation(selectedLat, selectedLon, locationName) - } - } - } - - private fun executeShareLocation(selectedLat: Double?, selectedLon: Double?, locationName: String?) { - binding.roundedImageView.visibility = View.GONE - binding.sendingLocationProgressbar.visibility = View.VISIBLE - - val objectId = "geo:$selectedLat,$selectedLon" - - var locationNameToShare = locationName - if (locationNameToShare.isNullOrBlank()) { - locationNameToShare = resources.getString(R.string.nc_shared_location) - } - - val metaData: String = - "{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\",\"latitude\":\"$selectedLat\"," + - "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}" - - val currentUser = currentUserProviderOld.currentUser.blockingGet() - - ncApi.sendLocation( - ApiUtils.getCredentials(currentUser.username, currentUser.token), - ApiUtils.getUrlToSendLocation(chatApiVersion, currentUser.baseUrl!!, roomToken), - "geo-location", - objectId, - metaData - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(t: GenericOverall) { - finish() - } - - override fun onError(e: Throwable) { - Log.e(TAG, "error when trying to share location", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - finish() - } - - override fun onComplete() { - // unused atm - } - }) - } - - private fun isLocationPermissionsGranted(): Boolean { - fun isCoarseLocationGranted(): Boolean = - PermissionChecker.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED - - fun isFineLocationGranted(): Boolean = - PermissionChecker.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PermissionChecker.PERMISSION_GRANTED - - return isCoarseLocationGranted() && isFineLocationGranted() - } - - private fun requestLocationPermissions() { - requestPermissions( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ), - REQUEST_PERMISSIONS_REQUEST_CODE - ) - } + getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context)) - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - fun areAllGranted(grantResults: IntArray): Boolean { - grantResults.forEach { - if (it == PackageManager.PERMISSION_DENIED) return false + val colorScheme = viewThemeUtils.getColorScheme(this) + setContent { + MaterialTheme(colorScheme = colorScheme) { + ColoredStatusBar() + LocationPickerScreen( + viewModel = viewModel, + roomToken = roomToken, + chatApiVersion = chatApiVersion, + onSearchClick = { navigateToGeocoding() }, + onBack = { onBackPressedDispatcher.onBackPressed() }, + onFinish = { finish() } + ) } - return grantResults.isNotEmpty() } - - if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE && areAllGranted(grantResults)) { - initMap() - } else { - Snackbar.make( - binding.root, - context!!.getString(R.string.nc_location_permission_required), - Snackbar.LENGTH_LONG - ).show() - } - } - - private fun initGeocoder() { - val baseUrl = context!!.getString(R.string.osm_geocoder_url) - val email = context!!.getString(R.string.osm_geocoder_contact) - nominatimClient = TalkJsonNominatimClient(baseUrl, okHttpClient, email) - } - - private fun searchPlaceNameForCoordinates(lat: Double, lon: Double): Boolean { - CoroutineScope(Dispatchers.IO).launch { - executeGeocodingRequest(lat, lon) - } - return true - } - - @Suppress("Detekt.TooGenericExceptionCaught") - private suspend fun executeGeocodingRequest(lat: Double, lon: Double) { - var address: Address? = null - try { - address = nominatimClient!!.getAddress(lon, lat) - } catch (e: Exception) { - Log.e(TAG, "Failed to get geocoded addresses", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - updateResultOnMainThread(lat, lon, address?.displayName) - } - - private suspend fun updateResultOnMainThread(lat: Double, lon: Double, addressName: String?) { - withContext(Dispatchers.Main) { - executeShareLocation(lat, lon, addressName) - } - } - - override fun onLocationChanged(location: Location) { - myLocation = GeoPoint(location) - } - - @Deprecated("Deprecated. This callback will never be invoked on Android Q and above.") - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { - // empty - } - - override fun onProviderEnabled(provider: String) { - // empty } - override fun onProviderDisabled(provider: String) { - // empty + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val state = viewModel.uiState.value + outState.putBoolean("moveToCurrentLocation", state.moveToCurrentLocation) + outState.putDouble("mapCenterLat", state.mapCenterLat) + outState.putDouble("mapCenterLon", state.mapCenterLon) + outState.putParcelable("geocodingResult", state.geocodingResult) } - companion object { - private val TAG = LocationPickerActivity::class.java.simpleName - private const val REQUEST_PERMISSIONS_REQUEST_CODE = 1 - private const val PERSON_HOT_SPOT_X: Float = 20.0F - private const val PERSON_HOT_SPOT_Y: Float = 20.0F - private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0 - private const val ZOOM_LEVEL_DEFAULT: Double = 14.0 - private const val COORDINATE_ZERO: Double = 0.0 - private const val MIN_LOCATION_UPDATE_TIME: Long = 30 * 1000L - private const val MIN_LOCATION_UPDATE_DISTANCE: Float = 0f + private fun navigateToGeocoding() { + val intent = Intent(this, GeocodingActivity::class.java) + intent.putExtra(KEY_ROOM_TOKEN, roomToken) + intent.putExtra(KEY_CHAT_API_VERSION, chatApiVersion) + geocodingLauncher.launch(intent) } } diff --git a/app/src/main/java/com/nextcloud/talk/location/components/LocationPickerScreen.kt b/app/src/main/java/com/nextcloud/talk/location/components/LocationPickerScreen.kt new file mode 100644 index 00000000000..3b26ff8c3ab --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/location/components/LocationPickerScreen.kt @@ -0,0 +1,885 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +@file:Suppress("TooManyFunctions") + +package com.nextcloud.talk.location.components + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.location.LocationListener +import android.location.LocationManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.PermissionChecker +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nextcloud.talk.R +import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.viewmodels.LocationPickerViewModel +import kotlinx.coroutines.launch +import org.osmdroid.events.DelayedMapListener +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController.Visibility +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.TilesOverlay +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay + +private const val PERSON_HOT_SPOT_X: Float = 0.5F +private const val PERSON_HOT_SPOT_Y: Float = 0.5F +private const val ZOOM_LEVEL_RECEIVED_RESULT: Double = 14.0 +private const val ZOOM_LEVEL_DEFAULT: Double = 14.0 +private const val COORDINATE_ZERO: Double = 0.0 +private const val MIN_LOCATION_UPDATE_TIME: Long = 30 * 1000L +private const val MIN_LOCATION_UPDATE_DISTANCE: Float = 0f +private const val PIN_HEIGHT_DP = 50 +private const val TAG = "LocationPickerScreen" + +@Suppress("Detekt.LongMethod", "Detekt.LongParameterList") +@Composable +fun LocationPickerScreen( + viewModel: LocationPickerViewModel, + roomToken: String, + chatApiVersion: Int, + onSearchClick: () -> Unit, + onBack: () -> Unit, + onFinish: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + var myLocation by remember { mutableStateOf(GeoPoint(COORDINATE_ZERO, COORDINATE_ZERO)) } + + val mapView = remember { MapView(context) } + + @SuppressLint("LocalContextResourcesRead") + val locationOverlay = remember { + MyLocationNewOverlay(GpsMyLocationProvider(context), mapView).apply { + enableMyLocation() + setPersonIcon( + DisplayUtils.getBitmap( + ResourcesCompat.getDrawable(context.resources, R.drawable.current_location_circle, null)!! + ) + ) + setPersonAnchor(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y) + } + } + + val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager } + + val locationListener = remember { + LocationListener { location -> myLocation = GeoPoint(location) } + } + + @SuppressLint("LocalContextGetResourceValueCall") + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.all { it }) { + requestLocationUpdates(locationManager, locationListener) { msgRes -> + coroutineScope.launch { snackbarHostState.showSnackbar(context.getString(msgRes)) } + } + } else { + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.nc_location_permission_required)) + } + } + } + + val isDarkMode = isSystemInDarkTheme() + LaunchedEffect(isDarkMode) { + val colorFilter = if (isDarkMode) TilesOverlay.INVERT_COLORS else null + mapView.overlayManager.tilesOverlay.setColorFilter(colorFilter) + } + + LaunchedEffect(uiState.geocodingResult) { + val result = uiState.geocodingResult ?: return@LaunchedEffect + if (result.lat != COORDINATE_ZERO && result.lon != COORDINATE_ZERO) { + mapView.controller.animateTo(GeoPoint(result.lat, result.lon)) + viewModel.updateMapCenter(result.lat, result.lon) + } + } + + @SuppressLint("LocalContextGetResourceValueCall") + LaunchedEffect(uiState.viewState) { + when (val state = uiState.viewState) { + is LocationPickerViewModel.ViewState.LocationShared -> { + viewModel.consumeViewState() + onFinish() + } + + is LocationPickerViewModel.ViewState.Error -> { + viewModel.consumeViewState() + snackbarHostState.showSnackbar(context.getString(state.messageRes)) + } + + else -> {} + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + mapView.onResume() + viewModel.setReadyToShareLocation(false) + val state = viewModel.uiState.value + viewModel.setLocationDescription( + isGpsLocation = false, + isGeocodedResult = state.geocodingResult != null + ) + } + + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + Lifecycle.Event.ON_STOP -> { + locationOverlay.disableMyLocation() + @Suppress("Detekt.TooGenericExceptionCaught") + try { + locationManager.removeUpdates(locationListener) + } catch (e: Exception) { + Log.e(TAG, "error when trying to remove updates for location Manager", e) + } + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapView.onDetach() + } + } + + val sharedLocationFallbackName = stringResource(R.string.nc_shared_location) + + @SuppressLint("LocalContextGetResourceValueCall") + LocationPickerScreenContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onSearchClick = onSearchClick, + onBack = onBack, + onShareClick = { + viewModel.shareLocation( + selectedLat = mapView.mapCenter?.latitude, + selectedLon = mapView.mapCenter?.longitude, + locationName = uiState.placeName.ifEmpty { null }, + sharedLocationFallbackName = sharedLocationFallbackName, + roomToken = roomToken, + chatApiVersion = chatApiVersion + ) + }, + onCenterClick = { + if (myLocation.latitude == COORDINATE_ZERO && myLocation.longitude == COORDINATE_ZERO) { + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.nc_location_unknown)) + } + } else { + mapView.controller.animateTo(myLocation) + viewModel.setMoveToCurrentLocation(true) + } + }, + onZoomIn = { mapView.controller.zoomIn() }, + onZoomOut = { mapView.controller.zoomOut() }, + mapContent = { + AndroidView( + factory = { ctx -> + setupMapView( + mapView, + locationOverlay, + locationManager, + locationListener, + viewModel, + permissionLauncher, + ctx + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + ) +} + +@Suppress("Detekt.LongParameterList") +@Composable +private fun LocationPickerScreenContent( + uiState: LocationPickerViewModel.UiState, + snackbarHostState: SnackbarHostState, + onSearchClick: () -> Unit, + onBack: () -> Unit, + onShareClick: () -> Unit, + onCenterClick: () -> Unit, + onZoomIn: () -> Unit, + onZoomOut: () -> Unit, + mapContent: @Composable BoxScope.() -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + mapContent() + LocationPickerMapOverlays() + LocationPickerSearchCard(onBack = onBack, onSearchClick = onSearchClick) + LocationPickerBottomControls( + uiState = uiState, + snackbarHostState = snackbarHostState, + onShareClick = onShareClick, + onCenterClick = onCenterClick, + onZoomIn = onZoomIn, + onZoomOut = onZoomOut + ) + } + } +} + +@Composable +private fun BoxScope.LocationPickerSearchCard(onBack: () -> Unit, onSearchClick: () -> Unit) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + modifier = Modifier + .widthIn(min = 360.dp, max = 720.dp) + .fillMaxWidth() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + .align(Alignment.TopCenter), + shape = RoundedCornerShape(28.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back_button) + ) + } + Text( + text = stringResource(R.string.nc_search_location), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .wrapContentHeight(Alignment.CenterVertically) + .clickable(onClick = onSearchClick), + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton(onClick = onSearchClick) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.nc_search)) + } + } + } +} + +@Composable +private fun LocationPickerZoomControls(onZoomIn: () -> Unit, onZoomOut: () -> Unit, onCenterClick: () -> Unit) { + val zoomColors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Column( + modifier = Modifier.padding(end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilledIconButton(onClick = onZoomIn, colors = zoomColors) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.nc_zoom_in)) + } + FilledIconButton(onClick = onZoomOut, colors = zoomColors) { + Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.nc_zoom_out)) + } + FloatingActionButton(onClick = onCenterClick) { + Icon(painter = painterResource(R.drawable.ic_baseline_gps_fixed_24), contentDescription = null) + } + } + } +} + +@Suppress("Detekt.LongParameterList") +@Composable +private fun BoxScope.LocationPickerBottomControls( + uiState: LocationPickerViewModel.UiState, + snackbarHostState: SnackbarHostState, + onShareClick: () -> Unit, + onCenterClick: () -> Unit, + onZoomIn: () -> Unit, + onZoomOut: () -> Unit +) { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val zoomColors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .navigationBarsPadding() + ) { + if (isLandscape) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + LocationPickerSharePanel( + uiState = uiState, + onShareClick = onShareClick, + modifier = Modifier.weight(1f) + ) + Row( + modifier = Modifier.padding(start = 8.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton(onClick = onZoomIn, colors = zoomColors) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.nc_zoom_in)) + } + FilledIconButton(onClick = onZoomOut, colors = zoomColors) { + Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.nc_zoom_out)) + } + FloatingActionButton(onClick = onCenterClick) { + Icon(painter = painterResource(R.drawable.ic_baseline_gps_fixed_24), contentDescription = null) + } + } + } + } else { + LocationPickerZoomControls(onZoomIn = onZoomIn, onZoomOut = onZoomOut, onCenterClick = onCenterClick) + LocationPickerSharePanel(uiState = uiState, onShareClick = onShareClick) + } + SnackbarHost(hostState = snackbarHostState) + } +} + +@Composable +private fun BoxScope.LocationPickerMapOverlays() { + Image( + painter = painterResource(R.drawable.ic_baseline_location_on_red_24), + contentDescription = stringResource(R.string.nc_location_current_position_description), + modifier = Modifier + .size(width = 30.dp, height = PIN_HEIGHT_DP.dp) + .align(Alignment.Center) + .offset(y = (-(PIN_HEIGHT_DP / 2)).dp) + ) +} + +@Suppress("LongMethod") +@Composable +private fun LocationPickerSharePanel( + uiState: LocationPickerViewModel.UiState, + onShareClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 0.dp) + .clickable(enabled = uiState.readyToShareLocation, onClick = onShareClick), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (uiState.viewState) { + is LocationPickerViewModel.ViewState.SendingLocation -> { + CircularProgressIndicator( + modifier = Modifier + .padding(12.dp) + .size(48.dp) + ) + } + + else -> { + Image( + painter = painterResource(R.drawable.ic_circular_location), + contentDescription = null, + modifier = Modifier + .padding(12.dp) + .size(48.dp) + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + val descriptionText = when (uiState.locationDescriptionType) { + LocationPickerViewModel.LocationDescriptionType.GPS -> + stringResource(R.string.nc_share_current_location) + + else -> + stringResource(R.string.nc_share_this_location) + } + Text( + text = descriptionText, + fontSize = 16.sp, + color = colorResource(R.color.high_emphasis_text) + ) + if (uiState.placeName.isNotEmpty()) { + Text( + text = uiState.placeName, + fontSize = 14.sp, + color = colorResource(R.color.medium_emphasis_text), + maxLines = 1 + ) + } + } + } + } + Text( + text = stringResource(R.string.osm_map_view_attributation), + fontSize = 12.sp, + color = colorResource(R.color.medium_emphasis_text), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } +} + +@Suppress("Detekt.LongParameterList") +private fun setupMapView( + mapView: MapView, + locationOverlay: MyLocationNewOverlay, + locationManager: LocationManager, + locationListener: LocationListener, + viewModel: LocationPickerViewModel, + permissionLauncher: ActivityResultLauncher>, + context: Context +): MapView { + return mapView.apply { + setTileSource(TileSourceFactory.MAPNIK) + onResume() + setMultiTouchControls(true) + isTilesScaledToDpi = true + overlays.add(locationOverlay) + + val mapController = controller + val initialState = viewModel.uiState.value + + if (initialState.geocodingResult != null) { + mapController.setZoom(ZOOM_LEVEL_RECEIVED_RESULT) + } else { + mapController.setZoom(ZOOM_LEVEL_DEFAULT) + } + + if (initialState.mapCenterLat != COORDINATE_ZERO && initialState.mapCenterLon != COORDINATE_ZERO) { + mapController.setCenter(GeoPoint(initialState.mapCenterLat, initialState.mapCenterLon)) + viewModel.updateMapCenter(initialState.mapCenterLat, initialState.mapCenterLon) + } + + val zoomToCurrentPositionOnFirstFix = + initialState.geocodingResult == null && initialState.moveToCurrentLocation + + locationOverlay.runOnFirstFix { + val loc = locationOverlay.myLocation ?: return@runOnFirstFix + if (zoomToCurrentPositionOnFirstFix) { + Handler(Looper.getMainLooper()).post { + mapController.setZoom(ZOOM_LEVEL_DEFAULT) + mapController.setCenter(loc) + viewModel.updateMapCenter(loc.latitude, loc.longitude) + } + } + } + + initialState.geocodingResult?.let { + if (it.lat != COORDINATE_ZERO && it.lon != COORDINATE_ZERO) { + mapController.setCenter(GeoPoint(it.lat, it.lon)) + viewModel.updateMapCenter(it.lat, it.lon) + } + } + + if (isLocationPermissionsGranted(context)) { + requestLocationUpdates(locationManager, locationListener) { /* permissions already granted */ } + } else { + permissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + ) + } + + addMapListener( + DelayedMapListener( + object : MapListener { + @Suppress("Detekt.TooGenericExceptionCaught") + override fun onScroll(event: ScrollEvent): Boolean { + try { + viewModel.onMapScrolled() + viewModel.updateMapCenter(mapCenter.latitude, mapCenter.longitude) + } catch (_: NullPointerException) { + Log.d(TAG, "UI already closed") + } + return true + } + + override fun onZoom(event: ZoomEvent): Boolean = false + } + ) + ) + + zoomController.setVisibility(Visibility.NEVER) + } +} + +private fun isLocationPermissionsGranted(context: Context): Boolean = + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PermissionChecker.PERMISSION_GRANTED && + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PermissionChecker.PERMISSION_GRANTED + +@Suppress("Detekt.TooGenericExceptionCaught") +private fun requestLocationUpdates( + locationManager: LocationManager, + locationListener: LocationListener, + onError: (Int) -> Unit +) { + try { + when { + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + MIN_LOCATION_UPDATE_TIME, + MIN_LOCATION_UPDATE_DISTANCE, + locationListener, + Looper.getMainLooper() + ) + } + + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + MIN_LOCATION_UPDATE_TIME, + MIN_LOCATION_UPDATE_DISTANCE, + locationListener, + Looper.getMainLooper() + ) + Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER") + } + + else -> { + Log.e( + TAG, + "Error requesting location updates. Probably this is a phone without google services" + + " and there is no alternative like UnifiedNlp installed. Furthermore no GPS is supported." + ) + onError(R.string.nc_location_unknown) + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Error when requesting location updates. Permissions may be missing.", e) + onError(R.string.nc_location_unknown) + } catch (e: Exception) { + Log.e(TAG, "Error when requesting location updates.", e) + onError(R.string.nc_common_error_sorry) + } +} + +private val previewMapPlaceholder: @Composable BoxScope.() -> Unit = { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Gray), + contentAlignment = Alignment.Center + ) { + Text(text = "Map Preview") + } +} + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLocationPickerScreen() { + MaterialTheme { + LocationPickerScreenContent( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GPS, + readyToShareLocation = true + ), + snackbarHostState = SnackbarHostState(), + onSearchClick = {}, + onBack = {}, + onShareClick = {}, + onCenterClick = {}, + onZoomIn = {}, + onZoomOut = {}, + mapContent = previewMapPlaceholder + ) + } +} + +@Preview( + name = "Landscape", + showBackground = true, + device = "spec:width=891dp,height=411dp,dpi=420,orientation=landscape" +) +@Composable +private fun PreviewLocationPickerScreenLandscape() { + MaterialTheme { + LocationPickerScreenContent( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GPS, + readyToShareLocation = true + ), + snackbarHostState = SnackbarHostState(), + onSearchClick = {}, + onBack = {}, + onShareClick = {}, + onCenterClick = {}, + onZoomIn = {}, + onZoomOut = {}, + mapContent = previewMapPlaceholder + ) + } +} + +@Preview(name = "Geocoded", showBackground = true) +@Composable +private fun PreviewLocationPickerScreenGeocoded() { + MaterialTheme { + LocationPickerScreenContent( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GEOCODED, + placeName = "Brandenburger Tor, Mitte, Berlin", + readyToShareLocation = true + ), + snackbarHostState = SnackbarHostState(), + onSearchClick = {}, + onBack = {}, + onShareClick = {}, + onCenterClick = {}, + onZoomIn = {}, + onZoomOut = {}, + mapContent = previewMapPlaceholder + ) + } +} + +@Preview(name = "Sending", showBackground = true) +@Composable +private fun PreviewLocationPickerScreenSending() { + MaterialTheme { + LocationPickerScreenContent( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GPS, + readyToShareLocation = true, + viewState = LocationPickerViewModel.ViewState.SendingLocation + ), + snackbarHostState = SnackbarHostState(), + onSearchClick = {}, + onBack = {}, + onShareClick = {}, + onCenterClick = {}, + onZoomIn = {}, + onZoomOut = {}, + mapContent = previewMapPlaceholder + ) + } +} + +@Preview(name = "RTL - Arabic", showBackground = true, locale = "ar") +@Composable +private fun PreviewLocationPickerScreenRtl() { + MaterialTheme { + LocationPickerScreenContent( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GEOCODED, + placeName = "برج خليفة، دبي، الإمارات العربية المتحدة", + readyToShareLocation = true + ), + snackbarHostState = SnackbarHostState(), + onSearchClick = {}, + onBack = {}, + onShareClick = {}, + onCenterClick = {}, + onZoomIn = {}, + onZoomOut = {}, + mapContent = { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Gray), + contentAlignment = Alignment.Center + ) { + Text(text = "معاينة الخريطة") + } + } + ) + } +} + +@Preview(name = "Map Overlays - Light", showBackground = true) +@Preview(name = "Map Overlays - Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLocationPickerMapOverlays() { + MaterialTheme { + Box( + modifier = Modifier + .size(300.dp) + .background(Color.Gray) + ) { + LocationPickerMapOverlays() + } + } +} + +@Preview(name = "Share Panel - GPS", showBackground = true) +@Preview(name = "Share Panel - Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLocationPickerSharePanelGps() { + MaterialTheme { + LocationPickerSharePanel( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GPS, + readyToShareLocation = true + ), + onShareClick = {} + ) + } +} + +@Preview(name = "Share Panel - Geocoded", showBackground = true) +@Composable +private fun PreviewLocationPickerSharePanelGeocoded() { + MaterialTheme { + LocationPickerSharePanel( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GEOCODED, + placeName = "Brandenburger Tor, Mitte, Berlin", + readyToShareLocation = true + ), + onShareClick = {} + ) + } +} + +@Preview(name = "Share Panel - Sending", showBackground = true) +@Composable +private fun PreviewLocationPickerSharePanelSending() { + MaterialTheme { + LocationPickerSharePanel( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GPS, + readyToShareLocation = true, + viewState = LocationPickerViewModel.ViewState.SendingLocation + ), + onShareClick = {} + ) + } +} + +@Preview(name = "Share Panel - RTL Arabic", showBackground = true, locale = "ar") +@Composable +private fun PreviewLocationPickerSharePanelRtl() { + MaterialTheme { + LocationPickerSharePanel( + uiState = LocationPickerViewModel.UiState( + locationDescriptionType = LocationPickerViewModel.LocationDescriptionType.GEOCODED, + placeName = "برج خليفة، دبي، الإمارات العربية المتحدة", + readyToShareLocation = true + ), + onShareClick = {} + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/viewmodels/LocationPickerViewModel.kt b/app/src/main/java/com/nextcloud/talk/viewmodels/LocationPickerViewModel.kt new file mode 100644 index 00000000000..09c080841a8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/viewmodels/LocationPickerViewModel.kt @@ -0,0 +1,253 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.R +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.location.GeocodingResult +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import fr.dudie.nominatim.client.TalkJsonNominatimClient +import fr.dudie.nominatim.model.Address +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.io.IOException +import javax.inject.Inject + +class LocationPickerViewModel @Inject constructor( + private val ncApi: NcApi, + private val currentUserProviderOld: CurrentUserProviderOld, + private val okHttpClient: OkHttpClient +) : ViewModel() { + + sealed class ViewState { + data object Idle : ViewState() + data object SendingLocation : ViewState() + data object LocationShared : ViewState() + data class Error(val messageRes: Int) : ViewState() + } + + enum class LocationDescriptionType { GPS, GEOCODED, MANUAL } + + data class UiState( + val geocodingResult: GeocodingResult? = null, + val moveToCurrentLocation: Boolean = true, + val readyToShareLocation: Boolean = false, + val locationDescriptionType: LocationDescriptionType = LocationDescriptionType.MANUAL, + val placeName: String = "", + val mapCenterLat: Double = 0.0, + val mapCenterLon: Double = 0.0, + val viewState: ViewState = ViewState.Idle + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private var nominatimClient: TalkJsonNominatimClient? = null + + fun initGeocoder(baseUrl: String, email: String) { + nominatimClient = TalkJsonNominatimClient(baseUrl, okHttpClient, email) + } + + fun initState( + geocodingResult: GeocodingResult?, + moveToCurrentLocation: Boolean, + mapCenterLat: Double, + mapCenterLon: Double + ) { + _uiState.update { + it.copy( + geocodingResult = geocodingResult, + moveToCurrentLocation = moveToCurrentLocation, + mapCenterLat = mapCenterLat, + mapCenterLon = mapCenterLon + ) + } + setLocationDescription(isGpsLocation = false, isGeocodedResult = geocodingResult != null) + } + + fun setMoveToCurrentLocation(value: Boolean) { + _uiState.update { it.copy(moveToCurrentLocation = value) } + } + + fun setReadyToShareLocation(value: Boolean) { + _uiState.update { it.copy(readyToShareLocation = value) } + } + + fun onGeocodingResultReceived(geocodingResult: GeocodingResult) { + _uiState.update { + it.copy( + geocodingResult = geocodingResult, + placeName = geocodingResult.displayName, + locationDescriptionType = LocationDescriptionType.GEOCODED, + moveToCurrentLocation = false, + readyToShareLocation = false + ) + } + } + + fun setGeocodingResultToNull() { + _uiState.update { it.copy(geocodingResult = null) } + } + + fun updateMapCenter(lat: Double, lon: Double) { + _uiState.update { it.copy(mapCenterLat = lat, mapCenterLon = lon) } + } + + fun onMapScrolled() { + val state = _uiState.value + when { + state.moveToCurrentLocation -> { + setLocationDescription(isGpsLocation = true, isGeocodedResult = false) + setMoveToCurrentLocation(false) + } + state.geocodingResult != null -> { + setLocationDescription(isGpsLocation = false, isGeocodedResult = true) + setGeocodingResultToNull() + } + else -> { + setLocationDescription(isGpsLocation = false, isGeocodedResult = false) + } + } + setReadyToShareLocation(true) + } + + fun setLocationDescription(isGpsLocation: Boolean, isGeocodedResult: Boolean) { + when { + isGpsLocation -> _uiState.update { + it.copy( + locationDescriptionType = LocationDescriptionType.GPS, + placeName = "" + ) + } + isGeocodedResult -> _uiState.update { + it.copy( + locationDescriptionType = LocationDescriptionType.GEOCODED, + placeName = _uiState.value.geocodingResult?.displayName ?: "" + ) + } + else -> _uiState.update { + it.copy( + locationDescriptionType = LocationDescriptionType.MANUAL, + placeName = "" + ) + } + } + } + + @Suppress("Detekt.LongParameterList") + fun shareLocation( + selectedLat: Double?, + selectedLon: Double?, + locationName: String?, + sharedLocationFallbackName: String, + roomToken: String, + chatApiVersion: Int + ) { + if (selectedLat == null || selectedLon == null) return + if (locationName.isNullOrEmpty()) { + viewModelScope.launch(Dispatchers.IO) { + var address: Address? = null + try { + address = nominatimClient?.getAddress(selectedLon, selectedLat) + } catch (e: IOException) { + Log.e(TAG, "Failed to get geocoded addresses", e) + } + withContext(Dispatchers.Main) { + executeShareLocation( + selectedLat, + selectedLon, + address?.displayName, + sharedLocationFallbackName, + roomToken, + chatApiVersion + ) + } + } + } else { + executeShareLocation( + selectedLat, + selectedLon, + locationName, + sharedLocationFallbackName, + roomToken, + chatApiVersion + ) + } + } + + @Suppress("Detekt.LongParameterList") + private fun executeShareLocation( + selectedLat: Double, + selectedLon: Double, + locationName: String?, + sharedLocationFallbackName: String, + roomToken: String, + chatApiVersion: Int + ) { + _uiState.update { it.copy(viewState = ViewState.SendingLocation) } + + val objectId = "geo:$selectedLat,$selectedLon" + val locationNameToShare = if (locationName.isNullOrBlank()) sharedLocationFallbackName else locationName + + val metaData = + "{\"type\":\"geo-location\",\"id\":\"geo:$selectedLat,$selectedLon\"," + + "\"latitude\":\"$selectedLat\"," + + "\"longitude\":\"$selectedLon\",\"name\":\"$locationNameToShare\"}" + + val currentUser = currentUserProviderOld.currentUser.blockingGet() + + ncApi.sendLocation( + ApiUtils.getCredentials(currentUser.username, currentUser.token), + ApiUtils.getUrlToSendLocation(chatApiVersion, currentUser.baseUrl!!, roomToken), + "geo-location", + objectId, + metaData + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(t: GenericOverall) { + _uiState.update { it.copy(viewState = ViewState.LocationShared) } + } + + override fun onError(e: Throwable) { + Log.e(TAG, "error when trying to share location", e) + _uiState.update { it.copy(viewState = ViewState.Error(R.string.nc_common_error_sorry)) } + } + + override fun onComplete() { + // unused atm + } + }) + } + + fun consumeViewState() { + _uiState.update { it.copy(viewState = ViewState.Idle) } + } + + companion object { + private val TAG = LocationPickerViewModel::class.java.simpleName + } +} diff --git a/app/src/main/res/drawable/ic_baseline_location_on_red_24.xml b/app/src/main/res/drawable/ic_baseline_location_on_red_24.xml index e681914423b..433eebae411 100644 --- a/app/src/main/res/drawable/ic_baseline_location_on_red_24.xml +++ b/app/src/main/res/drawable/ic_baseline_location_on_red_24.xml @@ -1,15 +1,21 @@ + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#FFDF3F2F" + android:fillType="nonZero" + android:pathData="M12,22C9.317,19.717 7.313,17.596 5.988,15.637C4.662,13.679 4,11.867 4,10.2C4,7.7 4.804,5.708 6.412,4.225C8.021,2.742 9.883,2 12,2C14.117,2 15.979,2.742 17.587,4.225C19.196,5.708 20,7.7 20,10.2C20,11.867 19.337,13.679 18.013,15.637C16.688,17.596 14.683,19.717 12,22Z" + android:strokeWidth="0.5" + android:strokeColor="#000000" /> + diff --git a/app/src/main/res/layout/activity_location.xml b/app/src/main/res/layout/activity_location.xml deleted file mode 100644 index 05e8c2ed8a4..00000000000 --- a/app/src/main/res/layout/activity_location.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_locationpicker.xml b/app/src/main/res/menu/menu_locationpicker.xml deleted file mode 100644 index 9d759c125d2..00000000000 --- a/app/src/main/res/menu/menu_locationpicker.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/values/setup.xml b/app/src/main/res/values/setup.xml index f96f27466a2..7458a311256 100644 --- a/app/src/main/res/values/setup.xml +++ b/app/src/main/res/values/setup.xml @@ -55,6 +55,7 @@ https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png OpenStreetMap contributors + © OpenStreetMap contributors https://nominatim.openstreetmap.org/ android@nextcloud.com diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b67ed6e87ca..9b14cd93e4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -617,12 +617,15 @@ How to translate with transifex: Share location + Search location location permission is required Share current location Share this location Shared location Your current location Position unknown + Zoom in + Zoom out Share contact