diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b74993a44a..2c89ee181c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,7 +136,7 @@ dependencies { implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") // Jetpack Compose - val composeBom = platform("androidx.compose:compose-bom:2024.09.02") + val composeBom = platform("androidx.compose:compose-bom:2024.10.00") implementation(composeBom) androidTestImplementation(composeBom) implementation("androidx.compose.material:material") @@ -145,7 +145,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling") - implementation("androidx.navigation:navigation-compose:2.8.1") + implementation("androidx.navigation:navigation-compose:2.8.3") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08d8b19349..d6db44aa96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,8 +44,9 @@ android:supportsRtl="true"> @@ -77,7 +78,7 @@ diff --git a/app/src/main/java/de/westnordost/streetcomplete/ApplicationConstants.kt b/app/src/main/java/de/westnordost/streetcomplete/ApplicationConstants.kt index e95908c516..ed1570af6a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ApplicationConstants.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ApplicationConstants.kt @@ -57,6 +57,9 @@ object ApplicationConstants { const val NOTIFICATIONS_CHANNEL_SYNC = "downloading" const val NOTIFICATIONS_ID_SYNC = 1 + // where to send the error reports to + const val ERROR_REPORTS_EMAIL = "streetcomplete_errors@westnordost.de" + const val STREETMEASURE = "de.westnordost.streetmeasure" val IGNORED_RELATION_TYPES = setOf( diff --git a/app/src/main/java/de/westnordost/streetcomplete/ApplicationModule.kt b/app/src/main/java/de/westnordost/streetcomplete/ApplicationModule.kt index e4a5cd8ee6..79528ef828 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ApplicationModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ApplicationModule.kt @@ -15,7 +15,7 @@ val appModule = module { factory { androidContext().assets } factory { androidContext().resources } - single { CrashReportExceptionHandler(androidContext(), get(), "streetcomplete_errors@westnordost.de", "crashreport.txt") } + single { CrashReportExceptionHandler(androidContext(), get(), "crashreport.txt") } single { DatabaseLogger(get()) } single { SoundFx(androidContext()) } single { HttpClient { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt index 06fadb1968..3b260a92a9 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt @@ -15,7 +15,7 @@ val downloadModule = module { factory { MobileDataAutoDownloadStrategy(get(), get()) } factory { WifiAutoDownloadStrategy(get(), get()) } - single { Downloader(get(), get(), get(), get(), get(named("SerializeSync"))) } + single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync"))) } single { get() } single { DownloadController(get()) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt index f7f6a8f1a5..98a33ed096 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.data.download import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.data.AuthorizationException import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController import de.westnordost.streetcomplete.data.download.tiles.TilesRect import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect @@ -8,6 +9,7 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader import de.westnordost.streetcomplete.data.osmnotes.NotesDownloader +import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.format import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds @@ -26,6 +28,7 @@ class Downloader( private val mapDataDownloader: MapDataDownloader, private val mapTilesDownloader: MapTilesDownloader, private val downloadedTilesController: DownloadedTilesController, + private val userLoginController: UserLoginController, private val mutex: Mutex ) : DownloadProgressSource { @@ -80,6 +83,9 @@ class Downloader( } catch (e: Exception) { hasError = true Log.e(TAG, "Unable to download", e) + if (e is AuthorizationException) { + userLoginController.logOut() + } listeners.forEach { it.onError(e) } throw e } finally { diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/Edit.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/Edit.kt index 914bab6b5f..1bb8239fec 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/Edit.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/Edit.kt @@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.edithistory import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey import de.westnordost.streetcomplete.data.quest.OsmQuestKey +import kotlinx.serialization.Serializable interface Edit { val key: EditKey @@ -12,9 +13,14 @@ interface Edit { val isSynced: Boolean? } +@Serializable sealed class EditKey +@Serializable data class ElementEditKey(val id: Long) : EditKey() +@Serializable data class NoteEditKey(val id: Long) : EditKey() +@Serializable data class OsmQuestHiddenKey(val osmQuestKey: OsmQuestKey) : EditKey() +@Serializable data class OsmNoteQuestHiddenKey(val osmNoteQuestKey: OsmNoteQuestKey) : EditKey() diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/messages/MessagesSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/messages/MessagesSource.kt index 9e9bc7218c..a8c6cb9eb8 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/messages/MessagesSource.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/messages/MessagesSource.kt @@ -25,7 +25,7 @@ class MessagesSource( private val changelog: Changelog, ) { /* Must be a singleton because there is a listener that should respond to a change in the - * database table*/ + * database table */ interface UpdateListener { fun onNumberOfMessagesUpdated(messageCount: Int) diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt b/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt index e43997370c..ecf106256c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/sync/SyncNotification.kt @@ -12,7 +12,7 @@ import androidx.core.app.PendingIntentCompat import de.westnordost.streetcomplete.ApplicationConstants.NAME import de.westnordost.streetcomplete.ApplicationConstants.NOTIFICATIONS_CHANNEL_SYNC import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.screens.MainActivity +import de.westnordost.streetcomplete.screens.main.MainActivity /** Creates the notification for syncing in the Android notifications area. Used both by the upload * and by the download service. */ diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt index b743331b83..e882c1af91 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/UploadModule.kt @@ -10,7 +10,7 @@ import org.koin.dsl.module val uploadModule = module { factory { VersionIsBannedChecker(get(), "https://www.westnordost.de/streetcomplete/banned_versions.txt", ApplicationConstants.USER_AGENT) } - single { Uploader(get(), get(), get(), get(), get(), get(named("SerializeSync"))) } + single { Uploader(get(), get(), get(), get(), get(), get(), get(named("SerializeSync"))) } /* uploading and downloading should be serialized, i.e. may not run in parallel, to avoid * certain race-condition. * diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt index 8bdac3d396..f8020bcdd7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/data/upload/Uploader.kt @@ -7,6 +7,7 @@ import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos import de.westnordost.streetcomplete.data.osm.edits.upload.ElementEditsUploader import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsUploader +import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.data.user.UserLoginSource import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.logs.Log @@ -20,6 +21,7 @@ class Uploader( private val downloadedTilesController: DownloadedTilesController, private val userLoginSource: UserLoginSource, private val versionIsBannedChecker: VersionIsBannedChecker, + private val userLoginController: UserLoginController, private val mutex: Mutex ) : UploadProgressSource { @@ -77,6 +79,9 @@ class Uploader( Log.i(TAG, "Upload cancelled") } catch (e: Exception) { Log.e(TAG, "Unable to upload", e) + if (e is AuthorizationException) { + userLoginController.logOut() + } listeners.forEach { it.onError(e) } throw e } finally { diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt deleted file mode 100644 index a92f1c373d..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt +++ /dev/null @@ -1,350 +0,0 @@ -package de.westnordost.streetcomplete.screens - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Configuration -import android.os.Bundle -import android.text.Html -import android.text.method.LinkMovementMethod -import android.text.util.Linkify -import android.view.View -import android.view.WindowManager -import android.widget.TextView -import android.widget.Toast -import androidx.annotation.AnyThread -import androidx.appcompat.app.AlertDialog -import androidx.core.text.parseAsHtml -import androidx.fragment.app.commit -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.AuthorizationException -import de.westnordost.streetcomplete.data.ConnectionException -import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource -import de.westnordost.streetcomplete.data.download.DownloadProgressSource -import de.westnordost.streetcomplete.data.messages.Message -import de.westnordost.streetcomplete.data.osm.edits.ElementEdit -import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource -import de.westnordost.streetcomplete.data.osm.mapdata.LatLon -import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit -import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsSource -import de.westnordost.streetcomplete.data.preferences.Preferences -import de.westnordost.streetcomplete.data.quest.QuestAutoSyncer -import de.westnordost.streetcomplete.data.upload.UploadProgressSource -import de.westnordost.streetcomplete.data.upload.VersionBannedException -import de.westnordost.streetcomplete.data.urlconfig.UrlConfigController -import de.westnordost.streetcomplete.data.user.UserLoginController -import de.westnordost.streetcomplete.data.visiblequests.QuestPresetsSource -import de.westnordost.streetcomplete.screens.main.MainFragment -import de.westnordost.streetcomplete.screens.main.messages.MessagesContainerFragment -import de.westnordost.streetcomplete.screens.tutorial.OverlaysTutorialFragment -import de.westnordost.streetcomplete.screens.tutorial.TutorialFragment -import de.westnordost.streetcomplete.util.CrashReportExceptionHandler -import de.westnordost.streetcomplete.util.ktx.isLocationAvailable -import de.westnordost.streetcomplete.util.ktx.toast -import de.westnordost.streetcomplete.util.location.LocationAvailabilityReceiver -import de.westnordost.streetcomplete.util.location.LocationRequestFragment -import de.westnordost.streetcomplete.util.parseGeoUri -import de.westnordost.streetcomplete.view.dialogs.RequestLoginDialog -import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject - -class MainActivity : - BaseActivity(), - MainFragment.Listener, - TutorialFragment.Listener, - OverlaysTutorialFragment.Listener { - - private val crashReportExceptionHandler: CrashReportExceptionHandler by inject() - private val questAutoSyncer: QuestAutoSyncer by inject() - private val downloadProgressSource: DownloadProgressSource by inject() - private val uploadProgressSource: UploadProgressSource by inject() - private val locationAvailabilityReceiver: LocationAvailabilityReceiver by inject() - private val elementEditsSource: ElementEditsSource by inject() - private val noteEditsSource: NoteEditsSource by inject() - private val unsyncedChangesCountSource: UnsyncedChangesCountSource by inject() - private val userLoginController: UserLoginController by inject() - private val urlConfigController: UrlConfigController by inject() - private val questPresetsSource: QuestPresetsSource by inject() - private val prefs: Preferences by inject() - - private var mainFragment: MainFragment? = null - - private val requestLocationPermissionResultReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (!intent.getBooleanExtra(LocationRequestFragment.GRANTED, false)) { - toast(R.string.no_gps_no_quests, Toast.LENGTH_LONG) - } - } - } - - private val elementEditsListener = object : ElementEditsSource.Listener { - override fun onAddedEdit(edit: ElementEdit) { lifecycleScope.launch { ensureLoggedIn() } } - override fun onSyncedEdit(edit: ElementEdit) {} - override fun onDeletedEdits(edits: List) {} - } - - private val noteEditsListener = object : NoteEditsSource.Listener { - override fun onAddedEdit(edit: NoteEdit) { lifecycleScope.launch { ensureLoggedIn() } } - override fun onSyncedEdit(edit: NoteEdit) {} - override fun onDeletedEdits(edits: List) {} - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - LocalBroadcastManager.getInstance(this).registerReceiver( - requestLocationPermissionResultReceiver, - IntentFilter(LocationRequestFragment.REQUEST_LOCATION_PERMISSION_RESULT) - ) - - lifecycle.addObserver(questAutoSyncer) - crashReportExceptionHandler.askUserToSendCrashReportIfExists(this) - - window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) - window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) - - setContentView(R.layout.activity_main) - - mainFragment = supportFragmentManager.findFragmentById(R.id.map_fragment) as MainFragment? - if (savedInstanceState == null) { - supportFragmentManager.commit { add(LocationRequestFragment(), TAG_LOCATION_REQUEST) } - if (!prefs.hasShownTutorial && !userLoginController.isLoggedIn) { - supportFragmentManager.commit { - setCustomAnimations(R.anim.fade_in_from_bottom, R.anim.fade_out_to_bottom) - add(R.id.fragment_container, TutorialFragment()) - } - } - } - - elementEditsSource.addListener(elementEditsListener) - noteEditsSource.addListener(noteEditsListener) - - handleUrlConfig() - } - - private fun handleUrlConfig() { - if (intent.action != Intent.ACTION_VIEW) return - val data = intent.data ?: return - val config = urlConfigController.parse(data.toString()) ?: return - - val alreadyExists = config.presetName == null || questPresetsSource.getByName(config.presetName) != null - val name = config.presetName ?: getString(R.string.quest_presets_default_name) - - val htmlName = "" + Html.escapeHtml(name) + "" - val text = StringBuilder() - text.append(getString(R.string.urlconfig_apply_message, htmlName)) - text.append("

") - if (alreadyExists) { - text.append("" + getString(R.string.urlconfig_apply_message_overwrite) + "") - text.append("

") - } else { - text.append(getString(R.string.urlconfig_switch_hint)) - } - - AlertDialog.Builder(this) - .setTitle(R.string.urlconfig_apply_title) - .setMessage(text.toString().parseAsHtml()) - .setPositiveButton(android.R.string.ok) { _, _ -> urlConfigController.apply(config) } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - - private fun handleGeoUri() { - if (intent.action != Intent.ACTION_VIEW) return - val data = intent.data ?: return - if ("geo" != data.scheme) return - val geo = parseGeoUri(data) ?: return - val zoom = if (geo.zoom == null || geo.zoom < 14) 18.0 else geo.zoom - val pos = LatLon(geo.latitude, geo.longitude) - mainFragment?.setCameraPosition(pos, zoom) - } - - public override fun onStart() { - super.onStart() - - updateScreenOn() - uploadProgressSource.addListener(uploadProgressListener) - downloadProgressSource.addListener(downloadProgressListener) - - locationAvailabilityReceiver.addListener(::updateLocationAvailability) - updateLocationAvailability(isLocationAvailable) - } - - public override fun onStop() { - super.onStop() - uploadProgressSource.removeListener(uploadProgressListener) - downloadProgressSource.removeListener(downloadProgressListener) - locationAvailabilityReceiver.removeListener(::updateLocationAvailability) - } - - override fun onDestroy() { - super.onDestroy() - elementEditsSource.removeListener(elementEditsListener) - noteEditsSource.removeListener(noteEditsListener) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - findViewById(R.id.main).requestLayout() - // recreate the MessagesContainerFragment because it should load a new layout, see #2330 - supportFragmentManager.commit { - replace(R.id.messages_container_fragment, MessagesContainerFragment()) - } - } - - private suspend fun ensureLoggedIn() { - if (!questAutoSyncer.isAllowedByPreference) return - if (userLoginController.isLoggedIn) return - - // new users should not be immediately pestered to login after each change (#1446) - if (unsyncedChangesCountSource.getCount() < 3 || dontShowRequestAuthorizationAgain) return - - RequestLoginDialog(this).show() - dontShowRequestAuthorizationAgain = true - } - - /* ------------------------------- Preferences listeners ------------------------------------ */ - - private fun updateScreenOn() { - if (prefs.keepScreenOn) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - /* ------------------------------ Upload progress listener ---------------------------------- */ - - private val uploadProgressListener = object : UploadProgressSource.Listener { - @AnyThread - override fun onError(e: Exception) { - runOnUiThread { - if (e is VersionBannedException) { - var message = getString(R.string.version_banned_message) - if (e.banReason != null) { - message += "\n\n\n${e.banReason}" - } - val dialog = AlertDialog.Builder(this@MainActivity) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.show() - - // Makes links in the alert dialog clickable - val messageView = dialog.findViewById(android.R.id.message) - if (messageView is TextView) { - messageView.movementMethod = LinkMovementMethod.getInstance() - Linkify.addLinks(messageView, Linkify.WEB_URLS) - } - } else if (e is ConnectionException) { - /* A network connection error or server error is not the fault of this app. - Nothing we can do about it, so it does not make sense to send an error - report. Just notify the user. */ - toast(R.string.upload_server_error, Toast.LENGTH_LONG) - } else if (e is AuthorizationException) { - // delete secret in case it failed while already having a token -> token is invalid - userLoginController.logOut() - RequestLoginDialog(this@MainActivity).show() - } else { - crashReportExceptionHandler.askUserToSendErrorReport(this@MainActivity, - R.string.upload_error, e) - } - } - } - } - - /* ----------------------------- Download Progress listener -------------------------------- */ - - private val downloadProgressListener = object : DownloadProgressSource.Listener { - @AnyThread - override fun onError(e: Exception) { - runOnUiThread { - // A network connection error is not the fault of this app. Nothing we can do about - // it, so it does not make sense to send an error report. Just notify the user. - if (e is ConnectionException) { - toast(R.string.download_server_error, Toast.LENGTH_LONG) - } else if (e is AuthorizationException) { - // delete secret in case it failed while already having a token -> token is invalid - userLoginController.logOut() - } else { - crashReportExceptionHandler.askUserToSendErrorReport(this@MainActivity, - R.string.download_error, e) - } - } - } - } - - /* --------------------------------- MessagesButtonFragment.Listener ------------------------ */ - - override fun onClickShowMessage(message: Message) { - messagesContainerFragment?.showMessage(message) - } - - private val messagesContainerFragment get() = - supportFragmentManager.findFragmentById(R.id.messages_container_fragment) as? MessagesContainerFragment - - /* --------------------------------- MainFragment.Listener ---------------------------------- */ - - override fun onMapInitialized() { - handleGeoUri() - } - - /* ------------------------------- TutorialFragment.Listener -------------------------------- */ - - override fun onTutorialFinished() { - requestLocation() - - prefs.hasShownTutorial = true - removeTutorialFragment() - } - - private fun requestLocation() { - (supportFragmentManager.findFragmentByTag(TAG_LOCATION_REQUEST) as? LocationRequestFragment)?.startRequest() - } - - private fun removeTutorialFragment() { - val tutorialFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) - if (tutorialFragment != null) { - supportFragmentManager.commit { - setCustomAnimations(R.anim.fade_in_from_bottom, R.anim.fade_out_to_bottom) - remove(tutorialFragment) - } - } - } - - /* ---------------------------- OverlaysButtonFragment.Listener ----------------------------- */ - - override fun onShowOverlaysTutorial() { - supportFragmentManager.commit { - setCustomAnimations(R.anim.fade_in_from_bottom, R.anim.fade_out_to_bottom) - add(R.id.fragment_container, OverlaysTutorialFragment()) - } - } - - /* --------------------------- OverlaysTutorialFragment.Listener ---------------------------- */ - - override fun onOverlaysTutorialFinished() { - prefs.hasShownOverlaysTutorial = true - removeTutorialFragment() - } - - /* ------------------------------------ Location listener ----------------------------------- */ - - private fun updateLocationAvailability(isAvailable: Boolean) { - if (isAvailable) { - questAutoSyncer.startPositionTracking() - } else { - questAutoSyncer.stopPositionTracking() - } - } - - companion object { - private const val TAG_LOCATION_REQUEST = "LocationRequestFragment" - - // per application start settings - private var dontShowRequestAuthorizationAgain = false - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/IntersectionUtil.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/IntersectionUtil.kt deleted file mode 100644 index f847d12cb6..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/IntersectionUtil.kt +++ /dev/null @@ -1,129 +0,0 @@ -package de.westnordost.streetcomplete.screens.main - -import android.graphics.PointF -import android.view.View -import android.view.ViewGroup -import androidx.core.view.children -import androidx.core.view.isVisible - -/** Given an imaginary line drawn from the center of the given [viewGroup] and the [target], returns - * the point where the line either intersects with the bounds of the [viewGroup] or the bounds of - * any of its direct children, whichever is closer to the center. It returns null if there is no - * intersection (i.e. the [target] is within the bounds of [viewGroup] and not within the bounds - * of any of its direct children) */ -fun findClosestIntersection(viewGroup: ViewGroup, target: PointF): PointF? { - val w = viewGroup.width.toFloat() - val h = viewGroup.height.toFloat() - val ox = w / 2 - val oy = h / 2 - val tx = target.x - val ty = target.y - - var minA = Float.MAX_VALUE - var a: Float - - // left side - a = intersectionWithVerticalSegment(ox, oy, tx, ty, 0f, 0f, h) - if (a < minA) minA = a - // right side - a = intersectionWithVerticalSegment(ox, oy, tx, ty, w, 0f, h) - if (a < minA) minA = a - // top side - a = intersectionWithHorizontalSegment(ox, oy, tx, ty, 0f, 0f, w) - if (a < minA) minA = a - // bottom side - a = intersectionWithHorizontalSegment(ox, oy, tx, ty, 0f, h, w) - if (a < minA) minA = a - - for (child in viewGroup.children) { - if (!isReallyVisible(child)) continue - val t = child.top.toFloat() - val b = child.bottom.toFloat() - val r = child.right.toFloat() - val l = child.left.toFloat() - // left side - if (l > ox && l < w) { - a = intersectionWithVerticalSegment(ox, oy, tx, ty, l, t, b - t) - if (a < minA) minA = a - } - // right side - if (r > 0 && r < ox) { - a = intersectionWithVerticalSegment(ox, oy, tx, ty, r, t, b - t) - if (a < minA) minA = a - } - // top side - if (t > oy && t < h) { - a = intersectionWithHorizontalSegment(ox, oy, tx, ty, l, t, r - l) - if (a < minA) minA = a - } - // bottom side - if (b > 0 && b < oy) { - a = intersectionWithHorizontalSegment(ox, oy, tx, ty, l, b, r - l) - if (a < minA) minA = a - } - } - - return if (minA <= 1f) PointF(ox + (tx - ox) * minA, oy + (ty - oy) * minA) else null -} - -// A visible ViewGroup with no visible children is (probably) not actually visible -// This assumption isn't 100% necessarily correct, since the ViewGroup *could* itself be opaque; -// if there's a bug with the pointer pin not showing when it should, check here first. -private fun isReallyVisible(view: View): Boolean = - view.isVisible && when (view) { - is ViewGroup -> view.children.any(::isReallyVisible) - else -> true - } - -/** Intersection of line segment going from P to Q with vertical line starting at V and given - * length. Returns the f for P+f*(Q-P) or MAX_VALUE if no intersection found. */ -private fun intersectionWithVerticalSegment( - px: Float, - py: Float, - qx: Float, - qy: Float, - vx: Float, - vy: Float, - length: Float -): Float { - val dx = qx - px - if (dx == 0f) return Float.MAX_VALUE - val a = (vx - px) / dx - - // not in range of line segment A - if (a < 0f || a > 1f) return Float.MAX_VALUE - - val dy = qy - py - val posY = py + dy * a - - // not in range of horizontal line segment - if (posY < vy || posY > vy + length) return Float.MAX_VALUE - - return a -} - -/** Intersection of line segment going from P to Q with horizontal line starting at H and given - * length. Returns the f for P+f*(Q-P) or MAX_VALUE if no intersection found. */ -private fun intersectionWithHorizontalSegment( - px: Float, - py: Float, - qx: Float, - qy: Float, - hx: Float, - hy: Float, - length: Float -): Float { - val dy = qy - py - if (dy == 0f) return Float.MAX_VALUE - val a = (hy - py) / dy - - // not in range of line segment P-Q - if (a < 0f || a > 1f) return Float.MAX_VALUE - - val dx = qx - px - val posX = px + dx * a - - // not in range of horizontal line segment - if (posX < hx || posX > hx + length) return Float.MAX_VALUE - return a -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt similarity index 63% rename from app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt rename to app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt index 582d12e67f..2ebb0e276a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -1,42 +1,44 @@ package de.westnordost.streetcomplete.screens.main import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Color import android.graphics.PointF import android.location.Location import android.os.Bundle import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.view.animation.AccelerateInterpolator import android.view.animation.OvershootInterpolator import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge import androidx.annotation.AnyThread import androidx.annotation.DrawableRes import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.ListPopupWindow import androidx.appcompat.widget.PopupMenu +import androidx.compose.ui.geometry.Offset import androidx.core.graphics.Insets -import androidx.core.graphics.minus -import androidx.core.graphics.toPointF +import androidx.core.net.toUri import androidx.core.os.bundleOf -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.download.tiles.asBoundingBoxOfEnclosingTiles import de.westnordost.streetcomplete.data.edithistory.EditKey -import de.westnordost.streetcomplete.data.edithistory.icon -import de.westnordost.streetcomplete.data.messages.Message import de.westnordost.streetcomplete.data.osm.edits.ElementEditType import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry @@ -53,13 +55,15 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NotesWithEditsSource import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestsHiddenSource import de.westnordost.streetcomplete.data.osmtracks.Trackpoint +import de.westnordost.streetcomplete.data.preferences.Preferences import de.westnordost.streetcomplete.data.quest.OsmQuestKey import de.westnordost.streetcomplete.data.quest.Quest +import de.westnordost.streetcomplete.data.quest.QuestAutoSyncer import de.westnordost.streetcomplete.data.quest.QuestKey import de.westnordost.streetcomplete.data.quest.QuestType import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource +import de.westnordost.streetcomplete.databinding.ActivityMainBinding import de.westnordost.streetcomplete.databinding.EffectQuestPlopBinding -import de.westnordost.streetcomplete.databinding.FragmentMainBinding import de.westnordost.streetcomplete.osm.level.levelsIntersect import de.westnordost.streetcomplete.osm.level.parseLevelsOrNull import de.westnordost.streetcomplete.overlays.AbstractOverlayForm @@ -70,6 +74,7 @@ import de.westnordost.streetcomplete.quests.AbstractQuestForm import de.westnordost.streetcomplete.quests.IsShowingQuestDetails import de.westnordost.streetcomplete.quests.LeaveNoteInsteadFragment import de.westnordost.streetcomplete.quests.note_discussion.NoteDiscussionForm +import de.westnordost.streetcomplete.screens.BaseActivity import de.westnordost.streetcomplete.screens.main.bottom_sheet.CreateNoteFragment import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsCloseableBottomSheet import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapOrientationAware @@ -77,9 +82,8 @@ import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapPositionAwar import de.westnordost.streetcomplete.screens.main.bottom_sheet.MoveNodeFragment import de.westnordost.streetcomplete.screens.main.bottom_sheet.SplitWayFragment import de.westnordost.streetcomplete.screens.main.controls.LocationState -import de.westnordost.streetcomplete.screens.main.controls.MainMenuDialog -import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryFragment import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryViewModel +import de.westnordost.streetcomplete.screens.main.edithistory.icon import de.westnordost.streetcomplete.screens.main.map.MainMapFragment import de.westnordost.streetcomplete.screens.main.map.MapFragment import de.westnordost.streetcomplete.screens.main.map.Marker @@ -88,34 +92,24 @@ import de.westnordost.streetcomplete.screens.main.map.getIcon import de.westnordost.streetcomplete.screens.main.map.getTitle import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.screens.main.map.maplibre.toPadding -import de.westnordost.streetcomplete.screens.main.overlays.OverlaySelectionAdapter +import de.westnordost.streetcomplete.ui.util.content import de.westnordost.streetcomplete.util.SoundFx import de.westnordost.streetcomplete.util.buildGeoUri -import de.westnordost.streetcomplete.util.ktx.childFragmentManagerOrNull import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.getLocationInWindow import de.westnordost.streetcomplete.util.ktx.hasLocationPermission import de.westnordost.streetcomplete.util.ktx.hideKeyboard import de.westnordost.streetcomplete.util.ktx.isLocationAvailable import de.westnordost.streetcomplete.util.ktx.observe -import de.westnordost.streetcomplete.util.ktx.popIn -import de.westnordost.streetcomplete.util.ktx.popOut -import de.westnordost.streetcomplete.util.ktx.setMargins -import de.westnordost.streetcomplete.util.ktx.setPadding import de.westnordost.streetcomplete.util.ktx.toLatLon import de.westnordost.streetcomplete.util.ktx.toast import de.westnordost.streetcomplete.util.ktx.truncateTo5Decimals -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.location.FineLocationManager import de.westnordost.streetcomplete.util.location.LocationAvailabilityReceiver import de.westnordost.streetcomplete.util.location.LocationRequestFragment import de.westnordost.streetcomplete.util.math.area import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import de.westnordost.streetcomplete.util.math.enlargedBy -import de.westnordost.streetcomplete.util.math.initialBearingTo -import de.westnordost.streetcomplete.util.viewBinding -import de.westnordost.streetcomplete.view.dialogs.RequestLoginDialog -import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -123,13 +117,10 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.qualifier.named import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.sin import kotlin.math.sqrt import kotlin.random.Random -/** This fragment controls the main view. +/** Controls the main view. * * The logical sub components of this main view are all outsourced into individual child fragments * with which this fragment communicates with. @@ -147,8 +138,8 @@ import kotlin.random.Random * [-] icon next to it. * */ -class MainFragment : - Fragment(R.layout.fragment_main), +class MainActivity : + BaseActivity(), // listeners to child fragments: MapFragment.Listener, MainMapFragment.Listener, @@ -163,163 +154,93 @@ class MainFragment : VisibleQuestsSource.Listener, MapDataWithEditsSource.Listener, // rest - ShowsGeometryMarkers { - + ShowsGeometryMarkers +{ + private val questAutoSyncer: QuestAutoSyncer by inject() + private val locationAvailabilityReceiver: LocationAvailabilityReceiver by inject() + private val prefs: Preferences by inject() private val visibleQuestsSource: VisibleQuestsSource by inject() private val mapDataWithEditsSource: MapDataWithEditsSource by inject() private val notesSource: NotesWithEditsSource by inject() private val noteQuestsHiddenSource: OsmNoteQuestsHiddenSource by inject() - private val locationAvailabilityReceiver: LocationAvailabilityReceiver by inject() private val featureDictionary: Lazy by inject(named("FeatureDictionaryLazy")) private val soundFx: SoundFx by inject() private lateinit var locationManager: FineLocationManager - private val controlsViewModel by viewModel() + private val viewModel by viewModel() private val editHistoryViewModel by viewModel() - private val binding by viewBinding(FragmentMainBinding::bind) + private lateinit var binding: ActivityMainBinding private var wasFollowingPosition: Boolean? = null private var wasNavigationMode: Boolean? = null - private var selectedOverlay: Overlay? = null - private var windowInsets: Insets? = null - - private var mapFragment: MainMapFragment? = null + private val mapFragment: MainMapFragment? get() = + supportFragmentManager.findFragmentById(R.id.mapFragment) as MainMapFragment? private val bottomSheetFragment: Fragment? get() = - childFragmentManagerOrNull?.findFragmentByTag(BOTTOM_SHEET) - - private val editHistoryFragment: EditHistoryFragment? get() = - childFragmentManagerOrNull?.findFragmentByTag(EDIT_HISTORY) as? EditHistoryFragment - - interface Listener { - fun onMapInitialized() - fun onClickShowMessage(message: Message) - fun onShowOverlaysTutorial() - } - private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener + supportFragmentManager.findFragmentByTag(BOTTOM_SHEET) /* +++++++++++++++++++++++++++++++++++++++ CALLBACKS ++++++++++++++++++++++++++++++++++++++++ */ - private val historyBackPressedCallback = object : OnBackPressedCallback(false) { + private val sheetBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - closeEditHistorySidebar() + (bottomSheetFragment as IsCloseableBottomSheet).onClickClose { closeBottomSheet() } } } - private val sheetBackPressedCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - (bottomSheetFragment as IsCloseableBottomSheet).onClickClose { closeBottomSheet() } + private val requestLocationPermissionResultReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!intent.getBooleanExtra(LocationRequestFragment.GRANTED, false)) { + toast(R.string.no_gps_no_quests, Toast.LENGTH_LONG) + } } } - //region Lifecycle - Android Lifecycle Callbacks - override fun onAttach(context: Context) { - super.onAttach(context) + //region Lifecycle - Android Lifecycle Callbacks - locationManager = FineLocationManager(context, this::onLocationChanged) + override fun onCreate(savedInstanceState: Bundle?) { + val systemBarStyle = SystemBarStyle.dark(Color.argb(0x80, 0x1b, 0x1b, 0x1b)) + enableEdgeToEdge(systemBarStyle, systemBarStyle) + super.onCreate(savedInstanceState) - childFragmentManager.addFragmentOnAttachListener { _, fragment -> - when (fragment) { - is MainMapFragment -> mapFragment = fragment - } + LocalBroadcastManager.getInstance(this).registerReceiver( + requestLocationPermissionResultReceiver, + IntentFilter(LocationRequestFragment.REQUEST_LOCATION_PERMISSION_RESULT) + ) + supportFragmentManager.commit { add(LocationRequestFragment(), TAG_LOCATION_REQUEST) } + + lifecycle.addObserver(questAutoSyncer) + + locationManager = FineLocationManager(this, this::onLocationChanged) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.controls.content { + MainScreen( + viewModel = viewModel, + editHistoryViewModel = editHistoryViewModel, + onClickZoomIn = ::onClickZoomIn, + onClickZoomOut = ::onClickZoomOut, + onClickCompass = ::onClickCompassButton, + onClickLocation = ::onClickLocationButton, + onClickLocationPointer = ::onClickLocationPointer, + onClickCreate = ::onClickCreateButton, + onClickStopTrackRecording = ::onClickTracksStop, + onClickDownload = ::onClickDownload, + onExplainedNeedForLocationPermission = ::requestLocation + ) } - childFragmentManager.commit { add(LocationRequestFragment(), TAG_LOCATION_REQUEST) } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.mapControls.respectSystemInsets(View::setMargins) - view.respectSystemInsets { windowInsets = it } - - binding.locationPointerPin.setOnClickListener { onClickLocationPointer() } - - binding.compassView.setOnClickListener { onClickCompassButton() } - binding.gpsTrackingButton.setOnClickListener { onClickTrackingButton() } - binding.stopTracksButton.setOnClickListener { onClickTracksStop() } - binding.zoomInButton.setOnClickListener { onClickZoomIn() } - binding.zoomOutButton.setOnClickListener { onClickZoomOut() } - binding.createButton.setOnClickListener { onClickCreateButton() } - binding.uploadButton.setOnClickListener { onClickUploadButton() } - binding.undoButton.setOnClickListener { onClickUndoButton() } - binding.messagesButton.setOnClickListener { onClickMessagesButton() } - binding.starsCounterView.setOnClickListener { onClickAnswersCounterView() } - binding.overlaysButton.setOnClickListener { onClickOverlaysButton() } - binding.mainMenuButton.setOnClickListener { onClickMainMenu() } - - requireActivity().onBackPressedDispatcher - .addCallback(viewLifecycleOwner, historyBackPressedCallback) - historyBackPressedCallback.isEnabled = editHistoryFragment != null - requireActivity().onBackPressedDispatcher - .addCallback(viewLifecycleOwner, sheetBackPressedCallback) + + onBackPressedDispatcher.addCallback(this, sheetBackPressedCallback) sheetBackPressedCallback.isEnabled = bottomSheetFragment is IsCloseableBottomSheet - observe(controlsViewModel.isAutoSync) { isAutoSync -> - binding.uploadButton.isGone = isAutoSync - } - observe(controlsViewModel.unsyncedEditsCount) { count -> - binding.uploadButton.uploadableCount = count - } - observe(controlsViewModel.isUploading) { isUploadInProgress -> - binding.uploadButton.isEnabled = !isUploadInProgress - // Don't allow undoing while uploading. Should prevent race conditions. - // (Undoing quest while also uploading it at the same time) - binding.undoButton.isEnabled = !isUploadInProgress - } - observe(controlsViewModel.messagesCount) { messagesCount -> - binding.messagesButton.messagesCount = messagesCount - binding.messagesButton.isGone = messagesCount <= 0 - } - observe(controlsViewModel.isUploadingOrDownloading) { isUploadingOrDownloading -> - binding.starsCounterView.showProgress = isUploadingOrDownloading - } - observe(controlsViewModel.isShowingStarsCurrentWeek) { isShowingCurrentWeek -> - binding.starsCounterView.showLabel = isShowingCurrentWeek - } - observe(controlsViewModel.starsCount) { count -> - // only animate if count is positive, for positive feedback - binding.starsCounterView.setUploadedCount(count, count > 0) - } - observe(controlsViewModel.selectedOverlay) { overlay -> - val iconRes = overlay?.icon ?: R.drawable.ic_overlay_black_24dp - binding.overlaysButton.setImageResource(iconRes) - if (selectedOverlay != overlay) { - val f = bottomSheetFragment - if (f is IsShowingElement) { - closeBottomSheet() - } - } - selectedOverlay = overlay - } - observe(controlsViewModel.isTeamMode) { isTeamMode -> - if (isTeamMode) { - // always show this toast on start to remind user that it is still on - context?.toast(R.string.team_mode_active) - binding.teamModeColorCircle.popIn() - binding.teamModeColorCircle.setIndexInTeam(controlsViewModel.indexInTeam) - } else { - // show this only once when turning it off - if (controlsViewModel.teamModeChanged) context?.toast(R.string.team_mode_deactivated) - binding.teamModeColorCircle.popOut() - } - controlsViewModel.teamModeChanged = false - } - observe(controlsViewModel.selectedOverlay) { overlay -> - val isCreateNodeEnabled = overlay?.isCreateNodeEnabled == true - binding.createButton.isGone = !isCreateNodeEnabled - binding.crosshairView.isGone = !isCreateNodeEnabled - } - observe(editHistoryViewModel.editItems) { editItems -> - if (editItems.isEmpty()) closeEditHistorySidebar() - binding.undoButton.isGone = editItems.isEmpty() - } observe(editHistoryViewModel.selectedEdit) { edit -> if (edit == null) { - mapFragment?.endFocus() + mapFragment?.clearFocus() mapFragment?.clearHighlighting() } else { val geometry = editHistoryViewModel.getEditGeometry(edit) @@ -329,70 +250,93 @@ class MainFragment : mapFragment?.hideOverlay() } } - } - - @UiThread - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - - binding.crosshairView.setPadding(getQuestFormInsets()) - - updateLocationPointerPin() + observe(editHistoryViewModel.isShowingSidebar) { isShowingSidebar -> + if (!isShowingSidebar) { + freezeMap() + mapFragment?.pinMode = MainMapFragment.PinMode.QUESTS + } else { + unfreezeMap() + mapFragment?.hideOverlay() + mapFragment?.pinMode = MainMapFragment.PinMode.EDITS + } + } + observe(viewModel.geoUri) { geoUri -> + if (geoUri != null) { + mapFragment?.setInitialCameraPosition(geoUri) + } + } } override fun onStart() { super.onStart() + + updateScreenOn() + visibleQuestsSource.addListener(this) mapDataWithEditsSource.addListener(this) locationAvailabilityReceiver.addListener(::updateLocationAvailability) - updateLocationAvailability(requireContext().isLocationAvailable) + + updateLocationAvailability(isLocationAvailable) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (intent.action != Intent.ACTION_VIEW) return + val data = intent.data?.toString() ?: return + viewModel.setUri(data) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + findViewById(R.id.main).requestLayout() } override fun onStop() { super.onStop() - wasFollowingPosition = mapFragment?.isFollowingPosition - wasNavigationMode = mapFragment?.isNavigationMode + visibleQuestsSource.removeListener(this) - locationAvailabilityReceiver.removeListener(::updateLocationAvailability) mapDataWithEditsSource.removeListener(this) + locationAvailabilityReceiver.removeListener(::updateLocationAvailability) + + wasFollowingPosition = mapFragment?.isFollowingPosition + wasNavigationMode = mapFragment?.isNavigationMode + locationManager.removeUpdates() } - private fun getQuestFormInsets() = Insets.of( - resources.getDimensionPixelSize(R.dimen.quest_form_leftOffset), - resources.getDimensionPixelSize(R.dimen.quest_form_topOffset), - resources.getDimensionPixelSize(R.dimen.quest_form_rightOffset), - resources.getDimensionPixelSize(R.dimen.quest_form_bottomOffset) - ) - //endregion + + /* ------------------------------- Preferences listeners ------------------------------------ */ + + private fun updateScreenOn() { + if (prefs.keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + //region QuestsMapFragment - Callbacks from the map with its quest pins /* ---------------------------------- MapFragment.Listener ---------------------------------- */ override fun onMapInitialized() { - binding.gpsTrackingButton.isActivated = mapFragment?.isFollowingPosition ?: false - binding.gpsTrackingButton.isNavigation = mapFragment?.isNavigationMode ?: false - binding.stopTracksButton.isVisible = mapFragment?.isRecordingTracks ?: false - updateLocationPointerPin() - mapFragment?.cameraPosition?.zoom?.let { updateCreateButtonEnablement(it) } - listener?.onMapInitialized() + viewModel.geoUri.value?.let { mapFragment?.setInitialCameraPosition(it) } + viewModel.isFollowingPosition.value = mapFragment?.isFollowingPosition ?: false + viewModel.isNavigationMode.value = mapFragment?.isNavigationMode ?: false + viewModel.isRecordingTracks.value = mapFragment?.isRecordingTracks ?: false + viewModel.mapCamera.value = mapFragment?.cameraPosition + updateDisplayedPosition() } - override fun onMapIsChanging(position: LatLon, rotation: Double, tilt: Double, zoom: Double) { - binding.compassView.rotation = -rotation.toFloat() - binding.compassView.rotationX = tilt.toFloat() - - val margin = 2 - binding.compassView.isInvisible = abs(rotation) < margin && tilt < margin - - updateLocationPointerPin() - updateCreateButtonEnablement(zoom) + override fun onMapIsChanging(camera: CameraPosition) { + viewModel.mapCamera.value = camera + updateDisplayedPosition() val f = bottomSheetFragment - if (f is IsMapOrientationAware) f.onMapOrientation(rotation, tilt) - if (f is IsMapPositionAware) f.onMapMoved(position) + if (f is IsMapOrientationAware) f.onMapOrientation(camera.rotation, camera.tilt) + if (f is IsMapPositionAware) f.onMapMoved(camera.position) } override fun onPanBegin() { @@ -405,7 +349,7 @@ class MainFragment : } override fun onLongPress(point: PointF, position: LatLon) { - if (bottomSheetFragment != null || editHistoryFragment != null) return + if (bottomSheetFragment != null || editHistoryViewModel.isShowingSidebar.value) return binding.contextMenuView.translationX = point.x binding.contextMenuView.translationY = point.y @@ -419,9 +363,9 @@ class MainFragment : if (isQuestDetailsCurrentlyDisplayedFor(questKey)) return val f = bottomSheetFragment if (f is IsCloseableBottomSheet) { - f.onClickClose { viewLifecycleScope.launch { showQuestDetails(questKey) } } + f.onClickClose { lifecycleScope.launch { showQuestDetails(questKey) } } } else { - viewLifecycleScope.launch { showQuestDetails(questKey) } + lifecycleScope.launch { showQuestDetails(questKey) } } } @@ -435,22 +379,32 @@ class MainFragment : if (!f.onClickMapAt(position, clickAreaSizeInMeters)) { f.onClickClose { closeBottomSheet() } } - } else if (editHistoryFragment != null) { - closeEditHistorySidebar() + } else if (editHistoryViewModel.isShowingSidebar.value) { + editHistoryViewModel.hideSidebar() } } override fun onClickedElement(elementKey: ElementKey) { val f = bottomSheetFragment if (f is IsCloseableBottomSheet) { - f.onClickClose { viewLifecycleScope.launch { showElementDetails(elementKey) } } + f.onClickClose { lifecycleScope.launch { showElementDetails(elementKey) } } } else { - viewLifecycleScope.launch { showElementDetails(elementKey) } + lifecycleScope.launch { showElementDetails(elementKey) } } } override fun onDisplayedLocationDidChange() { - updateLocationPointerPin() + updateDisplayedPosition() + } + + private fun updateDisplayedPosition() { + viewModel.displayedPosition.value = getDisplayedPoint()?.let { Offset(it.x, it.y) } + } + + private fun getDisplayedPoint(): PointF? { + val mapFragment = mapFragment ?: return null + val displayedPosition = mapFragment.displayedLocation?.toLatLon() ?: return null + return mapFragment.getPointOf(displayedPosition) } //endregion @@ -528,11 +482,13 @@ class MainFragment : mapFragment?.putMarkersForCurrentHighlighting(markers) } - @UiThread override fun deleteMarkerForCurrentHighlighting(geometry: ElementGeometry) { + @UiThread + override fun deleteMarkerForCurrentHighlighting(geometry: ElementGeometry) { mapFragment?.deleteMarkerForCurrentHighlighting(geometry) } - @UiThread override fun clearMarkersForCurrentHighlighting() { + @UiThread + override fun clearMarkersForCurrentHighlighting() { mapFragment?.clearMarkersForCurrentHighlighting() } @@ -560,6 +516,13 @@ class MainFragment : override fun getRecordedTrack(): List? = mapFragment?.recordedTracks + private fun getQuestFormInsets() = Insets.of( + resources.getDimensionPixelSize(R.dimen.quest_form_leftOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_topOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_rightOffset), + resources.getDimensionPixelSize(R.dimen.quest_form_bottomOffset) + ) + //endregion //region Data Updates - Callbacks for when data changed in the local database @@ -568,7 +531,7 @@ class MainFragment : @AnyThread override fun onUpdatedVisibleQuests(added: Collection, removed: Collection) { - viewLifecycleScope.launch { + lifecycleScope.launch { val f = bottomSheetFragment // open quest has been deleted if (f is IsShowingQuestDetails && f.questKey in removed) { @@ -579,7 +542,7 @@ class MainFragment : @AnyThread override fun onVisibleQuestsInvalidated() { - viewLifecycleScope.launch { + lifecycleScope.launch { val f = bottomSheetFragment if (f is IsShowingQuestDetails) { val openQuest = withContext(Dispatchers.IO) { visibleQuestsSource.get(f.questKey) } @@ -593,7 +556,7 @@ class MainFragment : @AnyThread override fun onUpdated(updated: MapDataWithGeometry, deleted: Collection) { - viewLifecycleScope.launch { + lifecycleScope.launch { val f = bottomSheetFragment // open element has been deleted if (f is IsShowingElement && f.elementKey in deleted) { @@ -604,8 +567,7 @@ class MainFragment : @AnyThread override fun onReplacedForBBox(bbox: BoundingBox, mapDataWithGeometry: MapDataWithGeometry) { - if (view == null) return - viewLifecycleScope.launch { + lifecycleScope.launch { val f = bottomSheetFragment if (f !is IsShowingElement) return@launch val elementKey = f.elementKey ?: return@launch @@ -619,7 +581,7 @@ class MainFragment : @AnyThread override fun onCleared() { - viewLifecycleScope.launch { + lifecycleScope.launch { val f = bottomSheetFragment if (f is IsShowingElement) { closeBottomSheet() @@ -643,63 +605,50 @@ class MainFragment : @SuppressLint("MissingPermission") private fun onLocationIsEnabled() { - binding.gpsTrackingButton.state = LocationState.SEARCHING - mapFragment!!.startPositionTracking() + viewModel.locationState.value = LocationState.SEARCHING + mapFragment?.startPositionTracking() + questAutoSyncer.startPositionTracking() setIsFollowingPosition(wasFollowingPosition ?: true) locationManager.getCurrentLocation() } private fun onLocationIsDisabled() { - binding.gpsTrackingButton.state = when { - requireContext().hasLocationPermission -> LocationState.ALLOWED + viewModel.locationState.value = when { + hasLocationPermission -> LocationState.ALLOWED else -> LocationState.DENIED } - binding.gpsTrackingButton.isNavigation = false - binding.locationPointerPin.visibility = View.GONE - mapFragment!!.clearPositionTracking() + viewModel.isNavigationMode.value = false + viewModel.displayedPosition.value = null + mapFragment?.clearPositionTracking() + questAutoSyncer.stopPositionTracking() locationManager.removeUpdates() } private fun onLocationChanged(location: Location) { - viewLifecycleScope.launch { - binding.gpsTrackingButton.state = LocationState.UPDATING - updateLocationPointerPin() - } + viewModel.locationState.value = LocationState.UPDATING } //endregion //region Buttons - Functionality for the buttons in the main view - fun onClickMainMenu() { - MainMenuDialog( - requireContext(), - if (controlsViewModel.isTeamMode.value) controlsViewModel.indexInTeam else null, - this::onClickDownload, - controlsViewModel::enableTeamMode, - controlsViewModel::disableTeamMode - ).show() - } - private fun onClickDownload() { - if (controlsViewModel.isConnected) { + if (viewModel.isConnected) { val downloadBbox = getDownloadArea() ?: return - if (controlsViewModel.isUserInitiatedDownloadInProgress) { - context?.let { - AlertDialog.Builder(it) - .setMessage(R.string.confirmation_cancel_prev_download_title) - .setPositiveButton(R.string.confirmation_cancel_prev_download_confirmed) { _, _ -> - controlsViewModel.download(downloadBbox) - } - .setNegativeButton(R.string.confirmation_cancel_prev_download_cancel, null) - .show() - } + if (viewModel.isUserInitiatedDownloadInProgress) { + AlertDialog.Builder(this) + .setMessage(R.string.confirmation_cancel_prev_download_title) + .setPositiveButton(R.string.confirmation_cancel_prev_download_confirmed) { _, _ -> + viewModel.download(downloadBbox) + } + .setNegativeButton(R.string.confirmation_cancel_prev_download_cancel, null) + .show() } else { - controlsViewModel.download(downloadBbox) + viewModel.download(downloadBbox) } } else { - context?.toast(R.string.offline) + toast(R.string.offline) } } @@ -713,50 +662,13 @@ class MainFragment : private fun onClickTracksStop() { // hide the track information - binding.stopTracksButton.isVisible = false + viewModel.isRecordingTracks.value = false val mapFragment = mapFragment ?: return mapFragment.stopPositionTrackRecording() val pos = mapFragment.displayedLocation?.toLatLon() ?: return composeNote(pos, true) } - private fun onClickUploadButton() { - if (controlsViewModel.isConnected) { - if (controlsViewModel.isLoggedIn.value) { - controlsViewModel.upload() - } else { - context?.let { RequestLoginDialog(it).show() } - } - } else { - context?.toast(R.string.offline) - } - } - - private fun onClickUndoButton() { - showEditHistorySidebar() - } - - private fun onClickMessagesButton() { - viewLifecycleScope.launch { - val message = controlsViewModel.popMessage() - if (message != null) { - listener?.onClickShowMessage(message) - } - } - } - - private fun onClickAnswersCounterView() { - controlsViewModel.toggleShowingCurrentWeek() - } - - private fun onClickOverlaysButton() { - if (!controlsViewModel.hasShownOverlaysTutorial) { - showOverlaysTutorial() - } else { - showOverlaysMenu() - } - } - private fun onClickCompassButton() { // Clicking the compass button will always rotate the map back to north and remove tilt val mapFragment = mapFragment ?: return @@ -774,11 +686,11 @@ class MainFragment : } } - private fun onClickTrackingButton() { + private fun onClickLocationButton() { val mapFragment = mapFragment ?: return when { - !binding.gpsTrackingButton.state.isEnabled -> { + !viewModel.locationState.value.isEnabled -> { requestLocation() } !mapFragment.isFollowingPosition -> { @@ -790,60 +702,40 @@ class MainFragment : } } + private fun onClickLocationPointer() { + setIsFollowingPosition(true) + } + private fun requestLocation() { - (childFragmentManager.findFragmentByTag(TAG_LOCATION_REQUEST) as? LocationRequestFragment)?.startRequest() + (supportFragmentManager.findFragmentByTag(TAG_LOCATION_REQUEST) as? LocationRequestFragment)?.startRequest() } private fun onClickCreateButton() { showOverlayFormForNewElement() } - private fun updateCreateButtonEnablement(zoom: Double) { - binding.createButton.isEnabled = zoom >= 17.0 - } - private fun setIsNavigationMode(navigation: Boolean) { - val mapFragment = mapFragment ?: return - mapFragment.isNavigationMode = navigation - binding.gpsTrackingButton.isNavigation = navigation + mapFragment?.isNavigationMode = navigation + viewModel.isNavigationMode.value = navigation } private fun setIsFollowingPosition(follow: Boolean) { - val mapFragment = mapFragment ?: return - mapFragment.isFollowingPosition = follow - binding.gpsTrackingButton.isActivated = follow - if (follow) mapFragment.centerCurrentPositionIfFollowing() - } - - private fun showOverlaysTutorial() { - listener?.onShowOverlaysTutorial() - } - - private fun showOverlaysMenu() { - val adapter = OverlaySelectionAdapter(controlsViewModel.overlays) - val popupWindow = ListPopupWindow(requireContext()) - - popupWindow.setAdapter(adapter) - popupWindow.setOnItemClickListener { _, _, position, _ -> - controlsViewModel.selectOverlay(adapter.getItem(position)) - popupWindow.dismiss() - } - popupWindow.anchorView = binding.overlaysButton - popupWindow.width = resources.dpToPx(240).toInt() - popupWindow.show() + mapFragment?.isFollowingPosition = follow + viewModel.isFollowingPosition.value = follow + if (follow) mapFragment?.centerCurrentPositionIfFollowing() } private fun getDownloadArea(): BoundingBox? { val displayArea = mapFragment?.getDisplayedArea() if (displayArea == null) { - context?.toast(R.string.cannot_find_bbox_or_reduce_tilt, Toast.LENGTH_LONG) + toast(R.string.cannot_find_bbox_or_reduce_tilt, Toast.LENGTH_LONG) return null } val enclosingBBox = displayArea.asBoundingBoxOfEnclosingTiles(ApplicationConstants.DOWNLOAD_TILE_ZOOM) val areaInSqKm = enclosingBBox.area() / 1000000 if (areaInSqKm > ApplicationConstants.MAX_DOWNLOADABLE_AREA_IN_SQKM) { - context?.toast(R.string.download_area_too_big, Toast.LENGTH_LONG) + toast(R.string.download_area_too_big, Toast.LENGTH_LONG) return null } @@ -862,7 +754,7 @@ class MainFragment : /* -------------------------------------- Context Menu -------------------------------------- */ private fun showMapContextMenu(position: LatLon) { - val popupMenu = PopupMenu(requireContext(), binding.contextMenuView) + val popupMenu = PopupMenu(this, binding.contextMenuView) popupMenu.inflate(R.menu.menu_map_context) popupMenu.setOnMenuItemClickListener { item -> when (item.itemId) { @@ -876,24 +768,22 @@ class MainFragment : } private fun onClickOpenLocationInOtherApp(pos: LatLon) { - val ctx = context ?: return - val zoom = mapFragment?.cameraPosition?.zoom val uri = buildGeoUri(pos.latitude, pos.longitude, zoom) - val intent = Intent(Intent.ACTION_VIEW, uri) - val otherMapAppInstalled = ctx.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - .any { !it.activityInfo.packageName.equals(ctx.packageName) } + val intent = Intent(Intent.ACTION_VIEW, uri.toUri()) + val otherMapAppInstalled = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .any { !it.activityInfo.packageName.equals(packageName) } if (otherMapAppInstalled) { startActivity(intent) } else { - ctx.toast(R.string.map_application_missing, Toast.LENGTH_LONG) + toast(R.string.map_application_missing, Toast.LENGTH_LONG) } } private fun onClickCreateNote(pos: LatLon) { if ((mapFragment?.cameraPosition?.zoom ?: 0.0) < ApplicationConstants.NOTE_MIN_ZOOM) { - context?.toast(R.string.create_new_note_unprecise) + toast(R.string.create_new_note_unprecise) return } @@ -906,91 +796,16 @@ class MainFragment : } private fun composeNote(pos: LatLon, hasGpxAttached: Boolean = false) { - val mapFragment = mapFragment ?: return showInBottomSheet(CreateNoteFragment.create(hasGpxAttached)) - mapFragment.updateCameraPosition(300) { + mapFragment?.updateCameraPosition(300) { position = pos padding = getQuestFormInsets().toPadding() } } private fun onClickCreateTrack() { - val mapFragment = mapFragment ?: return - mapFragment.startPositionTrackRecording() - binding.stopTracksButton.isVisible = true - } - - // ---------------------------------- Location Pointer Pin --------------------------------- */ - - private fun updateLocationPointerPin() { - val mapFragment = mapFragment ?: return - val camera = mapFragment.cameraPosition ?: return - val position = camera.position - val rotation = camera.rotation - - val location = mapFragment.displayedLocation - if (location == null) { - binding.locationPointerPin.visibility = View.GONE - return - } - val displayedPosition = LatLon(location.latitude, location.longitude) - - var target = mapFragment.getPointOf(displayedPosition) ?: return - windowInsets?.let { - target -= PointF(it.left.toFloat(), it.top.toFloat()) - } - val intersection = findClosestIntersection(binding.mapControls, target) - if (intersection != null) { - val intersectionPosition = mapFragment.getPositionAt(intersection) - binding.locationPointerPin.isGone = intersectionPosition == null - if (intersectionPosition != null) { - val angleAtIntersection = position.initialBearingTo(intersectionPosition) - binding.locationPointerPin.pinRotation = (angleAtIntersection - rotation).toFloat() - - val a = (angleAtIntersection - rotation) * PI / 180f - val offsetX = (sin(a) / 2.0 + 0.5) * binding.locationPointerPin.width - val offsetY = (-cos(a) / 2.0 + 0.5) * binding.locationPointerPin.height - binding.locationPointerPin.x = intersection.x - offsetX.toFloat() - binding.locationPointerPin.y = intersection.y - offsetY.toFloat() - } - } else { - binding.locationPointerPin.visibility = View.GONE - } - } - - private fun onClickLocationPointer() { - setIsFollowingPosition(true) - } - - //endregion - - //region Edit History Sidebar - - private fun showEditHistorySidebar() { - freezeMap() - val appearAnim = R.animator.edit_history_sidebar_appear - val disappearAnim = R.animator.edit_history_sidebar_disappear - if (editHistoryFragment != null) { - childFragmentManager.popBackStack(EDIT_HISTORY, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - childFragmentManager.commit(true) { - setCustomAnimations(appearAnim, disappearAnim, appearAnim, disappearAnim) - add(R.id.edit_history_container, EditHistoryFragment(), EDIT_HISTORY) - addToBackStack(EDIT_HISTORY) - } - mapFragment?.hideOverlay() - mapFragment?.pinMode = MainMapFragment.PinMode.EDITS - historyBackPressedCallback.isEnabled = true - } - - private fun closeEditHistorySidebar() { - unfreezeMap() - if (editHistoryFragment != null) { - childFragmentManager.popBackStack(EDIT_HISTORY, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - editHistoryViewModel.select(null) - mapFragment?.pinMode = MainMapFragment.PinMode.QUESTS - historyBackPressedCallback.isEnabled = false + mapFragment?.startPositionTrackRecording() + viewModel.isRecordingTracks.value = true } //endregion @@ -1001,9 +816,9 @@ class MainFragment : * view (e.g. if it was zoomed in before to focus on an element) */ @UiThread private fun closeBottomSheet() { - activity?.currentFocus?.hideKeyboard() + currentFocus?.hideKeyboard() if (bottomSheetFragment != null) { - childFragmentManager.popBackStack(BOTTOM_SHEET, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.popBackStack(BOTTOM_SHEET, FragmentManager.POP_BACK_STACK_INCLUSIVE) } clearHighlighting() unfreezeMap() @@ -1014,15 +829,15 @@ class MainFragment : /** Open or replace the bottom sheet. If the bottom sheet is replaces, no appear animation is * played and the highlighting of the previous bottom sheet is cleared. */ private fun showInBottomSheet(f: Fragment, clearPreviousHighlighting: Boolean = true) { - activity?.currentFocus?.hideKeyboard() + currentFocus?.hideKeyboard() freezeMap() if (bottomSheetFragment != null) { if (clearPreviousHighlighting) clearHighlighting() - childFragmentManager.popBackStack(BOTTOM_SHEET, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.popBackStack(BOTTOM_SHEET, FragmentManager.POP_BACK_STACK_INCLUSIVE) } val appearAnim = R.animator.quest_answer_form_appear val disappearAnim = R.animator.quest_answer_form_disappear - childFragmentManager.commit(true) { + supportFragmentManager.commit(true) { setCustomAnimations(appearAnim, disappearAnim, appearAnim, disappearAnim) add(R.id.map_bottom_sheet_container, f, BOTTOM_SHEET) addToBackStack(BOTTOM_SHEET) @@ -1057,7 +872,7 @@ class MainFragment : @UiThread private fun showOverlayFormForNewElement() { - val overlay = controlsViewModel.selectedOverlay.value ?: return + val overlay = viewModel.selectedOverlay.value ?: return val mapFragment = mapFragment ?: return val f = overlay.createForm(null) ?: return @@ -1080,7 +895,7 @@ class MainFragment : @UiThread private suspend fun showElementDetails(elementKey: ElementKey) { if (isElementCurrentlyDisplayed(elementKey)) return - val overlay = controlsViewModel.selectedOverlay.value ?: return + val overlay = viewModel.selectedOverlay.value ?: return val geometry = mapDataWithEditsSource.getGeometry(elementKey.type, elementKey.id) ?: return val mapFragment = mapFragment ?: return @@ -1169,7 +984,7 @@ class MainFragment : val levels = parseLevelsOrNull(element.tags) - viewLifecycleScope.launch(Dispatchers.Default) { + lifecycleScope.launch(Dispatchers.Default) { val elements = withContext(Dispatchers.IO) { quest.type.getHighlightedElements(element, ::getMapData) } @@ -1198,8 +1013,8 @@ class MainFragment : return f is IsShowingQuestDetails && f.questKey == questKey } - private fun getCrosshairPoint(): PointF? { - val view = view ?: return null + private fun getCrosshairPoint(): PointF { + val view = binding.root val left = resources.getDimensionPixelSize(R.dimen.quest_form_leftOffset) val right = resources.getDimensionPixelSize(R.dimen.quest_form_rightOffset) val top = resources.getDimensionPixelSize(R.dimen.quest_form_topOffset) @@ -1214,11 +1029,10 @@ class MainFragment : //region Animation - Animation(s) for when a quest is solved private fun showQuestSolvedAnimation(iconResId: Int, position: LatLon) { - val ctx = context ?: return - val offset = view?.getLocationInWindow() ?: return + val offset = binding.root.getLocationInWindow() val startPos = mapFragment?.getPointOf(position) ?: return - val size = ctx.resources.dpToPx(42).toInt() + val size = resources.dpToPx(42).toInt() startPos.x += offset.x - size / 2f startPos.y += offset.y - size * 1.5f @@ -1226,28 +1040,21 @@ class MainFragment : } private fun showMarkerSolvedAnimation(@DrawableRes iconResId: Int, startScreenPos: PointF) { - val ctx = context ?: return - val activity = activity ?: return - val view = view ?: return - - viewLifecycleScope.launch { - soundFx.play(resources.getIdentifier("plop" + Random.nextInt(4), "raw", ctx.packageName)) + lifecycleScope.launch { + soundFx.play(resources.getIdentifier("plop" + Random.nextInt(4), "raw", packageName)) } - val root = activity.window.decorView as ViewGroup + val root = window.decorView as ViewGroup val img = EffectQuestPlopBinding.inflate(layoutInflater, root, false).root img.x = startScreenPos.x img.y = startScreenPos.y img.setImageResource(iconResId) root.addView(img) - val isAutoSync = controlsViewModel.isAutoSync.value - val answerTarget = if (isAutoSync) binding.starsCounterView else binding.uploadButton - flingQuestMarkerTo(img, answerTarget) { root.removeView(img) } + flingQuestMarker(img) { root.removeView(img) } } - private fun flingQuestMarkerTo(quest: View, target: View, onFinished: () -> Unit) { - val targetPos = target.getLocationInWindow().toPointF() + private fun flingQuestMarker(quest: View, onFinished: () -> Unit) { quest.animate() .scaleX(1.6f).scaleY(1.6f) .setInterpolator(OvershootInterpolator(8f)) @@ -1256,7 +1063,7 @@ class MainFragment : quest.animate() .scaleX(0.2f).scaleY(0.2f) .alpha(0.8f) - .x(targetPos.x).y(targetPos.y) + .x(0f).y(0f) .setDuration(250) .setInterpolator(AccelerateInterpolator()) .withEndAction(onFinished) @@ -1265,24 +1072,8 @@ class MainFragment : //endregion - /* ++++++++++++++++++++++++++++++++++++++++ INTERFACE +++++++++++++++++++++++++++++++++++++++ */ - - //region Interface - For the parent fragment / activity - - fun getCameraPosition(): CameraPosition? = mapFragment?.cameraPosition - - fun setCameraPosition(position: LatLon, zoom: Double) { - mapFragment?.isFollowingPosition = false - mapFragment?.isNavigationMode = false - mapFragment?.setInitialCameraPosition(CameraPosition(position, 0.0, 0.0, zoom)) - setIsFollowingPosition(false) - } - - //endregion - companion object { private const val BOTTOM_SHEET = "bottom_sheet" - private const val EDIT_HISTORY = "edit_history" private const val TAG_LOCATION_REQUEST = "LocationRequestFragment" } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt new file mode 100644 index 0000000000..9d9cb19533 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainMenuDialog.kt @@ -0,0 +1,169 @@ +package de.westnordost.streetcomplete.screens.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.screens.main.teammode.TeamModeColorCircle + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MainMenuDialog( + onDismissRequest: () -> Unit, + onClickProfile: () -> Unit, + onClickSettings: () -> Unit, + onClickAbout: () -> Unit, + onClickDownload: () -> Unit, + onClickEnterTeamMode: () -> Unit, + onClickExitTeamMode: () -> Unit, + isLoggedIn: Boolean, + indexInTeam: Int?, + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.medium, + backgroundColor: Color = MaterialTheme.colors.surface, + contentColor: Color = contentColorFor(backgroundColor), +) { + Dialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = modifier, + shape = shape, + color = backgroundColor, + contentColor = contentColor + ) { + Column { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + BigMenuButton( + onClick = { onDismissRequest(); onClickProfile() }, + icon = { Icon(painterResource(R.drawable.ic_profile_48dp), null) }, + text = stringResource( + if (isLoggedIn) R.string.user_profile else R.string.user_login + ), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickSettings() }, + icon = { Icon(painterResource(R.drawable.ic_settings_48dp), null) }, + text = stringResource(R.string.action_settings), + ) + BigMenuButton( + onClick = { onDismissRequest(); onClickAbout() }, + icon = { Icon(painterResource(R.drawable.ic_info_outline_48dp), null) }, + text = stringResource(R.string.action_about2), + ) + } + Divider() + CompactMenuButton( + onClick = { onDismissRequest(); onClickDownload() }, + icon = { Icon(painterResource(R.drawable.ic_file_download_24dp), null) }, + text = stringResource(R.string.action_download), + ) + if (indexInTeam == null) { + CompactMenuButton( + onClick = { onDismissRequest(); onClickEnterTeamMode() }, + icon = { Icon(painterResource(R.drawable.ic_team_mode_24dp), null) }, + text = stringResource(R.string.team_mode) + ) + } else { + CompactMenuButton( + onClick = { onDismissRequest(); onClickExitTeamMode() }, + icon = { + TeamModeColorCircle( + index = indexInTeam, + modifier = Modifier.size(24.dp) + ) + }, + text = stringResource(R.string.team_mode_exit) + ) + } + } + } + } +} + +@Composable +private fun BigMenuButton( + onClick: () -> Unit, + icon: @Composable () -> Unit, + text: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .width(144.dp) + .clickable { onClick() } + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + icon() + Text( + text = text, + style = MaterialTheme.typography.body1 + ) + } +} + +@Composable +private fun CompactMenuButton( + onClick: () -> Unit, + icon: @Composable () -> Unit, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Text( + text = text, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body1, + ) + } +} + +@Preview +@Composable +private fun PreviewMainMenuDialog() { + MainMenuDialog( + onDismissRequest = {}, + onClickProfile = {}, + onClickSettings = {}, + onClickAbout = {}, + onClickDownload = {}, + onClickEnterTeamMode = {}, + onClickExitTeamMode = {}, + isLoggedIn = true, + indexInTeam = 0, + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt index 53aaad45df..9a74c44296 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainModule.kt @@ -13,7 +13,8 @@ val mainModule = module { single { RecentLocationStore() } viewModel { MainViewModelImpl( - get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get() + get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), + get(), get(), get(), get(), get(), get() ) } viewModel { EditHistoryViewModelImpl(get(), get(), get(named("FeatureDictionaryLazy"))) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt new file mode 100644 index 0000000000..b03a72852b --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainScreen.kt @@ -0,0 +1,480 @@ +package de.westnordost.streetcomplete.screens.main + +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.screens.about.AboutActivity +import de.westnordost.streetcomplete.screens.main.controls.CompassButton +import de.westnordost.streetcomplete.screens.main.controls.Crosshair +import de.westnordost.streetcomplete.screens.main.controls.LocationStateButton +import de.westnordost.streetcomplete.screens.main.controls.MainMenuButton +import de.westnordost.streetcomplete.screens.main.controls.MapAttribution +import de.westnordost.streetcomplete.screens.main.controls.MapButton +import de.westnordost.streetcomplete.screens.main.controls.MessagesButton +import de.westnordost.streetcomplete.screens.main.controls.OverlaySelectionButton +import de.westnordost.streetcomplete.screens.main.controls.PointerPinButton +import de.westnordost.streetcomplete.screens.main.controls.StarsCounter +import de.westnordost.streetcomplete.screens.main.controls.UploadButton +import de.westnordost.streetcomplete.screens.main.controls.findClosestIntersection +import de.westnordost.streetcomplete.screens.main.edithistory.EditHistorySidebar +import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryViewModel +import de.westnordost.streetcomplete.screens.main.errors.LastCrashEffect +import de.westnordost.streetcomplete.screens.main.errors.LastDownloadErrorEffect +import de.westnordost.streetcomplete.screens.main.errors.LastUploadErrorEffect +import de.westnordost.streetcomplete.screens.main.messages.MessageDialog +import de.westnordost.streetcomplete.screens.main.overlays.OverlaySelectionDropdownMenu +import de.westnordost.streetcomplete.screens.main.teammode.TeamModeWizard +import de.westnordost.streetcomplete.screens.main.urlconfig.ApplyUrlConfigEffect +import de.westnordost.streetcomplete.screens.settings.SettingsActivity +import de.westnordost.streetcomplete.screens.tutorial.IntroTutorialScreen +import de.westnordost.streetcomplete.screens.tutorial.OverlaysTutorialScreen +import de.westnordost.streetcomplete.screens.user.UserActivity +import de.westnordost.streetcomplete.ui.common.AnimatedScreenVisibility +import de.westnordost.streetcomplete.ui.common.LargeCreateIcon +import de.westnordost.streetcomplete.ui.common.StopRecordingIcon +import de.westnordost.streetcomplete.ui.common.UndoIcon +import de.westnordost.streetcomplete.ui.common.ZoomInIcon +import de.westnordost.streetcomplete.ui.common.ZoomOutIcon +import de.westnordost.streetcomplete.ui.ktx.dir +import de.westnordost.streetcomplete.ui.ktx.pxToDp +import de.westnordost.streetcomplete.util.ktx.sendErrorReportEmail +import de.westnordost.streetcomplete.util.ktx.toast +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs + + +/** Map controls shown on top of the map. */ +@Composable +fun MainScreen( + viewModel: MainViewModel, + editHistoryViewModel: EditHistoryViewModel, + onClickZoomIn: () -> Unit, + onClickZoomOut: () -> Unit, + onClickCompass: () -> Unit, + onClickLocation: () -> Unit, + onClickLocationPointer: () -> Unit, + onClickCreate: () -> Unit, + onClickStopTrackRecording: () -> Unit, + onClickDownload: () -> Unit, + onExplainedNeedForLocationPermission: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + + val context = LocalContext.current + + val starsCount by viewModel.starsCount.collectAsState() + val isShowingStarsCurrentWeek by viewModel.isShowingStarsCurrentWeek.collectAsState() + + val selectedOverlay by viewModel.selectedOverlay.collectAsState() + + val isAutoSync by viewModel.isAutoSync.collectAsState() + val unsyncedEditsCount by viewModel.unsyncedEditsCount.collectAsState() + + val isTeamMode by viewModel.isTeamMode.collectAsState() + val indexInTeam by viewModel.indexInTeam.collectAsState() + + val messagesCount by viewModel.messagesCount.collectAsState() + + val isLoggedIn by viewModel.isLoggedIn.collectAsState() + val isUploadingOrDownloading by viewModel.isUploadingOrDownloading.collectAsState() + + val urlConfig by viewModel.urlConfig.collectAsState() + val lastCrashReport by viewModel.lastCrashReport.collectAsState() + val lastDownloadError by viewModel.lastDownloadError.collectAsState() + val lastUploadError by viewModel.lastUploadError.collectAsState() + + val locationState by viewModel.locationState.collectAsState() + val isNavigationMode by viewModel.isNavigationMode.collectAsState() + val isFollowingPosition by viewModel.isFollowingPosition.collectAsState() + val isRecordingTracks by viewModel.isRecordingTracks.collectAsState() + + val mapCamera by viewModel.mapCamera.collectAsState() + val displayedPosition by viewModel.displayedPosition.collectAsState() + + val editItems by editHistoryViewModel.editItems.collectAsState() + val selectedEdit by editHistoryViewModel.selectedEdit.collectAsState() + val hasEdits by remember { derivedStateOf { editItems.isNotEmpty() } } + + val isRequestingLogin by viewModel.isRequestingLogin.collectAsState() + + var showOverlaysDropdown by remember { mutableStateOf(false) } + var showOverlaysTutorial by remember { mutableStateOf(false) } + var showIntroTutorial by remember { mutableStateOf(false) } + var showTeamModeWizard by remember { mutableStateOf(false) } + var showMainMenuDialog by remember { mutableStateOf(false) } + var shownMessage by remember { mutableStateOf(null) } + val showEditHistorySidebar by editHistoryViewModel.isShowingSidebar.collectAsState() + + val mapRotation = mapCamera?.rotation ?: 0.0 + val mapTilt = mapCamera?.tilt ?: 0.0 + + fun onClickOverlays() { + if (viewModel.hasShownOverlaysTutorial) { + showOverlaysDropdown = true + } else { + showOverlaysTutorial = true + } + } + + fun onClickMessages() { + coroutineScope.launch { + shownMessage = viewModel.popMessage() + } + } + + fun onClickUpload() { + if (viewModel.isConnected) { + viewModel.upload() + } else { + context.toast(R.string.offline) + } + } + + fun sendErrorReport(error: Exception) { + coroutineScope.launch { + val report = viewModel.createErrorReport(error) + context.sendErrorReportEmail(report) + } + } + + LaunchedEffect(viewModel.hasShownTutorial) { + if (!viewModel.hasShownTutorial && !isLoggedIn) { + showIntroTutorial = true + } + } + + LaunchedEffect(isTeamMode) { + // always show this toast on start to remind user that it is still on + if (isTeamMode) { + context.toast(R.string.team_mode_active) + } + // show this only once when turning it off + else if (viewModel.teamModeChanged) { + context.toast(R.string.team_mode_deactivated) + viewModel.teamModeChanged = false + } + } + + Box(modifier) { + if (selectedOverlay?.isCreateNodeEnabled == true) { + Crosshair() + } + + val pointerPinRects = remember { mutableStateMapOf() } + val intersection = remember(displayedPosition, pointerPinRects) { + findClosestIntersection( + origin = pointerPinRects["frame"]?.center, + target = displayedPosition, + rects = pointerPinRects.values + ) + } + + Column(Modifier + .fillMaxSize() + .safeDrawingPadding() + .onGloballyPositioned { pointerPinRects["frame"] = it.boundsInRoot() } + ) { + Box(Modifier + .fillMaxWidth() + .weight(1f) + ) { + // top-start controls + Box(Modifier + .align(Alignment.TopStart) + .onGloballyPositioned { pointerPinRects["top-start"] = it.boundsInRoot() } + ) { + // stars counter + StarsCounter( + count = starsCount, + modifier = Modifier + .defaultMinSize(minWidth = 96.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { viewModel.toggleShowingCurrentWeek() }, + isCurrentWeek = isShowingStarsCurrentWeek, + showProgress = isUploadingOrDownloading + ) + } + + // top-end controls + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .onGloballyPositioned { pointerPinRects["top-end"] = it.boundsInRoot() }, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (messagesCount > 0) { + MessagesButton( + onClick = ::onClickMessages, + messagesCount = messagesCount + ) + } + if (!isAutoSync) { + UploadButton( + onClick = ::onClickUpload, + unsyncedEditsCount = unsyncedEditsCount, + enabled = !isUploadingOrDownloading + ) + } + Box { + OverlaySelectionButton( + onClick = ::onClickOverlays, + overlay = selectedOverlay + ) + OverlaySelectionDropdownMenu( + expanded = showOverlaysDropdown, + onDismissRequest = { showOverlaysDropdown = false }, + overlays = viewModel.overlays, + onSelect = { viewModel.selectOverlay(it) } + ) + } + + MainMenuButton( + onClick = { showMainMenuDialog = true }, + indexInTeam = if (isTeamMode) indexInTeam else null + ) + } + + // bottom-end controls + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .onGloballyPositioned { pointerPinRects["bottom-end"] = it.boundsInRoot() }, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val isCompassVisible = abs(mapRotation) >= 1.0 || abs(mapTilt) >= 1.0 + AnimatedVisibility( + visible = isCompassVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + CompassButton( + onClick = onClickCompass, + modifier = Modifier.graphicsLayer( + rotationZ = -mapRotation.toFloat(), + rotationX = mapTilt.toFloat() + ) + ) + } + MapButton(onClick = onClickZoomIn) { ZoomInIcon() } + MapButton(onClick = onClickZoomOut) { ZoomOutIcon() } + LocationStateButton( + onClick = onClickLocation, + state = locationState, + isNavigationMode = isNavigationMode, + isFollowing = isFollowingPosition, + ) + } + + if (selectedOverlay?.isCreateNodeEnabled == true) { + MapButton( + onClick = { + if ((mapCamera?.zoom ?: 0.0) >= 17.0) { + onClickCreate() + } else { + context.toast(R.string.download_area_too_big, Toast.LENGTH_LONG) + } + }, + modifier = Modifier + .align(BiasAlignment(0.333f, 1f)) + .padding(4.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.secondaryVariant, + ), + ) { + LargeCreateIcon() + } + } + + // bottom-start controls + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp) + .onGloballyPositioned { + pointerPinRects["bottom-start"] = it.boundsInRoot() + }, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isRecordingTracks) { + MapButton( + onClick = onClickStopTrackRecording, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.secondaryVariant, + ), + ) { + StopRecordingIcon() + } + } + + if (hasEdits) { + MapButton( + onClick = { editHistoryViewModel.showSidebar() }, + // Don't allow undoing while uploading. Should prevent race conditions. + // (Undoing quest while also uploading it at the same time) + enabled = !isUploadingOrDownloading, + ) { + UndoIcon() + } + } + } + } + + MapAttribution(Modifier.padding(8.dp)) + } + + if (intersection != null) { + val (offset, angle) = intersection + val rotation = angle * 180 / PI + PointerPinButton( + onClick = onClickLocationPointer, + rotate = rotation.toFloat(), + modifier = Modifier.absoluteOffset(offset.x.pxToDp(), offset.y.pxToDp()), + ) { Image(painterResource(R.drawable.location_dot_small), null) } + } + + val dir = LocalLayoutDirection.current.dir + AnimatedVisibility( + visible = showEditHistorySidebar, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 2 * dir }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 2 * dir }), + ) { + EditHistorySidebar( + editItems = editItems, + selectedEdit = selectedEdit, + onSelectEdit = { editHistoryViewModel.select(it.key) }, + onUndoEdit = { editHistoryViewModel.undo(it.key) }, + onDismissRequest = { editHistoryViewModel.hideSidebar() }, + featureDictionaryLazy = editHistoryViewModel.featureDictionaryLazy, + getEditElement = editHistoryViewModel::getEditElement, + ) + } + } + + shownMessage?.let { message -> + val questIcons = remember { viewModel.allQuestTypes.map { it.icon } } + MessageDialog( + message = message, + onDismissRequest = { shownMessage = null }, + allQuestIconIds = questIcons + ) + } + + if (showMainMenuDialog) { + MainMenuDialog( + onDismissRequest = { showMainMenuDialog = false }, + onClickProfile = { context.startActivity(Intent(context, UserActivity::class.java)) }, + onClickSettings = { context.startActivity(Intent(context, SettingsActivity::class.java)) }, + onClickAbout = { context.startActivity(Intent(context, AboutActivity::class.java)) }, + onClickDownload = onClickDownload, + onClickEnterTeamMode = { showTeamModeWizard = true }, + onClickExitTeamMode = { viewModel.disableTeamMode() }, + isLoggedIn = isLoggedIn, + indexInTeam = if (isTeamMode) indexInTeam else null, + ) + } + + urlConfig?.let { config -> + ApplyUrlConfigEffect( + urlConfig = config.urlConfig, + presetNameAlreadyExists = config.alreadyExists, + onApplyUrlConfig = { viewModel.applyUrlConfig(it) } + ) + } + lastDownloadError?.let { error -> + LastDownloadErrorEffect(lastError = error, onReportError = ::sendErrorReport) + } + lastUploadError?.let { error -> + LastUploadErrorEffect(lastError = error, onReportError = ::sendErrorReport) + } + lastCrashReport?.let { report -> + LastCrashEffect(lastReport = report, onReport = { context.sendErrorReportEmail(it) }) + } + + if (isRequestingLogin) { + RequestLoginDialog( + onDismissRequest = { viewModel.finishRequestingLogin() }, + onConfirmed = { + val intent = Intent(context, UserActivity::class.java) + intent.putExtra(UserActivity.EXTRA_LAUNCH_AUTH, true) + context.startActivity(intent) + } + ) + } + + AnimatedScreenVisibility(showTeamModeWizard) { + val questIcons = remember { viewModel.allQuestTypes.map { it.icon } } + TeamModeWizard( + onDismissRequest = { showTeamModeWizard = false }, + onFinished = { teamSize, indexInTeam -> + viewModel.enableTeamMode( + teamSize = teamSize, + indexInTeam = indexInTeam + ) + }, + allQuestIconIds = questIcons + ) + } + + AnimatedScreenVisibility(showOverlaysTutorial) { + OverlaysTutorialScreen( + onDismissRequest = { showOverlaysTutorial = false }, + onFinished = { viewModel.hasShownOverlaysTutorial = true } + ) + } + + AnimatedScreenVisibility(showIntroTutorial) { + IntroTutorialScreen( + onDismissRequest = { showIntroTutorial = false }, + onExplainedNeedForLocationPermission = onExplainedNeedForLocationPermission, + onFinished = { viewModel.hasShownTutorial = true }, + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt index cde247bb76..31d312c2ff 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModel.kt @@ -1,28 +1,52 @@ package de.westnordost.streetcomplete.screens.main +import androidx.compose.ui.geometry.Offset import androidx.lifecycle.ViewModel import de.westnordost.streetcomplete.data.messages.Message import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.urlconfig.UrlConfig import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.screens.main.controls.LocationState +import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow abstract class MainViewModel : ViewModel() { + /* error handling */ + abstract val lastCrashReport: StateFlow + + abstract val lastDownloadError: StateFlow + abstract val lastUploadError: StateFlow + abstract suspend fun createErrorReport(error: Exception): String + + /* start parameters */ + abstract fun setUri(uri: String) + + abstract val urlConfig: StateFlow + abstract fun applyUrlConfig(config: UrlConfig) + abstract val geoUri: StateFlow + + /* intro */ + abstract var hasShownTutorial: Boolean + /* messages */ abstract val messagesCount: StateFlow abstract suspend fun popMessage(): Message? + abstract val allQuestTypes: List /* overlays */ abstract val selectedOverlay: StateFlow abstract val overlays: List - abstract val hasShownOverlaysTutorial: Boolean + abstract var hasShownOverlaysTutorial: Boolean abstract fun selectOverlay(overlay: Overlay?) /* team mode */ abstract val isTeamMode: StateFlow abstract var teamModeChanged: Boolean - abstract val indexInTeam: Int + abstract val indexInTeam: StateFlow abstract fun enableTeamMode(teamSize: Int, indexInTeam: Int) abstract fun disableTeamMode() @@ -37,6 +61,9 @@ abstract class MainViewModel : ViewModel() { abstract val isLoggedIn: StateFlow abstract val isConnected: Boolean + abstract val isRequestingLogin: StateFlow + abstract fun finishRequestingLogin() + abstract fun upload() abstract fun download(bbox: BoundingBox) @@ -44,4 +71,18 @@ abstract class MainViewModel : ViewModel() { abstract val starsCount: StateFlow abstract val isShowingStarsCurrentWeek: StateFlow abstract fun toggleShowingCurrentWeek() + + /* map */ + // NOTE: currently filled from MainActivity (communication to compose view), i.e. the source of + // truth is actually the MapFragment + abstract val locationState: MutableStateFlow + abstract val mapCamera: MutableStateFlow + abstract val displayedPosition: MutableStateFlow + + abstract val isFollowingPosition: MutableStateFlow + abstract val isNavigationMode: MutableStateFlow + + abstract val isRecordingTracks: MutableStateFlow } + +data class ShownUrlConfig(val urlConfig: UrlConfig, val alreadyExists: Boolean) diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt index a453cb2e6f..d02e43cb42 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainViewModelImpl.kt @@ -1,26 +1,41 @@ package de.westnordost.streetcomplete.screens.main +import androidx.compose.ui.geometry.Offset import androidx.lifecycle.viewModelScope import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource import de.westnordost.streetcomplete.data.download.DownloadController import de.westnordost.streetcomplete.data.download.DownloadProgressSource import de.westnordost.streetcomplete.data.messages.Message import de.westnordost.streetcomplete.data.messages.MessagesSource +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsSource import de.westnordost.streetcomplete.data.overlays.OverlayRegistry import de.westnordost.streetcomplete.data.overlays.SelectedOverlayController import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource import de.westnordost.streetcomplete.data.platform.InternetConnectionState import de.westnordost.streetcomplete.data.preferences.Autosync import de.westnordost.streetcomplete.data.preferences.Preferences +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.upload.UploadController import de.westnordost.streetcomplete.data.upload.UploadProgressSource +import de.westnordost.streetcomplete.data.urlconfig.UrlConfig +import de.westnordost.streetcomplete.data.urlconfig.UrlConfigController import de.westnordost.streetcomplete.data.user.UserLoginSource import de.westnordost.streetcomplete.data.user.statistics.StatisticsSource +import de.westnordost.streetcomplete.data.visiblequests.QuestPresetsSource import de.westnordost.streetcomplete.data.visiblequests.TeamModeQuestFilter import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.screens.main.controls.LocationState +import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition +import de.westnordost.streetcomplete.util.CrashReportExceptionHandler import de.westnordost.streetcomplete.util.ktx.launch -import kotlinx.coroutines.Dispatchers +import de.westnordost.streetcomplete.util.parseGeoUri +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -34,6 +49,9 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.withContext class MainViewModelImpl( + private val crashReportExceptionHandler: CrashReportExceptionHandler, + private val urlConfigController: UrlConfigController, + private val questPresetsSource: QuestPresetsSource, private val uploadController: UploadController, private val uploadProgressSource: UploadProgressSource, private val downloadController: DownloadController, @@ -43,12 +61,81 @@ class MainViewModelImpl( private val statisticsSource: StatisticsSource, private val internetConnectionState: InternetConnectionState, private val selectedOverlayController: SelectedOverlayController, + private val questTypeRegistry: QuestTypeRegistry, private val overlayRegistry: OverlayRegistry, private val messagesSource: MessagesSource, private val teamModeQuestFilter: TeamModeQuestFilter, + private val elementEditsSource: ElementEditsSource, + private val noteEditsSource: NoteEditsSource, private val prefs: Preferences, ) : MainViewModel() { + /* error handling */ + override val lastCrashReport = MutableStateFlow(null) + + override val lastDownloadError: StateFlow = callbackFlow { + val listener = object : DownloadProgressSource.Listener { + override fun onStarted() { trySend(null) } + override fun onError(e: Exception) { trySend(e) } + } + downloadProgressSource.addListener(listener) + awaitClose { downloadProgressSource.removeListener(listener) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val lastUploadError: StateFlow = callbackFlow { + val listener = object : UploadProgressSource.Listener { + override fun onStarted() { trySend(null) } + override fun onError(e: Exception) { trySend(e) } + } + uploadProgressSource.addListener(listener) + awaitClose { uploadProgressSource.removeListener(listener) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override suspend fun createErrorReport(error: Exception) = withContext(IO) { + crashReportExceptionHandler.createErrorReport(error) + } + + /* start parameters */ + override fun setUri(uri: String) { + launch { + urlConfig.value = parseShownUrlConfig(uri) + + val geo = parseGeoUri(uri) + if (geo != null) { + val zoom = if (geo.zoom == null || geo.zoom < 14) 18.0 else geo.zoom + val pos = LatLon(geo.latitude, geo.longitude) + + isFollowingPosition.value = false + isNavigationMode.value = false + geoUri.value = CameraPosition(pos, 0.0, 0.0, zoom) + } + } + } + + private suspend fun parseShownUrlConfig(uri: String): ShownUrlConfig? { + val config = urlConfigController.parse(uri) ?: return null + val alreadyExists = withContext(IO) { + config.presetName == null || questPresetsSource.getByName(config.presetName) != null + } + return ShownUrlConfig(urlConfig = config, alreadyExists = alreadyExists) + } + + override val urlConfig = MutableStateFlow(null) + + override fun applyUrlConfig(config: UrlConfig) { + launch(IO) { + urlConfigController.apply(config) + } + } + + override val geoUri = MutableStateFlow(null) + + /* intro */ + + override var hasShownTutorial: Boolean + get() = prefs.hasShownTutorial + set(value) { prefs.hasShownTutorial = value } + /* messages */ override val messagesCount: StateFlow = callbackFlow { @@ -58,11 +145,12 @@ class MainViewModelImpl( } messagesSource.addListener(listener) awaitClose { messagesSource.removeListener(listener) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, 0) + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) - override suspend fun popMessage(): Message? = withContext(Dispatchers.IO) { - messagesSource.popNextMessage() - } + override suspend fun popMessage(): Message? = + withContext(IO) { messagesSource.popNextMessage() } + + override val allQuestTypes: List get() = questTypeRegistry /* overlays */ @@ -77,13 +165,14 @@ class MainViewModelImpl( } selectedOverlayController.addListener(listener) awaitClose { selectedOverlayController.removeListener(listener) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, null) + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, null) - override val hasShownOverlaysTutorial: Boolean get() = - prefs.hasShownOverlaysTutorial + override var hasShownOverlaysTutorial: Boolean + get() = prefs.hasShownOverlaysTutorial + set(value) { prefs.hasShownOverlaysTutorial = value } override fun selectOverlay(overlay: Overlay?) { - launch(Dispatchers.IO) { + launch(IO) { selectedOverlayController.selectedOverlay = overlay } } @@ -91,18 +180,15 @@ class MainViewModelImpl( /* team mode */ override val isTeamMode = MutableStateFlow(teamModeQuestFilter.isEnabled) - - override val indexInTeam: Int - get() = teamModeQuestFilter.indexInTeam - override var teamModeChanged: Boolean = false + override val indexInTeam = MutableStateFlow(teamModeQuestFilter.indexInTeam) override fun enableTeamMode(teamSize: Int, indexInTeam: Int) { - launch(Dispatchers.IO) { teamModeQuestFilter.enableTeamMode(teamSize, indexInTeam) } + launch(IO) { teamModeQuestFilter.enableTeamMode(teamSize, indexInTeam) } } override fun disableTeamMode() { - launch(Dispatchers.IO) { teamModeQuestFilter.disableTeamMode() } + launch(IO) { teamModeQuestFilter.disableTeamMode() } } override fun download(bbox: BoundingBox) { @@ -113,6 +199,7 @@ class MainViewModelImpl( override fun onTeamModeChanged(enabled: Boolean) { teamModeChanged = true isTeamMode.value = enabled + indexInTeam.value = teamModeQuestFilter.indexInTeam } } @@ -122,7 +209,7 @@ class MainViewModelImpl( send(prefs.autosync == Autosync.ON) val listener = prefs.onAutosyncChanged { trySend(it == Autosync.ON) } awaitClose { listener.deactivate() } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, true) + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, true) override val unsyncedEditsCount: StateFlow = callbackFlow { var count = unsyncedChangesCountSource.getCount() @@ -133,7 +220,7 @@ class MainViewModelImpl( } unsyncedChangesCountSource.addListener(listener) awaitClose { unsyncedChangesCountSource.removeListener(listener) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, 0) + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) override val isUploading: StateFlow = callbackFlow { val listener = object : UploadProgressSource.Listener { @@ -144,7 +231,7 @@ class MainViewModelImpl( awaitClose { uploadProgressSource.removeListener(listener) } }.stateIn(viewModelScope, SharingStarted.Eagerly, uploadProgressSource.isUploadInProgress) - private val isDownloading: StateFlow = callbackFlow { + private val isDownloading: StateFlow = callbackFlow { val listener = object : DownloadProgressSource.Listener { override fun onStarted() { trySend(true) } override fun onFinished() { trySend(false) } @@ -173,9 +260,46 @@ class MainViewModelImpl( override val isConnected: Boolean get() = internetConnectionState.isConnected override fun upload() { - uploadController.upload(isUserInitiated = true) + if (isLoggedIn.value) { + uploadController.upload(isUserInitiated = true) + } else { + isRequestingLogin.value = true + } + } + + private val elementEditsListener = object : ElementEditsSource.Listener { + override fun onAddedEdit(edit: ElementEdit) { launch { ensureLoggedIn() } } + override fun onSyncedEdit(edit: ElementEdit) {} + override fun onDeletedEdits(edits: List) {} } + private val noteEditsListener = object : NoteEditsSource.Listener { + override fun onAddedEdit(edit: NoteEdit) { launch { ensureLoggedIn() } } + override fun onSyncedEdit(edit: NoteEdit) {} + override fun onDeletedEdits(edits: List) {} + } + + private suspend fun ensureLoggedIn() { + if ( + internetConnectionState.isConnected && + !userLoginSource.isLoggedIn && + prefs.autosync != Autosync.OFF && + // new users should not be immediately pestered to login after each change (#1446) + unsyncedChangesCountSource.getCount() >= 3 && + !alreadyRequestedLogin + ) { + isRequestingLogin.value = true + alreadyRequestedLogin = true + } + } + + override val isRequestingLogin = MutableStateFlow(false) + override fun finishRequestingLogin() { + isRequestingLogin.value = false + } + + private var alreadyRequestedLogin = false + /* stars */ private val editCount: Flow = callbackFlow { @@ -243,15 +367,31 @@ class MainViewModelImpl( val unsyncedEdits = if (isAutoSync) solvedEditsCount else 0 val syncedEdits = if (isShowingStarsCurrentWeek) editCountCurrentWeek else editCount syncedEdits + unsyncedEdits - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, 0) + }.stateIn(viewModelScope + IO, SharingStarted.Eagerly, 0) + + override val locationState = MutableStateFlow(LocationState.ENABLED) + override val mapCamera = MutableStateFlow(null) + override val displayedPosition = MutableStateFlow(null) + + override val isFollowingPosition = MutableStateFlow(false) + override val isNavigationMode = MutableStateFlow(false) + + override val isRecordingTracks = MutableStateFlow(false) // --------------------------------------------------------------------------------------- init { + launch(IO) { + lastCrashReport.value = crashReportExceptionHandler.popCrashReport() + } teamModeQuestFilter.addListener(teamModeListener) + elementEditsSource.addListener(elementEditsListener) + noteEditsSource.addListener(noteEditsListener) } override fun onCleared() { teamModeQuestFilter.removeListener(teamModeListener) + elementEditsSource.removeListener(elementEditsListener) + noteEditsSource.removeListener(noteEditsListener) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/PointerPinView.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/PointerPinView.kt deleted file mode 100644 index f42c05e51e..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/PointerPinView.kt +++ /dev/null @@ -1,165 +0,0 @@ -package de.westnordost.streetcomplete.screens.main - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Outline -import android.graphics.Paint -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.View -import android.view.View.MeasureSpec.getMode -import android.view.View.MeasureSpec.getSize -import android.view.ViewOutlineProvider -import androidx.core.content.withStyledAttributes -import androidx.core.graphics.applyCanvas -import androidx.core.graphics.createBitmap -import androidx.core.graphics.withRotation -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.util.ktx.dpToPx -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.min -import kotlin.math.sin - -/** A view for the pointer pin that ought to be displayed at the edge of the screen. - * Can be rotated with the pinRotation field. As opposed to normal rotation, it ensures that the - * pin icon always stays upright */ -class PointerPinView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { - - private val pointerPin: Drawable = context.getDrawable(R.drawable.quest_pin_pointer)!! - private var pointerPinBitmap: Bitmap? = null - private val antiAliasPaint: Paint = Paint().apply { - isAntiAlias = true - isFilterBitmap = true - } - - /** rotation of the pin in degrees. Similar to rotation, only that the pointy end of the pin - * is always located at the edge of the view */ - var pinRotation: Float = 0f - set(value) { - field = value - invalidate() - invalidateOutline() - } - - var pinIconDrawable: Drawable? = null - set(value) { - field = value - invalidate() - } - - fun setPinIconResource(resId: Int) { - pinIconDrawable = context.getDrawable(resId) - } - - init { - context.withStyledAttributes(attrs, R.styleable.PointerPinView) { - pinRotation = getFloat(R.styleable.PointerPinView_pinRotation, 0f) - val resId = getResourceId(R.styleable.PointerPinView_iconSrc, 0) - if (resId != 0) { - setPinIconResource(resId) - } - } - outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - val size = min(width, height) - val pinCircleSize = (size * (1 - PIN_CENTER_OFFSET_FRACTION * 2)).toInt() - val arrowOffset = size * PIN_CENTER_OFFSET_FRACTION - val a = pinRotation.toDouble().normalizeAngle().toRadians() - val x = (-sin(a) * arrowOffset).toInt() - val y = (+cos(a) * arrowOffset).toInt() - outline.setOval( - width / 2 - pinCircleSize / 2 + x, - height / 2 - pinCircleSize / 2 + y, - width / 2 + pinCircleSize / 2 + x, - height / 2 + pinCircleSize / 2 + y - ) - } - } - } - - override fun invalidateDrawable(drawable: Drawable) { - super.invalidateDrawable(drawable) - if (drawable == pinIconDrawable) { - invalidate() - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val desiredSize = resources.dpToPx(DEFAULT_SIZE).toInt() - val width = reconcileSize(desiredSize, widthMeasureSpec) - val height = reconcileSize(desiredSize, heightMeasureSpec) - setMeasuredDimension(width, height) - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - pointerPinBitmap?.recycle() - - pointerPinBitmap = if (w <= 0 || h <= 0) { - null - } else { - val size = min(width, height) - createBitmap(size, size, Bitmap.Config.ARGB_8888).applyCanvas { - pointerPin.setBounds(0, 0, size, size) - pointerPin.draw(this) - } - } - } - - override fun onDraw(canvas: Canvas) { - val size = min(width, height) - val r = pinRotation - - canvas.withRotation(r, width / 2f, height / 2f) { - pointerPinBitmap?.let { canvas.drawBitmap(it, 0f, 0f, antiAliasPaint) } - } - - val icon = pinIconDrawable - if (icon != null) { - val iconSize = (size * ICON_SIZE_FRACTION).toInt() - val arrowOffset = size * PIN_CENTER_OFFSET_FRACTION - val a = r.toDouble().normalizeAngle().toRadians() - val x = (-sin(a) * arrowOffset).toInt() - val y = (+cos(a) * arrowOffset).toInt() - icon.setBounds( - width / 2 - iconSize / 2 + x, - height / 2 - iconSize / 2 + y, - width / 2 + iconSize / 2 + x, - height / 2 + iconSize / 2 + y - ) - icon.draw(canvas) - } - } - - private fun reconcileSize(contentSize: Int, measureSpec: Int): Int { - val mode = getMode(measureSpec) - val size = getSize(measureSpec) - return when (mode) { - MeasureSpec.EXACTLY -> size - MeasureSpec.AT_MOST -> min(contentSize, size) - else -> contentSize - } - } - - companion object { - // half size of the sharp end of pin, depends on the pin drawable: using quest_pin_pointer - private const val PIN_CENTER_OFFSET_FRACTION = 14f / 124f - // size of the icon part of the pin, depends on the pin drawable: using quest_pin_pointer - private const val ICON_SIZE_FRACTION = 84f / 124f - // intrinsic/default size - private const val DEFAULT_SIZE = 64 // in dp - } -} - -private fun Double.toRadians(): Double = this / 180.0 * PI - -private fun Double.normalizeAngle(): Double { - var r = this % 360 // r is -360..360 - r = (r + 360) % 360 // r is 0..360 - return r -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/RequestLoginDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/RequestLoginDialog.kt new file mode 100644 index 0000000000..660fe747ba --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/RequestLoginDialog.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.screens.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.dialogs.ConfirmationDialog + +/** Shows a dialog that asks the user to login */ +@Composable +fun RequestLoginDialog( + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, +) { + ConfirmationDialog( + onDismissRequest = onDismissRequest, + onConfirmed = onConfirmed, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.confirmation_authorize_now)) + Text(stringResource(R.string.confirmation_authorize_now_note2)) + } + }, + cancelButtonText = stringResource(R.string.later) + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/CompassButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/CompassButton.kt new file mode 100644 index 0000000000..8d96a9394a --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/CompassButton.kt @@ -0,0 +1,47 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R + +/** Map button showing current compass orientation in relation to the map */ +@Composable +fun CompassButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + rotation: Float = 0f, + tilt: Float = 0f, +) { + MapButton( + onClick = onClick, + modifier = modifier, + contentPadding = 8.dp + ) { + Image( + painter = painterResource(R.drawable.ic_compass_needle_48dp), + contentDescription = null, + modifier = Modifier + .size(32.dp) + .graphicsLayer( + rotationZ = rotation, + rotationX = tilt + ) + ) + } +} + +@Preview +@Composable +private fun PreviewCompassButton() { + CompassButton( + onClick = {}, + rotation = 30f, + tilt = 0f + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/Crosshair.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/Crosshair.kt new file mode 100644 index 0000000000..636a8c0bfa --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/Crosshair.kt @@ -0,0 +1,49 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.theme.AppTheme +import de.westnordost.streetcomplete.ui.theme.getMaxQuestFormWidth +import de.westnordost.streetcomplete.ui.theme.getQuestFormPeekHeight + +/** A crosshair at the position at which a new POI should be created */ +@Composable +fun Crosshair(modifier: Modifier = Modifier) { + BoxWithConstraints(modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + val crosshairOffsetX = if (isLandscape) getMaxQuestFormWidth(maxWidth) else 0.dp + val crosshairOffsetY = if (isLandscape) 0.dp else getQuestFormPeekHeight(isLandscape) + + Icon( + painter = painterResource(R.drawable.crosshair), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .padding(start = crosshairOffsetX, bottom = crosshairOffsetY), + tint = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + ) + } +} + +@PreviewLightDark +@Composable +private fun PreviewCrosshair() { + AppTheme { + Surface { + Crosshair() + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/IntersectionUtil.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/IntersectionUtil.kt new file mode 100644 index 0000000000..596b37b021 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/IntersectionUtil.kt @@ -0,0 +1,107 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.PI +import kotlin.math.atan2 + +data class Intersection(val position: Offset, val angle: Double) + +/** Given an imaginary line drawn from [origin] to [target], returns the point and angle at which + * the line intersects with the bounds closest to [origin] of the [rects]. It returns null if there + * is no intersection */ +fun findClosestIntersection( + origin: Offset?, + target: Offset?, + rects: Iterable, +): Intersection? { + val o = origin ?: return null + val t = target ?: return null + + var minA = Float.MAX_VALUE + + for (rect in rects) { + val a = intersectionWithRect(rect, o, t) + if (a < minA) minA = a + } + if (minA > 1f) return null + + return Intersection( + position = Offset(o.x + (t.x - o.x) * minA, o.y + (t.y - o.y) * minA), + angle = atan2(t.y - o.y, t.x - o.x) + PI / 2 + ) +} + +/** First intersection of line drawn from [o] to [t] with rect [r] or Float.MAX_VALUE if none */ +private fun intersectionWithRect(r: Rect, o: Offset, t: Offset): Float { + var minA = Float.MAX_VALUE + var a: Float + + // left side + a = intersectionWithVerticalSegment(o.x, o.y, t.x, t.y, r.left, r.top, r.height) + if (a < minA) minA = a + // right side + a = intersectionWithVerticalSegment(o.x, o.y, t.x, t.y, r.right, r.top, r.height) + if (a < minA) minA = a + // top side + a = intersectionWithHorizontalSegment(o.x, o.y, t.x, t.y, r.left, r.top, r.width) + if (a < minA) minA = a + // bottom side + a = intersectionWithHorizontalSegment(o.x, o.y, t.x, t.y, r.left, r.bottom, r.width) + if (a < minA) minA = a + + return minA +} + +/** Intersection of line segment going from P to Q with vertical line starting at V and given + * length. Returns the f for P+f*(Q-P) or MAX_VALUE if no intersection found. */ +private fun intersectionWithVerticalSegment( + px: Float, + py: Float, + qx: Float, + qy: Float, + vx: Float, + vy: Float, + length: Float +): Float { + val dx = qx - px + if (dx == 0f) return Float.MAX_VALUE + val a = (vx - px) / dx + + // not in range of line segment A + if (a < 0f || a > 1f) return Float.MAX_VALUE + + val dy = qy - py + val posY = py + dy * a + + // not in range of horizontal line segment + if (posY < vy || posY > vy + length) return Float.MAX_VALUE + + return a +} + +/** Intersection of line segment going from P to Q with horizontal line starting at H and given + * length. Returns the f for P+f*(Q-P) or MAX_VALUE if no intersection found. */ +private fun intersectionWithHorizontalSegment( + px: Float, + py: Float, + qx: Float, + qy: Float, + hx: Float, + hy: Float, + length: Float +): Float { + val dy = qy - py + if (dy == 0f) return Float.MAX_VALUE + val a = (hy - py) / dy + + // not in range of line segment P-Q + if (a < 0f || a > 1f) return Float.MAX_VALUE + + val dx = qx - px + val posX = px + dx * a + + // not in range of horizontal line segment + if (posX < hx || posX > hx + length) return Float.MAX_VALUE + return a +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt deleted file mode 100644 index 8fda57f345..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.controls - -/** State of location updates */ -enum class LocationState { - /** user declined to give this app access to location */ - DENIED, - /** user allowed this app to access location (but location disabled) */ - ALLOWED, - /** location service is turned on (but no location request active) */ - ENABLED, - /** requested location updates and waiting for first fix */ - SEARCHING, - /** receiving location updates */ - UPDATING; - - val isEnabled: Boolean get() = ordinal >= ENABLED.ordinal -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt index 0360b96b69..9fe53e2d62 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton.kt @@ -1,120 +1,99 @@ package de.westnordost.streetcomplete.screens.main.controls -import android.content.Context -import android.content.res.ColorStateList -import android.content.res.TypedArray -import android.graphics.drawable.Animatable -import android.os.Bundle -import android.os.Parcelable -import android.util.AttributeSet -import android.view.View -import androidx.appcompat.widget.AppCompatImageButton -import androidx.core.os.bundleOf +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.util.ktx.parcelable -import de.westnordost.streetcomplete.util.ktx.serializable - -/** - * An image button which shows the current location state - */ -class LocationStateButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : AppCompatImageButton(context, attrs, defStyle) { - - var state: LocationState - get() = _state ?: LocationState.DENIED - set(value) { _state = value } - - // this is necessary because state is accessed before it is initialized (in constructor of super) - private var _state: LocationState? = null - set(value) { - if (field != value) { - field = value - refreshDrawableState() - } - } +import kotlinx.coroutines.delay + +/** State of location updates */ +enum class LocationState { + /** user declined to give this app access to location */ + DENIED, + /** user allowed this app to access location (but location disabled) */ + ALLOWED, + /** location service is turned on (but no location request active) */ + ENABLED, + /** requested location updates and waiting for first fix */ + SEARCHING, + /** receiving location updates */ + UPDATING; + + val isEnabled: Boolean get() = ordinal >= ENABLED.ordinal +} - private val tint: ColorStateList? - var isNavigation: Boolean = false - set(value) { - if (field != value) { - field = value - refreshDrawableState() +/** Map button that shows the current state of location updates and map mode */ +@Composable +fun LocationStateButton( + onClick: () -> Unit, + state: LocationState, + modifier: Modifier = Modifier, + isNavigationMode: Boolean = false, + isFollowing: Boolean = false, + enabled: Boolean = true +) { + var iconResource by remember(state) { mutableStateOf(getIcon(state, isNavigationMode)) } + + MapButton( + onClick = onClick, + modifier = modifier, + enabled = enabled + ) { + LaunchedEffect(state) { + if (state == LocationState.SEARCHING) { + while (true) { + delay(750) + iconResource = getIcon(LocationState.UPDATING, isNavigationMode) + delay(750) + iconResource = getIcon(LocationState.ENABLED, isNavigationMode) + } } } - - init { - val a = context.obtainStyledAttributes(attrs, R.styleable.LocationStateButton) - state = determineStateFrom(a) - tint = a.getColorStateList(R.styleable.LocationStateButton_tint) - isNavigation = a.getBoolean(R.styleable.LocationStateButton_is_navigation, false) - - a.recycle() - } - - private fun determineStateFrom(a: TypedArray): LocationState = when { - a.getBoolean(R.styleable.LocationStateButton_state_updating, false) -> LocationState.UPDATING - a.getBoolean(R.styleable.LocationStateButton_state_searching, false) -> LocationState.SEARCHING - a.getBoolean(R.styleable.LocationStateButton_state_enabled, false) -> LocationState.ENABLED - a.getBoolean(R.styleable.LocationStateButton_state_allowed, false) -> LocationState.ALLOWED - else -> LocationState.DENIED - } - - override fun drawableStateChanged() { - super.drawableStateChanged() - // autostart - val current = drawable.current - if (current is Animatable) { - if (!current.isRunning) current.start() - } - if (tint != null && tint.isStateful) { - setColorFilter(tint.getColorForState(drawableState, 0)) - } - } - - override fun onCreateDrawableState(extraSpace: Int): IntArray { - val attributes = ArrayList() - attributes += state.styleableAttributes - if (isNavigation) attributes += R.attr.is_navigation - - val drawableState = super.onCreateDrawableState(extraSpace + attributes.size) - - View.mergeDrawableStates(drawableState, attributes.toIntArray()) - return drawableState + Icon( + painter = painterResource(iconResource), + contentDescription = stringResource(R.string.map_btn_gps_tracking), + tint = if (isFollowing) MaterialTheme.colors.secondary else LocalContentColor.current + ) } +} - override fun onSaveInstanceState() = bundleOf( - KEY_SUPER_STATE to super.onSaveInstanceState(), - KEY_STATE to state, - KEY_IS_ACTIVATED to isActivated, - KEY_IS_NAVIGATION to isNavigation, - ) +private fun getIcon(state: LocationState, isNavigationMode: Boolean) = when (state) { + LocationState.DENIED, + LocationState.ALLOWED -> + R.drawable.ic_location_disabled_24dp + LocationState.ENABLED, + LocationState.SEARCHING -> + if (isNavigationMode) R.drawable.ic_location_navigation_no_location_24dp + else R.drawable.ic_location_no_location_24dp + LocationState.UPDATING -> + if (isNavigationMode) R.drawable.ic_location_navigation_24dp + else R.drawable.ic_location_24dp +} - override fun onRestoreInstanceState(state: Parcelable?) { - if (state is Bundle) { - super.onRestoreInstanceState(state.parcelable(KEY_SUPER_STATE)) - this.state = state.serializable(KEY_STATE)!! - isActivated = state.getBoolean(KEY_IS_ACTIVATED) - isNavigation = state.getBoolean(KEY_IS_NAVIGATION) - requestLayout() +@Preview +@Composable +private fun PreviewLocationButton() { + Column { + for (state in LocationState.entries) { + Row { + LocationStateButton(onClick = {}, state = state) + LocationStateButton(onClick = {}, state = state, isNavigationMode = true) + LocationStateButton(onClick = {}, state = state, isFollowing = true) + LocationStateButton(onClick = {}, state = state, isNavigationMode = true, isFollowing = true) + } } } - - private val LocationState.styleableAttributes: List get() = - listOf( - R.attr.state_allowed, - R.attr.state_enabled, - R.attr.state_searching, - R.attr.state_updating - ).subList(0, ordinal) - - companion object { - private const val KEY_SUPER_STATE = "superState" - private const val KEY_STATE = "state" - private const val KEY_IS_ACTIVATED = "isActivated" - private const val KEY_IS_NAVIGATION = "isNavigation" - } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt deleted file mode 100644 index 063ca8202a..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/LocationStateButton2.kt +++ /dev/null @@ -1,81 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.controls - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import de.westnordost.streetcomplete.R -import kotlinx.coroutines.delay - -@Composable -fun LocationStateButton( - onClick: () -> Unit, - state: LocationState, - modifier: Modifier = Modifier, - isNavigationMode: Boolean = false, - isFollowing: Boolean = false, - enabled: Boolean = true -) { - var iconResource by remember(state) { mutableStateOf(getIcon(state, isNavigationMode)) } - - MapButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - LaunchedEffect(state) { - if (state == LocationState.SEARCHING) { - while (true) { - delay(750) - iconResource = getIcon(LocationState.UPDATING, isNavigationMode) - delay(750) - iconResource = getIcon(LocationState.ENABLED, isNavigationMode) - } - } - } - Icon( - painter = painterResource(iconResource), - contentDescription = stringResource(R.string.map_btn_gps_tracking), - tint = if (isFollowing) MaterialTheme.colors.secondary else Color.Black - ) - } -} - -private fun getIcon(state: LocationState, isNavigationMode: Boolean) = when (state) { - LocationState.DENIED, - LocationState.ALLOWED -> - R.drawable.ic_location_disabled_24dp - LocationState.ENABLED, - LocationState.SEARCHING -> - if (isNavigationMode) R.drawable.ic_location_navigation_no_location_24dp - else R.drawable.ic_location_no_location_24dp - LocationState.UPDATING -> - if (isNavigationMode) R.drawable.ic_location_navigation_24dp - else R.drawable.ic_location_24dp -} - -@Preview -@Composable -private fun PreviewLocationButton() { - Column { - for (state in LocationState.entries) { - Row { - LocationStateButton(onClick = {}, state = state) - LocationStateButton(onClick = {}, state = state, isNavigationMode = true) - LocationStateButton(onClick = {}, state = state, isFollowing = true) - LocationStateButton(onClick = {}, state = state, isNavigationMode = true, isFollowing = true) - } - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuButton.kt new file mode 100644 index 0000000000..2437866d54 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuButton.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.screens.main.teammode.TeamModeColorCircle +import de.westnordost.streetcomplete.ui.common.MenuIcon + +@Composable +fun MainMenuButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + indexInTeam: Int? = null, +) { + Box(modifier) { + MapButton(onClick = onClick) { MenuIcon() } + if (indexInTeam != null) { + TeamModeColorCircle( + index = indexInTeam, + modifier = Modifier + .align(Alignment.TopEnd) + .size(22.dp) + ) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuDialog.kt deleted file mode 100644 index 6803dc19d1..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MainMenuDialog.kt +++ /dev/null @@ -1,66 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.controls - -import android.content.Context -import android.content.Intent -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import androidx.core.view.doOnPreDraw -import androidx.core.view.isGone -import de.westnordost.streetcomplete.databinding.DialogMainMenuBinding -import de.westnordost.streetcomplete.screens.about.AboutActivity -import de.westnordost.streetcomplete.screens.main.teammode.TeamModeDialog -import de.westnordost.streetcomplete.screens.settings.SettingsActivity -import de.westnordost.streetcomplete.screens.user.UserActivity - -/** Shows a dialog containing the main menu items */ -class MainMenuDialog( - context: Context, - indexInTeam: Int?, - onClickDownload: () -> Unit, - onEnableTeamMode: (Int, Int) -> Unit, - onDisableTeamMode: () -> Unit -) : AlertDialog(context) { - init { - val binding = DialogMainMenuBinding.inflate(LayoutInflater.from(context)) - - binding.profileButton.setOnClickListener { - val intent = Intent(context, UserActivity::class.java) - context.startActivity(intent) - dismiss() - } - binding.enableTeamModeButton.setOnClickListener { - TeamModeDialog(context, onEnableTeamMode).show() - dismiss() - } - binding.disableTeamModeButton.setOnClickListener { - onDisableTeamMode() - dismiss() - } - binding.settingsButton.setOnClickListener { - val intent = Intent(context, SettingsActivity::class.java) - context.startActivity(intent) - dismiss() - } - binding.aboutButton.setOnClickListener { - val intent = Intent(context, AboutActivity::class.java) - context.startActivity(intent) - dismiss() - } - binding.downloadButton.setOnClickListener { - onClickDownload() - dismiss() - } - - if (indexInTeam != null) { - binding.teamModeColorCircle.setIndexInTeam(indexInTeam) - } - binding.enableTeamModeButton.isGone = indexInTeam != null - binding.disableTeamModeButton.isGone = indexInTeam == null - - binding.root.doOnPreDraw { - binding.bigMenuItemsContainer.columnCount = binding.root.width / binding.profileButton.width - } - - setView(binding.root) - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapAttribution.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapAttribution.kt new file mode 100644 index 0000000000..88b2e48775 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapAttribution.kt @@ -0,0 +1,58 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.LocalElevationOverlay +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.TextWithHalo +import de.westnordost.streetcomplete.ui.common.dialogs.ConfirmationDialog + +/** Shows (hardcoded) map attribution and opens links on click */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MapAttribution(modifier: Modifier = Modifier) { + var shownLink by remember { mutableStateOf(null) } + + ProvideTextStyle(MaterialTheme.typography.caption) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextWithHalo( + text = stringResource(R.string.map_attribution_osm), + modifier = Modifier.clickable { shownLink ="https://www.openstreetmap.org/copyright" }, + elevation = 4.dp + ) + TextWithHalo( + text = "© JawgMaps", + modifier = Modifier.clickable { shownLink = "https://www.jawg.io" }, + elevation = 4.dp + ) + } + } + + shownLink?.let { url -> + val uriHandler = LocalUriHandler.current + ConfirmationDialog( + onDismissRequest = { shownLink = null }, + onConfirmed = { uriHandler.openUri(url) }, + title = { Text(stringResource(R.string.open_url)) }, + text = { Text(url) } + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt index bce6cebefc..958ac46f52 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButton.kt @@ -4,34 +4,43 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.westnordost.streetcomplete.R +/** Small floating button on top of the map */ @OptIn(ExperimentalMaterialApi::class) @Composable fun MapButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - content: @Composable() (BoxScope.() -> Unit) + colors: ButtonColors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface, + ), + contentPadding: Dp = 12.dp, + content: @Composable (BoxScope.() -> Unit), ) { Surface( onClick = onClick, modifier = modifier, enabled = enabled, shape = CircleShape, - color = Color.White, + color = colors.backgroundColor(enabled).value, + contentColor = colors.contentColor(enabled).value, elevation = 4.dp ) { - Box(Modifier.padding(16.dp), content = content) + Box(modifier = Modifier.padding(contentPadding), content = content) } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButtonNotification.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButtonNotification.kt new file mode 100644 index 0000000000..d9ed39a78c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MapButtonNotification.kt @@ -0,0 +1,55 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R + +/** Notification shown on the top end corner of e.g. a button */ +@Composable +fun BoxScope.MapButtonNotification( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + CompositionLocalProvider( + LocalContentColor provides Color.White, + LocalTextStyle provides MaterialTheme.typography.caption + ) { + Box( + modifier = modifier + .align(Alignment.TopEnd) + .background(MaterialTheme.colors.secondaryVariant, RoundedCornerShape(12.dp)) + .padding(vertical = 2.dp, horizontal = 6.dp), + contentAlignment = Alignment.Center, + content = content + ) + } +} + +@Preview +@Composable +private fun PreviewMapButtonWithNotification() { + Box { + MapButton(onClick = {}) { + Icon(painterResource(R.drawable.ic_email_24dp), null) + } + MapButtonNotification { + Text(text = "999") + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MessagesButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MessagesButton.kt index 8242ad3d06..a446dbba91 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MessagesButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/MessagesButton.kt @@ -1,29 +1,22 @@ package de.westnordost.streetcomplete.screens.main.controls -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.RelativeLayout -import androidx.core.view.isInvisible -import de.westnordost.streetcomplete.databinding.ViewMessagesButtonBinding +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import de.westnordost.streetcomplete.ui.common.MessagesIcon -/** View that shows a messages-button with a little counter at the top right */ -class MessagesButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : RelativeLayout(context, attrs, defStyleAttr) { - - private val binding = ViewMessagesButtonBinding.inflate(LayoutInflater.from(context), this) - - var messagesCount: Int = 0 - set(value) { - field = value - binding.textView.text = value.toString() - binding.textView.isInvisible = value == 0 +@Composable +fun MessagesButton( + onClick: () -> Unit, + messagesCount: Int, + modifier: Modifier = Modifier, +) { + Box(modifier) { + MapButton(onClick = onClick) { MessagesIcon() } + MapButtonNotification { + Text(messagesCount.toString(), textAlign = TextAlign.Center) } - - init { - clipToPadding = false } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt new file mode 100644 index 0000000000..b40a901070 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/OverlaySelectionButton.kt @@ -0,0 +1,35 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.ui.common.OverlaysIcon + +/** Overlay selection button that shows the icon of the currently selected overlay */ +@Composable +fun OverlaySelectionButton( + onClick: () -> Unit, + overlay: Overlay?, + modifier: Modifier = Modifier +) { + val icon = overlay?.icon + MapButton( + onClick = onClick, + modifier = modifier, + contentPadding = if (icon != null) 6.dp else 12.dp + ) { + if (icon != null) { + Image( + painter = painterResource(icon), + contentDescription = overlay.name, + modifier = Modifier.size(36.dp) + ) + } else { + OverlaysIcon() + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/PointerPinButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/PointerPinButton.kt new file mode 100644 index 0000000000..c9eb8beb77 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/PointerPinButton.kt @@ -0,0 +1,116 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.toPath +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.ktx.proportionalAbsoluteOffset +import de.westnordost.streetcomplete.ui.ktx.proportionalPadding +import de.westnordost.streetcomplete.ui.util.svgPath +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** A view for the pointer pin that ought to be displayed at the edge of the screen. The upper left + * corner is always the the position at which it is pointing to, i.e. it will be drawn outside of + * its bounds when pointing to the right. + * [rotate] rotates the pin. As opposed to normal rotation, the content always stays upright */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PointerPinButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface, + ), + contentPadding: Dp = 12.dp, + rotate: Float = 0f, + content: @Composable (BoxScope.() -> Unit), +) { + val pointerPinShape = remember(rotate) { PointerPinShape(rotate) } + val a = rotate * PI / 180f + Surface( + onClick = onClick, + modifier = modifier + .proportionalAbsoluteOffset( + x = (-sin(a) / 2.0 - 0.5).toFloat(), + y = (cos(a) / 2.0 - 0.5).toFloat(), + ), + enabled = enabled, + shape = pointerPinShape, + color = colors.backgroundColor(enabled).value, + contentColor = colors.contentColor(enabled).value, + elevation = 4.dp + ) { + Box(Modifier + .proportionalPadding(pointySize) + .padding(contentPadding) + ) { content() } + } +} + +private class PointerPinShape(val rotation: Float = 0f) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val m = Matrix() + val halfWidth = size.width / 2 + val halfHeight = size.height / 2 + m.translate(halfWidth, halfHeight) + m.rotateZ(rotation) + m.translate(-halfWidth, -halfHeight) + m.scale( + x = size.width / pathSize, + y = size.height / pathSize + ) + val p = path.toPath() + p.transform(m) + return Outline.Generic(p) + } +} + +private const val pathSize = 76f +private val path = svgPath("M 38,62 C 24.745,62 14,51.255 14,38 14.003,32.6405 15.7995,27.4365 19.1035,23.217 L 38,0 56.914,23.2715 C 60.2005,27.4785 61.99,32.6615 62,38 62,51.255 51.255,62 38,62 Z") +private const val pointySize = 14f / 76f + +@Preview +@Composable +private fun PreviewPointerPinButton() { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + 0f, 360f, + infiniteRepeatable(tween(12000, 0, LinearEasing)), + ) + PointerPinButton(onClick = {}, rotate = rotation) { + Image(painterResource(R.drawable.location_dot_small), null) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt new file mode 100644 index 0000000000..d441360785 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounter.kt @@ -0,0 +1,100 @@ +package de.westnordost.streetcomplete.screens.main.controls + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.LocalElevationOverlay +import androidx.compose.material.MaterialTheme +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.CounterWithHalo +import de.westnordost.streetcomplete.ui.common.TextWithHalo +import de.westnordost.streetcomplete.ui.theme.titleLarge +import de.westnordost.streetcomplete.ui.theme.titleSmall + +/** View that displays the user's quest answer counter */ +@Composable +fun StarsCounter( + count: Int, + modifier: Modifier = Modifier, + isCurrentWeek: Boolean = false, + showProgress: Boolean = false, +) { + val surfaceColor = MaterialTheme.colors.surface + val haloColor = LocalElevationOverlay.current?.apply(surfaceColor, 4.dp) ?: surfaceColor + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(4.dp) + ) { + if (showProgress) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colors.secondary + ) + } + Icon( + painter = painterResource(R.drawable.ic_star_halo_32dp), + contentDescription = null, + tint = haloColor + ) + Icon( + painter = painterResource(R.drawable.ic_star_32dp), + contentDescription = null, + tint = contentColorFor(surfaceColor) + ) + } + + if (isCurrentWeek) { + Column { + TextWithHalo( + text = stringResource(R.string.user_profile_current_week_title), + maxLines = 1, + haloWidth = 3.dp, + elevation = 4.dp, + style = MaterialTheme.typography.titleSmall + ) + CounterWithHalo( + count = count, + haloWidth = 3.dp, + elevation = 4.dp, + style = MaterialTheme.typography.titleLarge, + ) + } + } else { + CounterWithHalo( + count = count, + haloWidth = 3.dp, + elevation = 4.dp, + style = MaterialTheme.typography.titleLarge, + ) + } + } +} + +@Preview +@Composable +private fun PreviewStarsCounter() { + StarsCounter( + count = 123, + isCurrentWeek = true, + showProgress = true + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounterView.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounterView.kt deleted file mode 100644 index 2928592302..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/StarsCounterView.kt +++ /dev/null @@ -1,56 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.controls - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.DecelerateInterpolator -import android.widget.RelativeLayout -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import de.westnordost.streetcomplete.databinding.ViewAnswersCounterBinding - -/** View that displays the user's quest answer counter */ -class StarsCounterView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : RelativeLayout(context, attrs, defStyleAttr) { - - private val binding = ViewAnswersCounterBinding.inflate(LayoutInflater.from(context), this) - - var starsCount: Int = 0 - set(value) { - field = value - binding.textView.text = value.toString() - } - - var showProgress: Boolean = false - set(value) { - field = value - binding.progressView.isInvisible = !value - } - - var showLabel: Boolean - set(value) { binding.labelView.isGone = !value } - get() = binding.labelView.isGone - - fun setUploadedCount(uploadedCount: Int, animate: Boolean) { - if (this.starsCount < uploadedCount && animate) { - animateChange() - } - this.starsCount = uploadedCount - } - - private fun animateChange() { - binding.textView.animate() - .scaleX(1.6f).scaleY(1.6f) - .setInterpolator(DecelerateInterpolator(2f)) - .setDuration(100) - .withEndAction { - binding.textView.animate() - .scaleX(1f).scaleY(1f) - .setInterpolator(AccelerateDecelerateInterpolator()).duration = 100 - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/UploadButton.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/UploadButton.kt index ff15dfd023..2a63ef842b 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/UploadButton.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/UploadButton.kt @@ -1,35 +1,25 @@ package de.westnordost.streetcomplete.screens.main.controls -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.RelativeLayout -import androidx.core.view.isInvisible -import de.westnordost.streetcomplete.databinding.ViewUploadButtonBinding +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import de.westnordost.streetcomplete.ui.common.UploadIcon -/** A view that shows an upload-icon, with a counter at the top right and an (upload) progress view - */ -class UploadButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : RelativeLayout(context, attrs, defStyleAttr) { - - private val binding = ViewUploadButtonBinding.inflate(LayoutInflater.from(context), this) - - var uploadableCount: Int = 0 - set(value) { - field = value - binding.textView.text = value.toString() - binding.textView.isInvisible = value == 0 +@Composable +fun UploadButton( + onClick: () -> Unit, + unsyncedEditsCount: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Box(modifier) { + MapButton(onClick = onClick, enabled = enabled) { UploadIcon() } + if(unsyncedEditsCount > 0) { + MapButtonNotification { + Text(unsyncedEditsCount.toString(), textAlign = TextAlign.Center) + } } - - override fun setEnabled(enabled: Boolean) { - super.setEnabled(enabled) - binding.iconView.alpha = if (enabled) 1f else 0.5f - } - - init { - clipToPadding = false } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt similarity index 57% rename from app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt rename to app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt index 85c5c6e42e..b70faa6964 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditItem.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt @@ -1,6 +1,10 @@ -package de.westnordost.streetcomplete.data.edithistory +package de.westnordost.streetcomplete.screens.main.edithistory +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.edithistory.Edit import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction @@ -10,6 +14,8 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden +import de.westnordost.streetcomplete.data.quest.QuestType +import de.westnordost.streetcomplete.quests.getTitle val Edit.icon: Int get() = when (this) { is ElementEdit -> type.icon @@ -37,3 +43,28 @@ val Edit.overlayIcon: Int get() = when (this) { is OsmQuestHidden -> R.drawable.ic_undo_visibility else -> 0 } + +@Composable +@ReadOnlyComposable +fun Edit.getTitle(elementTags: Map?): String = when (this) { + is ElementEdit -> { + if (type is QuestType) { + stringResource(type.getTitle(elementTags.orEmpty())) + } else { + stringResource(type.title) + } + } + is NoteEdit -> { + stringResource(when (action) { + CREATE -> R.string.created_note_action_title + COMMENT -> R.string.commented_note_action_title + }) + } + is OsmQuestHidden -> { + stringResource(questType.getTitle(elementTags.orEmpty())) + } + is OsmNoteQuestHidden -> { + stringResource(R.string.quest_noteDiscussion_title) + } + else -> throw IllegalArgumentException() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt new file mode 100644 index 0000000000..d885bdea2c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt @@ -0,0 +1,144 @@ +package de.westnordost.streetcomplete.screens.main.edithistory + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.osm.edits.ElementEdit +import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction +import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeFromVertexAction +import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction +import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction +import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete +import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify +import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden +import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit +import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden +import de.westnordost.streetcomplete.ui.common.HtmlText +import de.westnordost.streetcomplete.util.html.replaceHtmlEntities + +/** Shows what an edit changed. */ +@Composable +fun EditDescription( + edit: Edit, + modifier: Modifier = Modifier, +) { + when (edit) { + is ElementEdit -> { + when (edit.action) { + is UpdateElementTagsAction -> + TagUpdatesList(edit.action.changes.changes, modifier) + is DeletePoiNodeAction -> + Text(stringResource(R.string.deleted_poi_action_description), modifier) + is SplitWayAction -> + Text(stringResource(R.string.split_way_action_description), modifier) + is CreateNodeAction -> + Column(modifier) { + Text(stringResource(R.string.create_node_action_description)) + TagList(edit.action.tags) + } + is CreateNodeFromVertexAction -> + TagUpdatesList(edit.action.changes.changes, modifier) + is MoveNodeAction -> + Text(stringResource(R.string.move_node_action_description), modifier) + } + } + is NoteEdit -> + Text(edit.text.orEmpty(), modifier) + is OsmQuestHidden -> + Text(stringResource(R.string.hid_action_description), modifier) + is OsmNoteQuestHidden -> + Text(stringResource(R.string.hid_action_description), modifier) + } +} + +/** Shows a list of OSM tags in a bullet list */ +@Composable +private fun TagList( + tags: Map, + modifier: Modifier = Modifier +) { + HtmlText( + html = tags.toHtml(), + modifier = modifier, + ) +} + +/** Shows a list of changes to OSM tags in a bullet list */ +@Composable +private fun TagUpdatesList( + changes: Collection, + modifier: Modifier = Modifier +) { + HtmlText( + html = changes.toHtml(), + modifier = modifier, + ) +} + +@Composable +@ReadOnlyComposable +private fun Map.toHtml(): String { + val result = StringBuilder() + result.append("
    ") + for ((key, value) in this) { + result.append("
  • ") + result.append(linkedKey(key.replaceHtmlEntities())) + result.append(" = ") + result.append(value.replaceHtmlEntities()) + result.append("
  • ") + } + result.append("
") + return result.toString() +} + +@Composable +@ReadOnlyComposable +private fun Collection.toHtml(): String { + val result = StringBuilder() + result.append("
    ") + for (change in this) { + result.append("
  • ") + result.append(change.toHtml()) + result.append("
  • ") + } + result.append("
") + return result.toString() +} + +@Composable +@ReadOnlyComposable +private fun StringMapEntryChange.toHtml(): String { + val k = key.replaceHtmlEntities() + val v = when (this) { + is StringMapEntryAdd -> value + is StringMapEntryModify -> value + is StringMapEntryDelete -> valueBefore + }.replaceHtmlEntities() + + val tag = when (this) { + is StringMapEntryAdd -> linkedKey(k) + " = $v" + is StringMapEntryDelete -> "$k = $v" + is StringMapEntryModify -> linkedKey(k) + " = $v" + } + return stringResource(titleResId, "$tag") +} + +private fun linkedKey(key: String): String { + return "$key" +} + +private val StringMapEntryChange.titleResId: Int get() = when (this) { + is StringMapEntryAdd -> R.string.added_tag_action_title + is StringMapEntryModify -> R.string.changed_tag_action_title + is StringMapEntryDelete -> R.string.removed_tag_action_title +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDetails.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDetails.kt new file mode 100644 index 0000000000..5d39e296f2 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditDetails.kt @@ -0,0 +1,83 @@ +package de.westnordost.streetcomplete.screens.main.edithistory + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.ui.util.toAnnotatedString +import de.westnordost.streetcomplete.util.getNameAndLocationHtml +import de.westnordost.streetcomplete.util.html.parseHtml +import java.text.DateFormat + +/** Shows details for an edit. I.e. image, title, name and location of edited element (if any), + * edit description */ +@Composable +fun EditDetails( + edit: Edit, + element: Element?, + featureDictionaryLazy: Lazy, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + EditImage( + edit = edit, + modifier = Modifier.size(64.dp) + ) + Column { + Text( + text = DateFormat + .getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(edit.createdTimestamp), + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + ) + Text( + text = edit.getTitle(element?.tags), + style = MaterialTheme.typography.body1, + color = LocalContentColor.current.copy(alpha = ContentAlpha.high), + ) + if (element != null) { + val nameAndLocation = remember(element, context.resources) { + getNameAndLocationHtml(element, context.resources, featureDictionaryLazy.value) + ?.let { parseHtml(it) } + } + if (nameAndLocation != null) { + Text( + text = nameAndLocation.toAnnotatedString(), + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + ) + } + } + } + } + Divider() + SelectionContainer { + EditDescription(edit) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryAdapter.kt deleted file mode 100644 index 87bcbed3ad..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryAdapter.kt +++ /dev/null @@ -1,97 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.edithistory - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.edithistory.icon -import de.westnordost.streetcomplete.data.edithistory.overlayIcon -import de.westnordost.streetcomplete.databinding.RowEditItemBinding -import java.text.DateFormat - -/** Adapter to show the edit history in a list */ -class EditHistoryAdapter( - private val onClick: (EditItem) -> Unit -) : RecyclerView.Adapter() { - - var edits: List = listOf() - set(value) { - val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize() = field.size - override fun getNewListSize() = value.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - field[oldItemPosition].edit.key == value[newItemPosition].edit.key - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - field[oldItemPosition] == value[newItemPosition] - }) - field = value.toList() - diff.dispatchUpdatesTo(this) - - val newSelectedIndex = value.indexOfFirst { it.isSelected } - if (newSelectedIndex != -1) recyclerView?.scrollToPosition(newSelectedIndex) - } - - private var recyclerView: RecyclerView? = null - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - super.onAttachedToRecyclerView(recyclerView) - this.recyclerView = recyclerView - } - - override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { - super.onDetachedFromRecyclerView(recyclerView) - this.recyclerView = null - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - EditViewHolder(RowEditItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - - override fun onBindViewHolder(holder: EditViewHolder, position: Int) { - holder.onBind(edits[position]) - } - - override fun getItemCount() = edits.size - - inner class EditViewHolder( - private val binding: RowEditItemBinding - ) : RecyclerView.ViewHolder(binding.root) { - - fun onBind(item: EditItem) { - binding.undoButtonIcon.isEnabled = item.edit.isUndoable - binding.undoButtonIcon.isInvisible = !item.isSelected - binding.selectionRing.isInvisible = !item.isSelected - - if (item.edit.icon != 0) { - binding.questIcon.setImageResource(item.edit.icon) - } else { - binding.questIcon.setImageDrawable(null) - } - - if (item.edit.overlayIcon != 0) { - binding.overlayIcon.setImageResource(item.edit.overlayIcon) - } else { - binding.overlayIcon.setImageDrawable(null) - } - - binding.timeDateContainer.isGone = !item.showDate && !item.showTime - - binding.dateText.isGone = !item.showDate - binding.dateText.text = DateFormat.getDateInstance(DateFormat.SHORT).format(item.edit.createdTimestamp) - - binding.timeText.isGone = !item.showTime - binding.timeText.text = DateFormat.getTimeInstance(DateFormat.SHORT).format(item.edit.createdTimestamp) - - itemView.setBackgroundColor( - itemView.context.resources.getColor( - if (item.edit.isSynced == true) R.color.background_disabled else R.color.background - ) - ) - - binding.clickArea.isSelected = item.isSelected - binding.clickArea.setOnClickListener { onClick(item) } - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryFragment.kt deleted file mode 100644 index 695896d32e..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryFragment.kt +++ /dev/null @@ -1,63 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.edithistory - -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.databinding.FragmentEditHistoryListBinding -import de.westnordost.streetcomplete.util.ktx.observe -import de.westnordost.streetcomplete.util.ktx.toast -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope -import de.westnordost.streetcomplete.util.viewBinding -import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets -import kotlinx.coroutines.launch -import org.koin.androidx.viewmodel.ext.android.viewModel - -/** Shows a list of the edit history */ -class EditHistoryFragment : Fragment(R.layout.fragment_edit_history_list) { - - private val binding by viewBinding(FragmentEditHistoryListBinding::bind) - private val viewModel by viewModel(ownerProducer = { requireParentFragment() }) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val initialPaddingBottom = binding.editHistoryList.paddingBottom - binding.editHistoryList.respectSystemInsets { - updatePadding(left = it.left, top = it.top, bottom = it.bottom + initialPaddingBottom) - } - - val adapter = EditHistoryAdapter(this::onClick) - binding.editHistoryList.adapter = adapter - - // on opening, always select the first item - viewModel.select(viewModel.editItems.value.firstOrNull()?.edit?.key) - - observe(viewModel.editItems) { editItems -> - adapter.edits = editItems - } - } - - private fun onClick(editItem: EditItem) { - if (editItem.isSelected) { - if (editItem.edit.isUndoable) { - viewLifecycleScope.launch { - val element = viewModel.getEditElement(editItem.edit) - UndoDialog( - requireContext(), - editItem.edit, - element, - viewModel.featureDictionaryLazy, - { viewModel.undo(it.key) } - ).show() - } - } else { - context?.toast(R.string.toast_undo_unavailable, Toast.LENGTH_LONG) - } - } else { - viewModel.select(editItem.edit.key) - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryItem.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryItem.kt new file mode 100644 index 0000000000..5a13d1f79e --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryItem.kt @@ -0,0 +1,85 @@ +package de.westnordost.streetcomplete.screens.main.edithistory + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.ContentAlpha +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.osm.mapdata.ElementType +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden +import de.westnordost.streetcomplete.quests.recycling.AddRecyclingType +import de.westnordost.streetcomplete.screens.main.controls.MapButton +import de.westnordost.streetcomplete.ui.common.UndoIcon +import de.westnordost.streetcomplete.ui.theme.selectionBackground + +/** One item in the edit history sidebar list. Selectable and when selected, an undo button is + * clickable. */ +@Composable +fun EditHistoryItem( + selected: Boolean, + onSelect: () -> Unit, + onUndo: () -> Unit, + edit: Edit, + modifier: Modifier = Modifier, +) { + val backgroundColor = when { + selected -> MaterialTheme.colors.selectionBackground + edit.isSynced == true -> MaterialTheme.colors.onSurface.copy(alpha = 0.1f) + else -> MaterialTheme.colors.surface + } + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background(backgroundColor) + .selectable( + selected = selected, + onClick = onSelect + ), + ) { + Box( + Modifier + .size(56.dp) + .padding(4.dp) + ) { + EditImage(edit) + AnimatedVisibility( + visible = selected, + enter = fadeIn(), + exit = fadeOut() + ) { + MapButton(onClick = onUndo, contentPadding = 8.dp) { UndoIcon() } + } + } + } +} + +@Preview +@Composable +private fun PreviewEditsColumnItem() { + var selected by remember { mutableStateOf(false) } + EditHistoryItem( + selected = selected, + onSelect = { selected = !selected }, + onUndo = {}, + modifier = Modifier.width(80.dp), + edit = OsmQuestHidden(ElementType.NODE, 1L, AddRecyclingType(), LatLon(0.0,0.0), 1L), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistorySidebar.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistorySidebar.kt new file mode 100644 index 0000000000..712b693611 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistorySidebar.kt @@ -0,0 +1,199 @@ +package de.westnordost.streetcomplete.screens.main.edithistory + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import de.westnordost.osmfeatures.FeatureDictionary +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.ui.ktx.isItemAtIndexFullyVisible +import de.westnordost.streetcomplete.ui.theme.titleSmall +import de.westnordost.streetcomplete.util.ktx.toast +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.text.DateFormat + +/** Shows the edit history in a sidebar. The edit history is grouped by time and date, ordered by + * the most recent edit at the bottom. The list always scrolls to the currently selected edit. */ +@Composable +fun EditHistorySidebar( + editItems: List, + selectedEdit: Edit?, + onSelectEdit: (Edit) -> Unit, + onUndoEdit: (Edit) -> Unit, + onDismissRequest: () -> Unit, + featureDictionaryLazy: Lazy, + getEditElement: suspend (Edit) -> Element?, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + + val context = LocalContext.current + val dir = LocalLayoutDirection.current + + val insets = WindowInsets.safeDrawing.asPaddingValues() + + var showUndoDialog by remember { mutableStateOf(false) } + var editElement by remember { mutableStateOf(null) } + + // scrolling to selected item + val selectedIndex = remember(selectedEdit) { + if (selectedEdit != null) editItems + .indexOfLast { it.edit == selectedEdit } + .takeUnless { it < 0 } + else null + } + val state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex ?: editItems.lastIndex) + LaunchedEffect(selectedEdit) { + // except for first scroll, only scroll if not fully visible + if (selectedIndex != null && !state.isItemAtIndexFullyVisible(selectedIndex)) { + state.animateScrollToItem(selectedIndex) + } + } + + // close on back + BackHandler { + onDismissRequest() + } + + fun onClickUndoEdit(edit: Edit) { + if (edit.isUndoable) { + coroutineScope.launch { + editElement = getEditElement(edit) + showUndoDialog = true + } + } else { + context.toast(R.string.toast_undo_unavailable, Toast.LENGTH_LONG) + } + } + + // take care of insets: + // vertical offset as lazy column content padding, left padding as padding of the surface + Surface( + modifier = modifier + .padding(end = insets.calculateEndPadding(dir)) + .fillMaxHeight() + .consumeWindowInsets(insets) + .shadow(16.dp), + // not using surface's elevation here because we don't want it to change its background + // color to gray in dark mode + ) { + LazyColumn( + modifier = Modifier + .padding(start = insets.calculateStartPadding(dir)) + .width(80.dp), + state = state, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, + contentPadding = PaddingValues( + top = insets.calculateTopPadding(), + bottom = insets.calculateBottomPadding() + 32.dp // to align with undo button + ) + ) { + items( + items = editItems, + // NOTE: unfortunately, keys must be parcelable on Android. I wonder how they solved + // that in Compose multiplatform + key = { Json.encodeToString(it.edit.key) } + ) { editItem -> + Column { + DateTimeHeader( + timestamp = editItem.edit.createdTimestamp, + showDate = editItem.showDate, + showTime = editItem.showTime + ) + EditHistoryItem( + selected = selectedEdit == editItem.edit, + onSelect = { onSelectEdit(editItem.edit) }, + onUndo = { onClickUndoEdit(editItem.edit) }, + edit = editItem.edit, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + if (showUndoDialog && selectedEdit != null) { + UndoDialog( + edit = selectedEdit, + element = editElement, + featureDictionaryLazy = featureDictionaryLazy, + onDismissRequest = { + showUndoDialog = false + editElement = null + }, + onConfirmed = { onUndoEdit(selectedEdit) } + ) + } +} + +@Composable +private fun DateTimeHeader( + timestamp: Long, + showDate: Boolean, + showTime: Boolean, + modifier: Modifier = Modifier +) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.titleSmall, + LocalContentAlpha provides ContentAlpha.medium + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + // divider to demarcate time boundary + if (showDate || showTime) { + Divider() + } + if (showDate) { + // locale-dependent, e.g. 13/8/24 + Text(DateFormat.getDateInstance(DateFormat.SHORT).format(timestamp)) + } + if (showTime) { + // locale-dependent, e.g. 12:30 PM + Text(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt index 4d730f4403..1b2974657d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditHistoryViewModel.kt @@ -17,14 +17,16 @@ import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden import de.westnordost.streetcomplete.util.ktx.launch import de.westnordost.streetcomplete.util.ktx.toLocalDateTime import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime abstract class EditHistoryViewModel : ViewModel() { abstract val editItems: StateFlow> @@ -37,13 +39,19 @@ abstract class EditHistoryViewModel : ViewModel() { abstract fun undo(editKey: EditKey) abstract val featureDictionaryLazy: Lazy + + /* edit sidebar */ + // TODO could maybe be just a boolean in the composable when there's no communication between + // compose <-> fragment communication necessary anymore + abstract fun showSidebar() + abstract fun hideSidebar() + abstract val isShowingSidebar: StateFlow } data class EditItem( val edit: Edit, val showDate: Boolean, val showTime: Boolean, - val isSelected: Boolean ) class EditHistoryViewModelImpl( @@ -56,23 +64,10 @@ class EditHistoryViewModelImpl( override val selectedEdit = MutableStateFlow(null) - override val editItems = combine(edits, selectedEdit) { edits, selection -> - edits.mapIndexed { index, edit -> - val editAbove = if (index < edits.indices.last) edits[index + 1] else null - - val editDateTime = Instant.fromEpochMilliseconds(edit.createdTimestamp).toLocalDateTime() - val editAboveDateTime = editAbove?.let { Instant.fromEpochMilliseconds(it.createdTimestamp).toLocalDateTime() } - val sameDate = editDateTime.date == editAboveDateTime?.date - val sameTime = editDateTime.time.hour == editAboveDateTime?.time?.hour && - editDateTime.time.minute == editAboveDateTime.time.minute - - EditItem(edit, - showDate = !sameDate, - showTime = !sameTime || !sameDate, - isSelected = selection?.key == edit.key - ) - } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + @OptIn(ExperimentalCoroutinesApi::class) + override val editItems = edits + .transformLatest { emit(it.toEditItems()) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) override suspend fun getEditElement(edit: Edit): Element? { val key = edit.primaryElementKey ?: return null @@ -100,10 +95,23 @@ class EditHistoryViewModelImpl( } } + override fun showSidebar() { + selectedEdit.value = edits.value.lastOrNull() + isShowingSidebar.value = true + } + + override fun hideSidebar() { + selectedEdit.value = null + isShowingSidebar.value = false + } + + override val isShowingSidebar = MutableStateFlow(false) + + private val editHistoryListener = object : EditHistorySource.Listener { override fun onAdded(added: Edit) { edits.update { edits -> - var insertIndex = edits.indexOfFirst { it.createdTimestamp < added.createdTimestamp } + var insertIndex = edits.indexOfLast { it.createdTimestamp > added.createdTimestamp } if (insertIndex == -1) insertIndex = edits.size edits.toMutableList().also { it.add(insertIndex, added) } } @@ -114,7 +122,7 @@ class EditHistoryViewModelImpl( selectedEdit.value = synced } edits.update { edits -> - val editIndex = edits.indexOfFirst { it.key == synced.key } + val editIndex = edits.indexOfLast { it.key == synced.key } if (editIndex != -1) { edits.toMutableList().also { it[editIndex] = synced } } else { @@ -131,6 +139,7 @@ class EditHistoryViewModelImpl( edits.update { edits -> edits.filter { it.key !in deletedKeys } } + if (edits.value.isEmpty()) hideSidebar() } override fun onInvalidated() { @@ -149,7 +158,26 @@ class EditHistoryViewModelImpl( private fun updateEdits() { launch(IO) { - edits.value = editHistoryController.getAll().sortedByDescending { it.createdTimestamp } + edits.value = editHistoryController.getAll().sortedBy { it.createdTimestamp } + if (edits.value.isEmpty()) hideSidebar() + } + } + + private fun List.toEditItems(): List { + var editAboveDateTime: LocalDateTime? = null + return map { edit -> + val editDateTime = Instant.fromEpochMilliseconds(edit.createdTimestamp).toLocalDateTime() + val sameDate = editDateTime.date == editAboveDateTime?.date + val sameTime = + editDateTime.time.hour == editAboveDateTime?.time?.hour && + editDateTime.time.minute == editAboveDateTime?.time?.minute + editAboveDateTime = editDateTime + + EditItem( + edit = edit, + showDate = !sameDate, + showTime = !sameTime || !sameDate, + ) } } } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditImage.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditImage.kt new file mode 100644 index 0000000000..e77a437622 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/EditImage.kt @@ -0,0 +1,34 @@ +package de.westnordost.streetcomplete.screens.main.edithistory + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import de.westnordost.streetcomplete.data.edithistory.Edit + +/** Icon representing an edit (main icon + overlay icon) */ +@Composable +fun EditImage( + edit: Edit, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier) { + val editIcon = edit.icon + if (editIcon != 0) { + Image(painterResource(edit.icon), null) + } + val overlayIcon = edit.overlayIcon + if (overlayIcon != 0) { + Image( + painter = painterResource(overlayIcon), + contentDescription = null, + modifier = Modifier + .size(maxWidth * 0.75f) + .align(Alignment.BottomEnd) + ) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt index 945b7871f2..69a2b5d235 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/edithistory/UndoDialog.kt @@ -1,183 +1,51 @@ package de.westnordost.streetcomplete.screens.main.edithistory -import android.content.Context -import android.os.Bundle -import android.text.Html -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.TextView -import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import de.westnordost.osmfeatures.FeatureDictionary import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.edithistory.Edit -import de.westnordost.streetcomplete.data.edithistory.icon -import de.westnordost.streetcomplete.data.edithistory.overlayIcon -import de.westnordost.streetcomplete.data.osm.edits.ElementEdit -import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction -import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeFromVertexAction -import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction -import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction -import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete -import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify -import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction import de.westnordost.streetcomplete.data.osm.mapdata.Element -import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden -import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit -import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT -import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE -import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden -import de.westnordost.streetcomplete.data.quest.QuestType -import de.westnordost.streetcomplete.databinding.DialogUndoBinding -import de.westnordost.streetcomplete.quests.getTitle -import de.westnordost.streetcomplete.util.getNameAndLocationSpanned -import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import de.westnordost.streetcomplete.view.CharSequenceText -import de.westnordost.streetcomplete.view.ResText -import de.westnordost.streetcomplete.view.Text -import de.westnordost.streetcomplete.view.setHtml -import de.westnordost.streetcomplete.view.setText - -class UndoDialog( - context: Context, - private val edit: Edit, - private val element: Element?, - private val featureDictionaryLazy: Lazy, - private val onUndo: (edit: Edit) -> Unit, -) : AlertDialog(context) { - - private val binding = DialogUndoBinding.inflate(LayoutInflater.from(context)) - - init { - binding.icon.setImageResource(edit.icon) - val overlayResId = edit.overlayIcon - if (overlayResId != 0) binding.overlayIcon.setImageResource(overlayResId) - binding.createdTimeText.text = - DateUtils.getRelativeTimeSpanString(edit.createdTimestamp, nowAsEpochMilliseconds(), DateUtils.MINUTE_IN_MILLIS) - binding.descriptionContainer.addView(edit.descriptionView) - - setTitle(R.string.undo_confirm_title2) - setView(binding.root) - setButton(BUTTON_POSITIVE, context.getText(R.string.undo_confirm_positive), null) { _, _ -> onUndo(edit) } - setButton(BUTTON_NEGATIVE, context.getText(R.string.undo_confirm_negative), null, null) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding.titleText.text = edit.getTitle() - if (edit is ElementEdit) { - binding.titleHintText.text = element?.let { - getNameAndLocationSpanned(it, context.resources, featureDictionaryLazy.value) +import de.westnordost.streetcomplete.ui.common.dialogs.ScrollableAlertDialog + +/** Confirmation dialog for undoing an edit. Shows details about an edit */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UndoDialog( + edit: Edit, + element: Element?, + featureDictionaryLazy: Lazy, + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, +) { + ScrollableAlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.undo_confirm_title2)) }, + content = { + Box(Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + EditDetails(edit, element, featureDictionaryLazy) } - } - } - - private fun Edit.getTitle(): CharSequence = when (this) { - is ElementEdit -> { - if (type is QuestType) { - context.resources.getString(type.getTitle(element?.tags.orEmpty())) - } else { - context.resources.getText(type.title) - } - } - is NoteEdit -> { - context.resources.getText(when (action) { - CREATE -> R.string.created_note_action_title - COMMENT -> R.string.commented_note_action_title - }) - } - is OsmQuestHidden -> { - context.resources.getString(questType.getTitle(element?.tags.orEmpty())) - } - is OsmNoteQuestHidden -> { - context.resources.getText(R.string.quest_noteDiscussion_title) - } - else -> throw IllegalArgumentException() - } - - private val Edit.descriptionView: View get() = when (this) { - is ElementEdit -> { - when (action) { - is UpdateElementTagsAction -> - createListOfTagUpdates(action.changes.changes) - is DeletePoiNodeAction -> - createTextView(ResText(R.string.deleted_poi_action_description)) - is SplitWayAction -> - createTextView(ResText(R.string.split_way_action_description)) - is CreateNodeAction -> - createCreateNodeDescriptionView(action.tags) - is CreateNodeFromVertexAction -> - createListOfTagUpdates(action.changes.changes) - is MoveNodeAction -> - createTextView(ResText(R.string.move_node_action_description)) - else -> throw IllegalArgumentException() + }, + buttons = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.undo_confirm_negative)) } - } - is NoteEdit -> createTextView(text?.let { CharSequenceText(it) }) - is OsmQuestHidden -> createTextView(ResText(R.string.hid_action_description)) - is OsmNoteQuestHidden -> createTextView(ResText(R.string.hid_action_description)) - else -> throw IllegalArgumentException() - } - - private fun createTextView(text: Text?): TextView { - val txt = TextView(context) - txt.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - txt.setText(text) - txt.setTextIsSelectable(true) - return txt - } - - private fun createListOfTagUpdates(changes: Collection): TextView { - val txt = TextView(context) - txt.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - - txt.setHtml(changes.joinToString(separator = "", prefix = "
    ", postfix = "
") { change -> - "
  • " + - context.resources.getString( - change.titleResId, - "" + change.toLinkedTagString() + "" - ) + - "
  • " - }) - return txt - } - - private fun createCreateNodeDescriptionView(tags: Map): TextView { - val txt = TextView(context) - txt.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - - txt.setHtml( - context.resources.getString(R.string.create_node_action_description) + - tags.entries.joinToString(separator = "", prefix = "
      ", postfix = "
    ") { (key, value) -> - "
  • " + linkedTagString(key, value) + "
  • " + TextButton(onClick = { onConfirmed(); onDismissRequest() }) { + Text(stringResource(R.string.undo_confirm_positive)) } - ) - return txt - } -} - -private fun StringMapEntryChange.toLinkedTagString(): String = - linkedTagString(key, when (this) { - is StringMapEntryAdd -> value - is StringMapEntryModify -> value - is StringMapEntryDelete -> valueBefore - }) - -private fun linkedTagString(key: String, value: String): String { - val escapedKey = Html.escapeHtml(key) - val escapedValue = Html.escapeHtml(value) - val keyLink = "$escapedKey" - return "$keyLink = $escapedValue" -} - -private val StringMapEntryChange.titleResId: Int get() = when (this) { - is StringMapEntryAdd -> R.string.added_tag_action_title - is StringMapEntryModify -> R.string.changed_tag_action_title - is StringMapEntryDelete -> R.string.removed_tag_action_title + }, + height = 360.dp + ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastCrashEffect.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastCrashEffect.kt new file mode 100644 index 0000000000..bf1b5da7f0 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastCrashEffect.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.screens.main.errors + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R + +/** Offer to report the last occurred crash */ +@Composable +fun LastCrashEffect( + lastReport: String, + onReport: (errorReport: String) -> Unit +) { + var showErrorDialog by remember { mutableStateOf(false) } + + LaunchedEffect(lastReport) { showErrorDialog = true } + + if (showErrorDialog) { + SendErrorReportDialog( + onDismissRequest = { showErrorDialog = false }, + onConfirmed = { onReport(lastReport) }, + title = stringResource(R.string.crash_title) + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastDownloadErrorEffect.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastDownloadErrorEffect.kt new file mode 100644 index 0000000000..3a576fee52 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastDownloadErrorEffect.kt @@ -0,0 +1,49 @@ +package de.westnordost.streetcomplete.screens.main.errors + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.AuthorizationException +import de.westnordost.streetcomplete.data.ConnectionException +import de.westnordost.streetcomplete.util.ktx.toast + +/** Depending on the type of error, either display or conditionally offer to report the last + * occurred error during download */ +@Composable +fun LastDownloadErrorEffect( + lastError: Exception, + onReportError: (error: Exception) -> Unit +) { + val context = LocalContext.current + + var showDownloadErrorDialog by remember { mutableStateOf(false) } + + LaunchedEffect(lastError) { + when (lastError) { + is ConnectionException -> { + context.toast(R.string.download_server_error, Toast.LENGTH_LONG) + } + is AuthorizationException -> { + context.toast(R.string.auth_error, Toast.LENGTH_LONG) + } + else -> { + showDownloadErrorDialog = true + } + } + } + + if (showDownloadErrorDialog) { + SendErrorReportDialog( + onDismissRequest = { showDownloadErrorDialog = false }, + onConfirmed = { onReportError(lastError) }, + title = stringResource(R.string.download_error) + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastUploadErrorEffect.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastUploadErrorEffect.kt new file mode 100644 index 0000000000..9fdcf2a865 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/LastUploadErrorEffect.kt @@ -0,0 +1,61 @@ +package de.westnordost.streetcomplete.screens.main.errors + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.data.AuthorizationException +import de.westnordost.streetcomplete.data.ConnectionException +import de.westnordost.streetcomplete.data.upload.VersionBannedException +import de.westnordost.streetcomplete.util.ktx.toast + +/** Depending on the type of error, either display or conditionally offer to report the last + * occurred error during upload */ +@Composable +fun LastUploadErrorEffect( + lastError: Exception, + onReportError: (error: Exception) -> Unit +) { + val context = LocalContext.current + + var showUploadErrorDialog by remember { mutableStateOf(false) } + var shownVersionBanned by remember { mutableStateOf(null) } + + LaunchedEffect(lastError) { + when (lastError) { + is VersionBannedException -> { + shownVersionBanned = lastError.banReason + } + is ConnectionException -> { + context.toast(R.string.upload_server_error, Toast.LENGTH_LONG) + } + is AuthorizationException -> { + context.toast(R.string.auth_error, Toast.LENGTH_LONG) + } + else -> { + showUploadErrorDialog = true + } + } + } + + if (shownVersionBanned != null) { + VersionBannedDialog( + onDismissRequest = { shownVersionBanned = null }, + reason = shownVersionBanned + ) + } + + if (showUploadErrorDialog) { + SendErrorReportDialog( + onDismissRequest = { showUploadErrorDialog = false }, + onConfirmed = { onReportError(lastError) }, + title = stringResource(R.string.upload_error) + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/SendErrorReportDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/SendErrorReportDialog.kt new file mode 100644 index 0000000000..8b674e80ee --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/SendErrorReportDialog.kt @@ -0,0 +1,29 @@ +package de.westnordost.streetcomplete.screens.main.errors + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.dialogs.ConfirmationDialog + +/** Dialog that asks user to send a crash report to the developer */ +@Composable +fun SendErrorReportDialog( + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, + title: String +) { + ConfirmationDialog( + onDismissRequest = onDismissRequest, + onConfirmed = onConfirmed, + title = { Text(title) }, + text = { Text(stringResource(R.string.crash_message)) }, + confirmButtonText = stringResource(R.string.crash_compose_email), + // should be more of a modal dialog + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/VersionBannedDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/VersionBannedDialog.kt new file mode 100644 index 0000000000..458ca485a0 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/errors/VersionBannedDialog.kt @@ -0,0 +1,27 @@ +package de.westnordost.streetcomplete.screens.main.errors + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.dialogs.InfoDialog + +/** Info dialog that informs user that his current app version has been banned from uploading + * data */ +@Composable +fun VersionBannedDialog( + onDismissRequest: () -> Unit, + reason: String? +) { + InfoDialog( + onDismissRequest = onDismissRequest, + text = { + val message = StringBuilder(stringResource(R.string.version_banned_message)) + if (reason != null) { + message.append("\n\n\n") + message.append(reason) + } + Text(message.toString()) + } + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt index 1f899a6eb5..22b3caef9c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt @@ -9,7 +9,7 @@ import de.westnordost.streetcomplete.data.edithistory.ElementEditKey import de.westnordost.streetcomplete.data.edithistory.NoteEditKey import de.westnordost.streetcomplete.data.edithistory.OsmNoteQuestHiddenKey import de.westnordost.streetcomplete.data.edithistory.OsmQuestHiddenKey -import de.westnordost.streetcomplete.data.edithistory.icon +import de.westnordost.streetcomplete.screens.main.edithistory.icon import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt index 489d9ac426..555d2a0ee4 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt @@ -33,6 +33,7 @@ import de.westnordost.streetcomplete.screens.main.map.components.PinsMapComponen import de.westnordost.streetcomplete.screens.main.map.components.SelectedPinsMapComponent import de.westnordost.streetcomplete.screens.main.map.components.StyleableOverlayMapComponent import de.westnordost.streetcomplete.screens.main.map.components.TracksMapComponent +import de.westnordost.streetcomplete.screens.main.map.maplibre.CameraPosition import de.westnordost.streetcomplete.screens.main.map.maplibre.MapImages import de.westnordost.streetcomplete.screens.main.map.maplibre.camera import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLon @@ -354,8 +355,8 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers { listener?.onDisplayedLocationDidChange() } - override fun onMapIsChanging(position: LatLon, rotation: Double, tilt: Double, zoom: Double) { - super.onMapIsChanging(position, rotation, tilt, zoom) + override fun onMapIsChanging(camera: CameraPosition) { + super.onMapIsChanging(camera) questPinsManager?.onNewScreenPosition() styleableOverlayManager?.onNewScreenPosition() } diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt index 8a8cf943bd..f0a5ee5ea2 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MapFragment.kt @@ -3,7 +3,6 @@ package de.westnordost.streetcomplete.screens.main.map import android.graphics.PointF import android.os.Bundle import android.view.View -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import de.westnordost.streetcomplete.ApplicationConstants @@ -25,11 +24,8 @@ import de.westnordost.streetcomplete.screens.main.map.maplibre.toLatLon import de.westnordost.streetcomplete.screens.main.map.maplibre.updateCamera import de.westnordost.streetcomplete.util.ktx.dpToPx import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import de.westnordost.streetcomplete.util.ktx.openUri -import de.westnordost.streetcomplete.util.ktx.setMargins import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope import de.westnordost.streetcomplete.util.viewBinding -import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.android.ext.android.inject @@ -53,7 +49,7 @@ open class MapFragment : Fragment(R.layout.fragment_map) { /** Called when the map has been completely initialized */ fun onMapInitialized() /** Called during camera animation and while the map is being controlled by a user */ - fun onMapIsChanging(position: LatLon, rotation: Double, tilt: Double, zoom: Double) + fun onMapIsChanging(camera: CameraPosition) /** Called when the user begins to pan the map */ fun onPanBegin() /** Called when the user long-presses the map */ @@ -76,11 +72,6 @@ open class MapFragment : Fragment(R.layout.fragment_map) { binding.map.onCreate(savedInstanceState) binding.map.foreground = view.context.getDrawable(R.color.background) - binding.openstreetmapLink.setOnClickListener { showOpenUrlDialog("https://www.openstreetmap.org/copyright") } - binding.mapTileProviderLink.setOnClickListener { showOpenUrlDialog("https://www.jawg.io") } - - binding.attributionContainer.respectSystemInsets(View::setMargins) - initOfflineCacheSize() cleanOldOfflineRegions() @@ -105,15 +96,6 @@ open class MapFragment : Fragment(R.layout.fragment_map) { } } - private fun showOpenUrlDialog(url: String) { - AlertDialog.Builder(requireContext()) - .setTitle(R.string.open_url) - .setMessage(url) - .setPositiveButton(android.R.string.ok) { _, _ -> openUri(url) } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - override fun onStart() { super.onStart() // sceneMapComponent might actually be null if map style not initialized yet @@ -167,8 +149,8 @@ open class MapFragment : Fragment(R.layout.fragment_map) { }) map.addOnCameraMoveListener { val camera = cameraPosition ?: return@addOnCameraMoveListener - onMapIsChanging(camera.position, camera.rotation, camera.tilt, camera.zoom) - listener?.onMapIsChanging(camera.position, camera.rotation, camera.tilt, camera.zoom) + onMapIsChanging(camera) + listener?.onMapIsChanging(camera) } map.addOnMapLongClickListener { pos -> onLongPress(map.projection.toScreenLocation(pos), pos.toLatLon()) @@ -191,7 +173,7 @@ open class MapFragment : Fragment(R.layout.fragment_map) { protected open suspend fun onMapStyleLoaded(map: MapLibreMap, style: Style) {} - protected open fun onMapIsChanging(position: LatLon, rotation: Double, tilt: Double, zoom: Double) {} + protected open fun onMapIsChanging(camera: CameraPosition) {} /* ---------------------- Overridable callbacks for map interaction ------------------------ */ diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessageDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessageDialog.kt new file mode 100644 index 0000000000..3117f44849 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessageDialog.kt @@ -0,0 +1,56 @@ +package de.westnordost.streetcomplete.screens.main.messages + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import de.westnordost.streetcomplete.data.messages.Message +import de.westnordost.streetcomplete.data.messages.NewAchievementMessage +import de.westnordost.streetcomplete.data.messages.NewVersionMessage +import de.westnordost.streetcomplete.data.messages.OsmUnreadMessagesMessage +import de.westnordost.streetcomplete.data.messages.QuestSelectionHintMessage +import de.westnordost.streetcomplete.screens.settings.SettingsActivity +import de.westnordost.streetcomplete.screens.user.achievements.AchievementDialog + +/** Dialog that shows a Message */ +@Composable +fun MessageDialog( + message: Message, + allQuestIconIds: List, + onDismissRequest: () -> Unit, +) { + when (message) { + is NewAchievementMessage -> { + AchievementDialog( + achievement = message.achievement, + level = message.level, + onDismissRequest = onDismissRequest + ) + } + is NewVersionMessage -> { + WhatsNewDialog( + changelog = message.changelog, + onDismissRequest = onDismissRequest, + ) + } + is QuestSelectionHintMessage -> { + val context = LocalContext.current + QuestSelectionHintDialog( + onDismissRequest = onDismissRequest, + onClickOpenSettings = { + context.startActivity(SettingsActivity.createLaunchQuestSettingsIntent(context)) + }, + allQuestIconIds = allQuestIconIds + ) + } + is OsmUnreadMessagesMessage -> { + val uriHandler = LocalUriHandler.current + UnreadMessagesDialog( + unreadMessageCount = message.unreadMessages, + onDismissRequest = onDismissRequest, + onClickOpenMessages = { + uriHandler.openUri("https://www.openstreetmap.org/messages/inbox") + } + ) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessagesContainerFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessagesContainerFragment.kt deleted file mode 100644 index 85a94e7cc0..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/MessagesContainerFragment.kt +++ /dev/null @@ -1,71 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.messages - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.data.messages.Message -import de.westnordost.streetcomplete.data.messages.NewAchievementMessage -import de.westnordost.streetcomplete.data.messages.NewVersionMessage -import de.westnordost.streetcomplete.data.messages.OsmUnreadMessagesMessage -import de.westnordost.streetcomplete.data.messages.QuestSelectionHintMessage -import de.westnordost.streetcomplete.screens.settings.SettingsActivity -import de.westnordost.streetcomplete.screens.user.achievements.AchievementDialog -import de.westnordost.streetcomplete.ui.util.composableContent -import kotlinx.coroutines.flow.MutableStateFlow - -/** A fragment that contains any fragments that would show messages. - * Usually, messages are shown as dialogs, however there is currently one exception which - * makes this necessary as a fragment */ -class MessagesContainerFragment : Fragment() { - - private val shownMessage = MutableStateFlow(null) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = composableContent { - val message by shownMessage.collectAsState() - - when (val msg = message) { - is NewAchievementMessage -> { - AchievementDialog( - msg.achievement, - msg.level, - onDismissRequest = { shownMessage.value = null } - ) - } - is NewVersionMessage -> { - WhatsNewDialog( - changelog = msg.changelog, - onDismissRequest = { shownMessage.value = null }, - ) - } - else -> {} - } - } - - fun showMessage(message: Message) { - val ctx = context ?: return - shownMessage.value = message - when (message) { - is OsmUnreadMessagesMessage -> { - OsmUnreadMessagesFragment - .create(message.unreadMessages) - .show(childFragmentManager, null) - } - is QuestSelectionHintMessage -> { - AlertDialog.Builder(ctx) - .setTitle(R.string.quest_selection_hint_title) - .setMessage(R.string.quest_selection_hint_message) - .setPositiveButton(R.string.quest_streetName_cantType_open_settings) { _, _ -> - startActivity(SettingsActivity.createLaunchQuestSettingsIntent(ctx)) - } - .setNegativeButton(android.R.string.ok, null) - .show() - } - else -> {} - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OpenMailPainter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OpenMailPainter.kt new file mode 100644 index 0000000000..5bd8dc54a5 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OpenMailPainter.kt @@ -0,0 +1,36 @@ +package de.westnordost.streetcomplete.screens.main.messages + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.Path +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.util.interpolateColors + +@Composable +fun openMailPainter(progress: Float): VectorPainter = rememberVectorPainter( + defaultWidth = 288.dp, + defaultHeight = 288.dp, + viewportWidth = 288f, + viewportHeight = 288f, + autoMirror = false +) { _, _ -> + Path( + pathData = PathData { + moveTo(7.911f, 121.751f) + lineTo(142.5f, 238.62f * (1f - progress) + 6f) + lineTo(275.119f, 121.751f) + close() + }, + strokeLineJoin = StrokeJoin.Round, + strokeLineCap = StrokeCap.Round, + strokeLineWidth = 6f, + stroke = SolidColor(Color(0xffD3BF95)), + fill = SolidColor(interpolateColors(Color(0xffFFF7E0), Color(0xffD3BF95), progress)), + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OsmUnreadMessagesFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OsmUnreadMessagesFragment.kt deleted file mode 100644 index a6cc5866e0..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/OsmUnreadMessagesFragment.kt +++ /dev/null @@ -1,126 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.messages - -import android.app.Dialog -import android.graphics.drawable.AnimatedVectorDrawable -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.animation.DecelerateInterpolator -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.databinding.FragmentUnreadOsmMessageBinding -import de.westnordost.streetcomplete.util.SoundFx -import de.westnordost.streetcomplete.util.ktx.dpToPx -import de.westnordost.streetcomplete.util.ktx.openUri -import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope -import de.westnordost.streetcomplete.util.viewBinding -import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject - -/** Fragment that shows a message that the user has X unread messages in his OSM inbox */ -class OsmUnreadMessagesFragment : DialogFragment(R.layout.fragment_unread_osm_message) { - - private val soundFx: SoundFx by inject() - - private val binding by viewBinding(FragmentUnreadOsmMessageBinding::bind) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_FRAME, R.style.Theme_CustomDialog) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // hide first, to avoid flashing - binding.mailContainer.alpha = 0.0f - binding.dialogContainer.setOnClickListener { dismiss() } - binding.readMailButton.setOnClickListener { - openUri("https://www.openstreetmap.org/messages/inbox") - dismiss() - } - val unreadMessagesCount = arguments?.getInt(ARG_UNREAD_MESSAGE_COUNT, 0) ?: 0 - binding.unreadMessagesTextView.text = getString(R.string.unread_messages_message, unreadMessagesCount) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - dialog.setOnShowListener { startAnimation() } - // we want to show a highly custom dialog here with no frame. Without this, the dialog's - // content is restricted to wrap content, but we want to use whole screen here (for animation) - dialog.window?.setLayout(MATCH_PARENT, MATCH_PARENT) - return dialog - } - - private fun startAnimation() { - val ctx = requireContext() - - viewLifecycleScope.launch { soundFx.play(R.raw.sliding_envelope) } - - val speechbubbleContentContainer = binding.speechbubbleContentContainer - val mailOpenImageView = binding.mailOpenImageView - val mailFrontImageView = binding.mailFrontImageView - - mailFrontImageView.alpha = 0f - - speechbubbleContentContainer.alpha = 0.0f - speechbubbleContentContainer.visibility = View.VISIBLE - speechbubbleContentContainer.scaleX = 0.8f - speechbubbleContentContainer.scaleY = 0.8f - speechbubbleContentContainer.translationY = ctx.resources.dpToPx(140) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - (mailOpenImageView.drawable as? AnimatedVectorDrawable)?.reset() - } - - binding.mailContainer.rotation = -40f - binding.mailContainer.rotationY = -45f - binding.mailContainer.alpha = 0.2f - binding.mailContainer.translationX = ctx.resources.dpToPx(-400) - binding.mailContainer.translationY = ctx.resources.dpToPx(60) - binding.mailContainer.animate().run { - duration = 400 - startDelay = 200 - interpolator = DecelerateInterpolator() - rotation(0f) - rotationY(0f) - alpha(1f) - translationX(0f) - translationY(0f) - withEndAction { - (mailOpenImageView.drawable as? AnimatedVectorDrawable)?.start() - - mailFrontImageView.animate().run { - duration = 100 - startDelay = 100 - alpha(1f) - start() - } - - speechbubbleContentContainer.animate().run { - withStartAction { speechbubbleContentContainer.alpha = 0.4f } - startDelay = 200 - duration = 300 - scaleX(1f) - scaleY(1f) - alpha(1f) - translationY(0f) - start() - } - } - start() - } - } - - companion object { - private const val ARG_UNREAD_MESSAGE_COUNT = "unread_message_count" - - fun create(unreadMessagesCount: Int): OsmUnreadMessagesFragment { - val args = bundleOf(ARG_UNREAD_MESSAGE_COUNT to unreadMessagesCount) - val f = OsmUnreadMessagesFragment() - f.arguments = args - return f - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/QuestSelectionHintDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/QuestSelectionHintDialog.kt new file mode 100644 index 0000000000..6bbd9d7a2c --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/QuestSelectionHintDialog.kt @@ -0,0 +1,111 @@ +package de.westnordost.streetcomplete.screens.main.messages + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.BubblePile +import de.westnordost.streetcomplete.ui.common.dialogs.AlertDialogLayout +import de.westnordost.streetcomplete.ui.ktx.proportionalAbsoluteOffset +import kotlinx.coroutines.delay + +/** Dialog that tells the user that he can turn off some boring quests in the setting. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun QuestSelectionHintDialog( + onDismissRequest: () -> Unit, + onClickOpenSettings: () -> Unit, + allQuestIconIds: List, +) { + val avalanche = remember { Animatable(1.1f) } + val content = remember { Animatable(0f) } + + LaunchedEffect(allQuestIconIds) { + avalanche.animateTo(0f, tween(2000, easing = LinearEasing)) + delay(150) + content.animateTo(1f, tween(300)) + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDismissRequest() } + ) { + val bubbleCount = (maxHeight.value * 0.1f).toInt() + val bubbleSize = maxWidth * 0.25f + BubblePile( + count = bubbleCount, + allIconsIds = allQuestIconIds, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.6f) + .offset(y = bubbleSize * 0.75f) + .proportionalAbsoluteOffset(y = avalanche.value) + .graphicsLayer( + alpha = (1f - avalanche.value).coerceIn(0f, 1f), + rotationX = 5f, + scaleX = 1.5f, + scaleY = 1.25f, + transformOrigin = TransformOrigin(0.5f, 1f) + ), + bubbleSize = bubbleSize + ) + AlertDialogLayout( + modifier = Modifier + .align(Alignment.Center) + .offset(y = ((1 - content.value) * 32).dp) + .width(280.dp) + .alpha(content.value) + .shadow(24.dp), + title = { Text(stringResource(R.string.quest_selection_hint_title)) }, + content = { + Text( + text = stringResource(R.string.quest_selection_hint_message), + modifier = Modifier.padding(horizontal = 24.dp) + ) + }, + buttons = { + TextButton(onClick = { onDismissRequest(); onClickOpenSettings() }) { + Text(stringResource(R.string.quest_streetName_cantType_open_settings)) + } + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.ok)) + } + } + ) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/UnreadMessagesDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/UnreadMessagesDialog.kt new file mode 100644 index 0000000000..6de458bb3f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/UnreadMessagesDialog.kt @@ -0,0 +1,162 @@ +package de.westnordost.streetcomplete.screens.main.messages + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import de.westnordost.streetcomplete.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** Dialog that shows a message that the user has X unread messages in his OSM inbox */ +@Composable +fun UnreadMessagesDialog( + unreadMessageCount: Int, + onDismissRequest: () -> Unit, + onClickOpenMessages: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current.density + val envelope = remember { Animatable(-1f) } + val envelopeOpen = remember { Animatable(0f) } + val content = remember { Animatable(0f) } + + LaunchedEffect(unreadMessageCount) { + // TODO soundFx.play(R.raw.sliding_envelope) - should be provided via composition locals + // but that only becomes convenient if there are not entry points to compose all over the place + envelope.animateTo(0f, tween(600)) + delay(150) + launch { envelopeOpen.animateTo(1f, tween(300)) } + launch { content.animateTo(1f, tween(300, 150)) } + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDismissRequest() }, + contentAlignment = Alignment.Center + ) { + Envelope( + opening = envelopeOpen.value, + modifier = modifier.graphicsLayer { + val e = envelope.value + rotationZ = e * 40f + rotationY = e * 45f + alpha = 1f - 0.8f * abs(e) + translationX = e * 400 * density + translationY = -e * 60 * density + } + ) { + UnreadMessagesContent( + unreadMessageCount = unreadMessageCount, + onClickOpenMessages = { onDismissRequest(); onClickOpenMessages() }, + modifier = Modifier.graphicsLayer { + val c = content.value + val scale = 0.8f + 0.2f * c + scaleX = scale + scaleY = scale + alpha = 0.4f + c * 0.6f + translationY = (140f * (1f - c)) * density + } + .shadow(24.dp) + ) + } + } + } +} +@Composable +private fun Envelope( + opening: Float, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.TopCenter + ) { + Image(painterResource(R.drawable.mail_back), null) + if (opening > 0.5f) Image(openMailPainter(progress = opening), null) + content() + Image(painterResource(R.drawable.mail_front), null) + if (opening <= 0.5f) Image(openMailPainter(progress = opening), null) + } +} + +@Composable +private fun UnreadMessagesContent( + unreadMessageCount: Int, + onClickOpenMessages: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + shape = MaterialTheme.shapes.medium, + modifier = modifier.width(240.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + Text( + text = stringResource(R.string.unread_messages_message, unreadMessageCount), + textAlign = TextAlign.Center, + ) + Button(onClick = onClickOpenMessages) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(R.drawable.ic_open_in_browser_24dp), null) + Text(stringResource(R.string.unread_messages_button)) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewUnreadMessagesDialog() { + UnreadMessagesDialog( + unreadMessageCount = 9, + onDismissRequest = {}, + onClickOpenMessages = {} + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/WhatsNewDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/WhatsNewDialog.kt index ed751171ee..265f1f3d1c 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/WhatsNewDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/messages/WhatsNewDialog.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete.screens.main.messages +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.Text @@ -14,6 +15,7 @@ import de.westnordost.streetcomplete.ui.common.dialogs.ScrollableAlertDialog import de.westnordost.streetcomplete.util.html.HtmlNode /** A dialog that shows the changelog */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun WhatsNewDialog( changelog: Map>, diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionAdapter.kt deleted file mode 100644 index 66a2549dd2..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.overlays - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.TextView -import androidx.core.graphics.drawable.updateBounds -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.overlays.Overlay -import de.westnordost.streetcomplete.util.ktx.dpToPx - -/** Adapter for the list in which the user can select which overlay he wants to use */ -class OverlaySelectionAdapter(private val overlays: List) : BaseAdapter() { - - override fun getCount() = 1 + overlays.size - - override fun getItem(position: Int) = if (position > 0) overlays[position - 1] else null - - override fun getItemId(position: Int) = position.toLong() - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val overlay = getItem(position) - val context = parent.context - val view = convertView - ?: LayoutInflater.from(context).inflate(R.layout.row_overlay_selection, parent, false) - val textView = view as TextView - textView.setText(overlay?.title ?: R.string.overlay_none) - val icon = context.getDrawable(overlay?.icon ?: R.drawable.space_24dp) - val bound = context.resources.dpToPx(38).toInt() - icon?.updateBounds(right = bound, bottom = bound) - textView.setCompoundDrawables(icon, null, null, null) - textView.compoundDrawablePadding = context.resources.dpToPx(8).toInt() - return textView - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt new file mode 100644 index 0000000000..4cbfbf33de --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/overlays/OverlaySelectionDropdownMenu.kt @@ -0,0 +1,56 @@ +package de.westnordost.streetcomplete.screens.main.overlays + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.overlays.Overlay +import de.westnordost.streetcomplete.ui.common.DropdownMenuItem + +/** Dropdown menu for selecting an overlay */ +@Composable +fun OverlaySelectionDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + overlays: List, + onSelect: (Overlay?) -> Unit, + modifier: Modifier = Modifier +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier + ) { + DropdownMenuItem(onClick = { onDismissRequest(); onSelect(null) }) { + Text( + text = stringResource(R.string.overlay_none), + modifier = Modifier.padding(start = 48.dp) + ) + } + for (overlay in overlays) { + DropdownMenuItem(onClick = { onDismissRequest(); onSelect(overlay) }) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(overlay.icon), + contentDescription = null, + modifier = Modifier.size(36.dp) + ) + Text(stringResource(overlay.title)) + } + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircle.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircle.kt new file mode 100644 index 0000000000..1c8e53e747 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircle.kt @@ -0,0 +1,55 @@ +package de.westnordost.streetcomplete.screens.main.teammode + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.ktx.dpToSp +import de.westnordost.streetcomplete.ui.theme.TeamColors + +/** Circle showing the color and letter of the selected team mode index. + * A size should be provided, as it has no intrinsic size*/ +@Composable +fun TeamModeColorCircle( + index: Int, + modifier: Modifier = Modifier, +) { + val color = TeamColors[index] + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier + .background(color, CircleShape) + .aspectRatio(1f) + ) { + Text( + text = (index + 'A'.code).toChar().toString(), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = (maxWidth * 0.5f).dpToSp() + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +private fun PreviewTeamModeColorCircle() { + FlowRow { + for (index in TeamColors.indices) { + TeamModeColorCircle(index = index, modifier = Modifier.size(24.dp)) + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircleView.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircleView.kt deleted file mode 100644 index 5017279e82..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeColorCircleView.kt +++ /dev/null @@ -1,57 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.teammode - -import android.content.Context -import android.graphics.Color -import android.util.AttributeSet -import android.view.LayoutInflater -import androidx.annotation.ColorInt -import androidx.constraintlayout.widget.ConstraintLayout -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.databinding.ViewTeamModeColorCircleBinding - -class TeamModeColorCircleView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { - - private val binding = ViewTeamModeColorCircleBinding.inflate(LayoutInflater.from(context), this) - - fun setIndexInTeam(index: Int) { - val color = context.resources.getColor(colors[index]) - val brightness = getColorBrightness(color) - - binding.teamModeColorCircleBackground.setColorFilter(color) - binding.teamModeColorCircleText.text = (index + 'A'.code).toChar().toString() - binding.teamModeColorCircleText.setTextColor(if (brightness > 0.7) Color.BLACK else Color.WHITE) - } - - private fun getColorBrightness(@ColorInt color: Int): Float { - val hsv = FloatArray(3) - Color.colorToHSV(color, hsv) - return hsv[2] - } - - init { - setIndexInTeam(0) - } - - companion object { - private val colors = listOf( - R.color.team_0, - R.color.team_1, - R.color.team_2, - R.color.team_3, - R.color.team_4, - R.color.team_5, - R.color.team_6, - R.color.team_7, - R.color.team_8, - R.color.team_9, - R.color.team_10, - R.color.team_11 - ) - - val MAX_TEAM_SIZE get() = colors.size - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeDialog.kt deleted file mode 100644 index d4b3e69554..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeDialog.kt +++ /dev/null @@ -1,75 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.teammode - -import android.content.Context -import android.view.LayoutInflater -import android.view.WindowManager -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone -import androidx.core.widget.doAfterTextChanged -import androidx.recyclerview.widget.GridLayoutManager -import de.westnordost.streetcomplete.databinding.DialogTeamModeBinding -import de.westnordost.streetcomplete.screens.main.teammode.TeamModeColorCircleView.Companion.MAX_TEAM_SIZE - -/** Shows a dialog containing the team mode settings */ -class TeamModeDialog( - context: Context, - onEnableTeamMode: (Int, Int) -> Unit -) : AlertDialog(context) { - - private var selectedTeamSize: Int? = null - private var selectedIndexInTeam: Int? = null - private val binding = DialogTeamModeBinding.inflate(LayoutInflater.from(context)) - - init { - val adapter = TeamModeIndexSelectAdapter() - adapter.listeners.add(object : TeamModeIndexSelectAdapter.OnSelectedIndexChangedListener { - override fun onSelectedIndexChanged(index: Int?) { - selectedIndexInTeam = index - updateOkButtonEnablement() - } - }) - binding.colorCircles.adapter = adapter - binding.colorCircles.layoutManager = GridLayoutManager(context, 3) - - binding.teamSizeInput.doAfterTextChanged { editable -> - selectedTeamSize = parseTeamSize(editable.toString()) - updateOkButtonEnablement() - - if (selectedTeamSize == null) { - binding.introText.isGone = false - binding.teamSizeHint.isGone = false - binding.colorHint.isGone = true - binding.colorCircles.isGone = true - } else { - binding.introText.isGone = true - binding.teamSizeHint.isGone = true - binding.colorHint.isGone = false - binding.colorCircles.isGone = false - adapter.count = selectedTeamSize!! - } - } - - setButton(BUTTON_POSITIVE, context.resources.getText(android.R.string.ok)) { _, _ -> - onEnableTeamMode(selectedTeamSize!!, selectedIndexInTeam!!) - dismiss() - } - - setOnShowListener { updateOkButtonEnablement() } - - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) - - setView(binding.root) - } - - private fun updateOkButtonEnablement() { - getButton(BUTTON_POSITIVE)?.isEnabled = selectedTeamSize != null && selectedIndexInTeam != null - } - - private fun parseTeamSize(string: String): Int? = - try { - val number = Integer.parseInt(string) - if (number in 2..MAX_TEAM_SIZE) number else null - } catch (e: NumberFormatException) { - null - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeIndexSelectAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeIndexSelectAdapter.kt deleted file mode 100644 index fa5e4df20f..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeIndexSelectAdapter.kt +++ /dev/null @@ -1,78 +0,0 @@ -package de.westnordost.streetcomplete.screens.main.teammode - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import de.westnordost.streetcomplete.databinding.CellTeamModeColorCircleSelectBinding -import de.westnordost.streetcomplete.util.Listeners - -class TeamModeIndexSelectAdapter : RecyclerView.Adapter() { - var count: Int = 0 - set(value) { - deselect() - field = value - notifyDataSetChanged() - } - - private var selectedIndex: Int? = null - set(index) { - val oldIndex = field - field = index - - oldIndex?.let { notifyItemChanged(it) } - index?.let { notifyItemChanged(it) } - listeners.forEach { it.onSelectedIndexChanged(index) } - } - - val listeners = Listeners() - - interface OnSelectedIndexChangedListener { - fun onSelectedIndexChanged(index: Int?) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = CellTeamModeColorCircleSelectBinding.inflate(inflater, parent, false) - val holder = ViewHolder(binding) - holder.onClickListener = ::toggle - return holder - } - - private fun toggle(index: Int) { - if (index < 0 || index >= count) { - throw ArrayIndexOutOfBoundsException(index) - } - - selectedIndex = if (index == selectedIndex) null else index - } - - private fun deselect() { - selectedIndex = null - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(position) - holder.itemView.isSelected = selectedIndex == position - } - - override fun getItemCount() = count - - class ViewHolder(val binding: CellTeamModeColorCircleSelectBinding) : RecyclerView.ViewHolder(binding.root) { - var onClickListener: ((index: Int) -> Unit)? = null - set(value) { - field = value - if (value == null) { - itemView.setOnClickListener(null) - } else { - itemView.setOnClickListener { - val index = adapterPosition - if (index != RecyclerView.NO_POSITION) value.invoke(index) - } - } - } - - fun bind(index: Int) { - binding.teamModeColorCircle.setIndexInTeam(index) - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeWizard.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeWizard.kt new file mode 100644 index 0000000000..b246b876d7 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/teammode/TeamModeWizard.kt @@ -0,0 +1,321 @@ +package de.westnordost.streetcomplete.screens.main.teammode + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.screens.tutorial.TutorialScreen +import de.westnordost.streetcomplete.ui.common.BubblePile +import de.westnordost.streetcomplete.ui.common.WheelPicker +import de.westnordost.streetcomplete.ui.common.WheelPickerState +import de.westnordost.streetcomplete.ui.common.rememberWheelPickerState +import de.westnordost.streetcomplete.ui.ktx.conditional +import de.westnordost.streetcomplete.ui.ktx.toPx +import de.westnordost.streetcomplete.ui.theme.TeamColors +import de.westnordost.streetcomplete.ui.theme.headlineLarge +import de.westnordost.streetcomplete.ui.theme.selectionBackground +import kotlinx.coroutines.delay + +/** Wizard which enables team mode */ +@Composable +fun TeamModeWizard( + onDismissRequest: () -> Unit, + onFinished: (teamSize: Int, indexInTeam: Int) -> Unit, + allQuestIconIds: List, +) { + val teamSizes = remember { (2..TeamColors.size).toList() } + val teamSizeState = rememberWheelPickerState() + var indexInTeam by remember { mutableIntStateOf(-1) } + val teamSize = teamSizes[teamSizeState.selectedItemIndex] + + TutorialScreen( + pageCount = 3, + onDismissRequest = onDismissRequest, + onFinished = { onFinished(teamSize, indexInTeam) }, + dismissOnBackPress = true, + nextIsEnabled = { page -> + if (page == 2 && indexInTeam !in 0.. + val selectedIndex = if (page > 1) indexInTeam else -1 + AnimatedContent( + targetState = page > 0, + transitionSpec = { fadeIn(tween(600)) togetherWith fadeOut(tween(600)) } + ) { + when (it) { + false -> SplitQuestsIllustration(allQuestIconIds = allQuestIconIds) + true -> TeamSizeIllustration(teamSize = teamSize, selectedIndex = selectedIndex) + } + } + } + ) { page -> + Column( + modifier = Modifier.fillMaxSize(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (page) { + 0 -> TeamModeDescription() + 1 -> TeamModeTeamSizeInput( + teamSizes = teamSizes, + teamSizeState = teamSizeState + ) + 2 -> TeamModeColorSelect( + teamSize = teamSize, + selectedIndex = indexInTeam, + onSelectedIndex = { indexInTeam = it } + ) + } + } + } +} + +@Composable +private fun TeamModeDescription() { + Text( + text = stringResource(R.string.team_mode), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.team_mode_description), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp) + ) +} + +@Composable +private fun TeamModeTeamSizeInput( + teamSizes: List, + teamSizeState: WheelPickerState +) { + Text( + text = stringResource(R.string.team_mode_team_size_label2), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center + ) + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.headlineLarge) { + WheelPicker( + items = teamSizes, + modifier = Modifier + .padding(top = 24.dp) + .width(96.dp), + visibleAdjacentItems = 1, + state = teamSizeState, + ) { + Text(it.toString()) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TeamModeColorSelect( + teamSize: Int, + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, +) { + Text( + text = stringResource(R.string.team_mode_choose_color2), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center + ) + FlowRow( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(top = 24.dp) + ) { + for (index in 0.. +) { + val padding = remember { Animatable(0f) } + val divider = remember { Animatable(0f) } + LaunchedEffect(allQuestIconIds) { + delay(1000) + padding.animateTo(1f, tween(1000)) + divider.animateTo(1f, tween(500)) + } + + val arrangement = Arrangement.spacedBy((-48 + 64 * padding.value).dp) + val dividerColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + val dividerWidth = 4.dp.toPx() + Column( + modifier = Modifier + .fillMaxSize() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(center.x, 0f), + end = Offset(center.x, size.height * divider.value), + strokeWidth = dividerWidth, + ) + drawLine( + color = dividerColor, + start = Offset(0f, center.y), + end = Offset(size.width * divider.value, center.y), + strokeWidth = dividerWidth, + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = arrangement + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = arrangement + ) { + QuestPile(allQuestIconIds, Modifier.weight(1f)) + QuestPile(allQuestIconIds, Modifier.weight(1f)) + } + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = arrangement + ) { + QuestPile(allQuestIconIds, Modifier.weight(1f)) + QuestPile(allQuestIconIds, Modifier.weight(1f)) + } + } +} + +@Composable +private fun QuestPile( + allQuestIconIds: List, + modifier: Modifier = Modifier, +) { + BubblePile( + count = 15, + allIconsIds = allQuestIconIds, + bubbleSize = 50.dp, + modifier = modifier + ) +} + +@Composable +private fun TeamSizeIllustration(teamSize: Int, selectedIndex: Int) { + for (i in hands.indices) { + val hasSelection = selectedIndex >= 0 + val isSelected = selectedIndex == i + val animatedSelected by animateFloatAsState(if (isSelected) 1f else 0f) + + AnimatedVisibility( + visible = i < teamSize, + modifier = Modifier + .fillMaxSize() + .zIndex(if (isSelected) 1f else 0f) + .scale(1f + animatedSelected * 0.5f), + enter = fadeIn(), + exit = fadeOut() + ) { + val colorFilter = if (hasSelection) ColorFilter.saturation(animatedSelected) else null + Image( + painter = painterResource(hands[i]), + contentDescription = null, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +private fun ColorFilter.Companion.saturation(sat: Float) = + colorMatrix(ColorMatrix().also { it.setToSaturation(sat) }) + +private val hands = listOf( + R.drawable.team_size_1, + R.drawable.team_size_2, + R.drawable.team_size_3, + R.drawable.team_size_4, + R.drawable.team_size_5, + R.drawable.team_size_6, + R.drawable.team_size_7, + R.drawable.team_size_8, + R.drawable.team_size_9, + R.drawable.team_size_10, + R.drawable.team_size_11, + R.drawable.team_size_12, +) + +@Preview +@Composable +private fun PreviewTeamModeWizard() { + TeamModeWizard( + onDismissRequest = { }, + onFinished = { _, _ -> }, + allQuestIconIds = listOf( + R.drawable.ic_quest_bicycle_parking, + R.drawable.ic_quest_building, + R.drawable.ic_quest_drinking_water, + R.drawable.ic_quest_notes, + R.drawable.ic_quest_street_surface, + R.drawable.ic_quest_wheelchair, + ) + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigDialog.kt new file mode 100644 index 0000000000..31d833af94 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigDialog.kt @@ -0,0 +1,39 @@ +package de.westnordost.streetcomplete.screens.main.urlconfig + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import de.westnordost.streetcomplete.ui.common.dialogs.ConfirmationDialog + +/** Dialog that asks user for confirmation whether he wants to apply a preset with the given name */ +@Composable +fun ApplyUrlConfigDialog( + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, + presetName: String?, + presetNameAlreadyExists: Boolean, +) { + ConfirmationDialog( + onDismissRequest = onDismissRequest, + onConfirmed = onConfirmed, + title = { Text(stringResource(R.string.urlconfig_apply_title)) }, + text = { + val name = presetName ?: stringResource(R.string.quest_presets_default_name) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.urlconfig_apply_message, "\"$name\"")) + Text(stringResource(R.string.urlconfig_switch_hint)) + if (presetNameAlreadyExists) { + Text( + text = stringResource(R.string.urlconfig_apply_message_overwrite), + fontWeight = FontWeight.Bold + ) + } + } + } + ) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigEffect.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigEffect.kt new file mode 100644 index 0000000000..49f11cbfb6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/urlconfig/ApplyUrlConfigEffect.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.screens.main.urlconfig + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import de.westnordost.streetcomplete.data.urlconfig.UrlConfig + +/** Offer to apply the given url config */ +@Composable +fun ApplyUrlConfigEffect( + urlConfig: UrlConfig, + presetNameAlreadyExists: Boolean, + onApplyUrlConfig: (urlConfig: UrlConfig) -> Unit +) { + var showApplyUrlConfigDialog by remember { mutableStateOf(false) } + + LaunchedEffect(urlConfig) { showApplyUrlConfigDialog = true } + + if (showApplyUrlConfigDialog) { + ApplyUrlConfigDialog( + onDismissRequest = { showApplyUrlConfigDialog = false }, + onConfirmed = { onApplyUrlConfig(urlConfig) }, + presetName = urlConfig.presetName, + presetNameAlreadyExists = presetNameAlreadyExists + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt index 54306e3a42..9bd10b3014 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/IntroTutorialScreen.kt @@ -15,7 +15,11 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt deleted file mode 100644 index 1b061dd243..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package de.westnordost.streetcomplete.screens.tutorial - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material.Surface -import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.ui.util.composableContent - -class OverlaysTutorialFragment : Fragment() { - - interface Listener { - fun onOverlaysTutorialFinished() - } - private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - composableContent { - Surface { - OverlaysTutorialScreen( - onDismissRequest = {}, - onFinished = { listener?.onOverlaysTutorialFinished() }, - dismissOnBackPress = false - ) - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt index 96974aafd9..c4c1928a04 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/OverlaysTutorialScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt deleted file mode 100644 index cf4da30d65..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/tutorial/TutorialFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package de.westnordost.streetcomplete.screens.tutorial - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material.Surface -import androidx.fragment.app.Fragment -import de.westnordost.streetcomplete.ui.util.composableContent - -class TutorialFragment : Fragment() { - - interface Listener { - fun onTutorialFinished() - } - private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - composableContent { - Surface { - IntroTutorialScreen( - onDismissRequest = {}, - onFinished = { listener?.onTutorialFinished() }, - dismissOnBackPress = false, - ) - } - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt index 6ebc554d55..510760e3c5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/user/achievements/AchievementIcon.kt @@ -79,9 +79,11 @@ private fun Modifier.levelLabelBackground() = .padding(horizontal = 10.dp, vertical = 4.dp) object AchievementFrameShape : Shape { + private val path = PathParser() + .parsePathString("m0.55404 0.97761c-0.029848 0.029846-0.078236 0.029846-0.10808 0l-0.42357-0.42357c-0.029848-0.029848-0.029848-0.078239 0-0.10808l0.42357-0.42357c0.029846-0.029846 0.078236-0.029846 0.10808 0l0.42357 0.42357c0.029846 0.029846 0.029846 0.078236 0 0.10808z") + .toPath() + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { - val pathStr = "m0.55404 0.97761c-0.029848 0.029846-0.078236 0.029846-0.10808 0l-0.42357-0.42357c-0.029848-0.029848-0.029848-0.078239 0-0.10808l0.42357-0.42357c0.029846-0.029846 0.078236-0.029846 0.10808 0l0.42357 0.42357c0.029846 0.029846 0.029846 0.078236 0 0.10808z" - val path = PathParser().parsePathString(pathStr).toPath() path.transform(Matrix().apply { scale(size.width, size.height, 1f) }) return Outline.Generic(path) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt index d53d0c9420..2b76cd1db7 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/ActionIcons.kt @@ -40,3 +40,48 @@ fun OpenInBrowserIcon() { fun NextScreenIcon() { Icon(painterResource(R.drawable.ic_chevron_next_24dp), null) } + +@Composable +fun UndoIcon() { + Icon(painterResource(R.drawable.ic_undo_24dp), stringResource(R.string.action_undo)) +} + +@Composable +fun OverlaysIcon() { + Icon(painterResource(R.drawable.ic_overlay_black_24dp), stringResource(R.string.action_overlays)) +} + +@Composable +fun UploadIcon() { + Icon(painterResource(R.drawable.ic_file_upload_24dp), stringResource(R.string.action_upload)) +} + +@Composable +fun MessagesIcon() { + Icon(painterResource(R.drawable.ic_email_24dp), stringResource(R.string.action_messages)) +} + +@Composable +fun MenuIcon() { + Icon(painterResource(R.drawable.ic_menu_24dp), stringResource(R.string.map_btn_menu)) +} + +@Composable +fun ZoomInIcon() { + Icon(painterResource(R.drawable.ic_add_24dp), stringResource(R.string.map_btn_zoom_in)) +} + +@Composable +fun ZoomOutIcon() { + Icon(painterResource(R.drawable.ic_subtract_24dp), stringResource(R.string.map_btn_zoom_out)) +} + +@Composable +fun StopRecordingIcon() { + Icon(painterResource(R.drawable.ic_stop_recording_24dp), stringResource(R.string.map_btn_stop_track)) +} + +@Composable +fun LargeCreateIcon() { + Icon(painterResource(R.drawable.ic_crosshair_32dp), stringResource(R.string.action_create_new_poi)) +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/AnimatedInPlaceVisibility.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/AnimatedInPlaceVisibility.kt new file mode 100644 index 0000000000..417daf8393 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/AnimatedInPlaceVisibility.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.ui.common + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha + +@Composable +fun AnimatedInPlaceVisibility( + visible: Boolean, + modifier: Modifier = Modifier, + animationSpec: AnimationSpec = tween(durationMillis = 400, delayMillis = 0, easing = EaseInOut), + label: String = "AnimatedInPlaceVisibility", + content: @Composable () -> Unit +) { + val alpha by animateFloatAsState( + if (visible) 1f else 0f, + label = label, + animationSpec = animationSpec, + ) + + Box(modifier = modifier.alpha(alpha)) { + content.invoke() + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/BubblePile.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/BubblePile.kt new file mode 100644 index 0000000000..c780090485 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/BubblePile.kt @@ -0,0 +1,79 @@ +package de.westnordost.streetcomplete.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Colors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.R +import kotlin.random.Random + +@Composable +fun BubblePile( + count: Int, + allIconsIds: List, + modifier: Modifier = Modifier, + bubbleSize: Dp = 50.dp +) { + val bubbles = remember(count, allIconsIds) { + (0.. Unit)? = null, + style: TextStyle = LocalTextStyle.current +) { + val haloColor2 = LocalElevationOverlay.current?.apply(haloColor, elevation) ?: haloColor + val stroke = Stroke(haloWidth.toPx() * 2, cap = StrokeCap.Round, join = StrokeJoin.Round) + Box { + Counter( + count, modifier, clip, haloColor2, fontSize, fontStyle, fontWeight, fontFamily, + letterSpacing, textDecoration, textAlign, lineHeight, onTextLayout, + style.copy(drawStyle = stroke) + ) + Counter( + count, modifier, clip, color, fontSize, fontStyle, fontWeight, fontFamily, + letterSpacing, textDecoration, textAlign, lineHeight, null, style + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/TextWithHalo.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/TextWithHalo.kt new file mode 100644 index 0000000000..f5317d9a9f --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/TextWithHalo.kt @@ -0,0 +1,71 @@ +package de.westnordost.streetcomplete.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.LocalElevationOverlay +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import de.westnordost.streetcomplete.ui.ktx.toPx + +/* It would have been cleaner to have this as kind of an extension to TextStyle, a custom modifier + or something. But either neither of the two is possible or I am not experienced enough in Compose + to devise that. */ + +/** A text that has a halo. */ +@Composable +fun TextWithHalo( + text: String, + modifier: Modifier = Modifier, + haloColor: Color = MaterialTheme.colors.surface, + haloWidth: Dp = 2.dp, + elevation: Dp = 0.dp, + color: Color = contentColorFor(haloColor), + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + style: TextStyle = LocalTextStyle.current +) { + val haloColor2 = LocalElevationOverlay.current?.apply(haloColor, elevation) ?: haloColor + // * 2 because the stroke is painted half outside and half inside of the text shape + val stroke = Stroke(haloWidth.toPx() * 2, cap = StrokeCap.Round, join = StrokeJoin.Round) + Box { + Text( + text, modifier, haloColor2, fontSize, fontStyle, fontWeight, fontFamily, + letterSpacing, textDecoration, textAlign, lineHeight, overflow, softWrap, maxLines, + minLines, onTextLayout, style.copy(drawStyle = stroke) + ) + Text( + text, modifier, color, fontSize, fontStyle, fontWeight, fontFamily, letterSpacing, + textDecoration, textAlign, lineHeight, overflow, softWrap, maxLines, minLines, + null, style + ) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/WheelPicker.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/WheelPicker.kt new file mode 100644 index 0000000000..18b4d5fb76 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/WheelPicker.kt @@ -0,0 +1,229 @@ +package de.westnordost.streetcomplete.ui.common + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.inset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import de.westnordost.streetcomplete.ui.ktx.pxToDp +import kotlinx.coroutines.launch + +@Composable +fun rememberWheelPickerState(selectedItemIndex: Int = 0) = + remember { WheelPickerState(selectedItemIndex) } + +class WheelPickerState(selectedItemIndex: Int = 0) : ScrollableState { + + internal val lazyListState = LazyListState(firstVisibleItemIndex = selectedItemIndex) + + val selectedItemIndex: Int by derivedStateOf { + selectedItemInfo?.index ?: selectedItemIndex + } + + internal val selectedItemInfo: LazyListItemInfo? by derivedStateOf { + lazyListState.layoutInfo.findCenterItem() + } + + override val canScrollBackward: Boolean + get() = lazyListState.canScrollBackward + + override val canScrollForward: Boolean + get() = lazyListState.canScrollForward + + override val isScrollInProgress: Boolean + get() = lazyListState.isScrollInProgress + + suspend fun scrollToItem(index: Int) { + lazyListState.scrollToItem(index) + } + + suspend fun animateScrollToItem(index: Int) { + lazyListState.animateScrollToItem(index) + } + + override fun dispatchRawDelta(delta: Float): Float { + return lazyListState.dispatchRawDelta(delta) + } + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) { + lazyListState.scroll(scrollPriority, block) + } +} + +/** A WheelPicker aka NumberPicker (in Android). Presents the selectable [items] on a draggable + * vertical wheel, the item displayed in the center is selected. + * [visibleAdjacentItems] determines how many adjacent items to the one that is selected should + * be displayed. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun WheelPicker( + items: List, + modifier: Modifier = Modifier, + state: WheelPickerState = rememberWheelPickerState(), + key: ((T) -> Any)? = null, + visibleAdjacentItems: Int = 1, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + content: @Composable (item: T) -> Unit +) { + val scope = rememberCoroutineScope() + + val selectedItemHeight = (state.selectedItemInfo?.size ?: 0).pxToDp() + + val paddingValues = remember(visibleAdjacentItems, selectedItemHeight) { + PaddingValues(vertical = selectedItemHeight * visibleAdjacentItems) + } + + val visibleItemsCount = visibleAdjacentItems * 2 + 1 + + LazyColumn( + modifier = modifier + .height(selectedItemHeight * visibleItemsCount) + .fadingEdges(selectedItemHeight) + .pickerIndicator(selectedItemHeight), + state = state.lazyListState, + contentPadding = paddingValues, + horizontalAlignment = horizontalAlignment, + flingBehavior = rememberSnapFlingBehavior(state.lazyListState), + ) { + items( + count = items.size, + key = if (key != null) { { key(items[it]) } } else null + ) { index -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(if (index == state.selectedItemIndex) 1f else 0.85f) + .pointerInput(index) { + detectTapGestures { + scope.launch { state.animateScrollToItem(index) } + } + } + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + content(items[index]) + } + } + } +} + +/** the selected item is at full opacity, the items then fade off towards the edges */ +@Composable +private fun Modifier.fadingEdges(selectedItemHeight: Dp): Modifier { + val topGradient = remember { + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color.Black + ) + } + val bottomGradient = remember { + Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent + ) + } + + return this + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + + val height = (size.height - selectedItemHeight.toPx()) / 2 + + drawRect( + topLeft = Offset.Zero, + size = size.copy(height = height), + brush = topGradient, + blendMode = BlendMode.DstIn + ) + drawRect( + topLeft = Offset(0f, size.height - height), + size = size.copy(height = height), + brush = bottomGradient, + blendMode = BlendMode.DstIn + ) + } +} + +/** frame drawn around selected value */ +@Composable +private fun Modifier.pickerIndicator(selectedItemHeight: Dp): Modifier { + val density = LocalDensity.current.density + val color = MaterialTheme.colors.onSurface + + return drawWithContent { + drawContent() + + val strokeWidth = 2f * density + val inset = (size.height - selectedItemHeight.toPx()) / 2 + + inset(vertical = inset.coerceAtMost(size.height / 2)) { + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = strokeWidth, + ) + drawLine( + color = color, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth, + ) + } + } +} + +private fun LazyListLayoutInfo.findCenterItem(): LazyListItemInfo? = + visibleItemsInfo.fastFirstOrNull { + it.offset + it.size - viewportStartOffset > viewportSize.height / 2 + } + +@Preview +@Composable +private fun PreviewPicker() { + WheelPicker( + items = (1..100).toList(), + ) { item -> + Text(item.toString()) + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/AlertDialogLayout.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/AlertDialogLayout.kt new file mode 100644 index 0000000000..daaaa035e3 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/AlertDialogLayout.kt @@ -0,0 +1,72 @@ +package de.westnordost.streetcomplete.ui.common.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +/** The layout used in alert dialogs. Used for mimicing the appearance of alert dialogs when not + * using AlertDialog */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AlertDialogLayout( + modifier: Modifier = Modifier, + title: (@Composable () -> Unit)? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, + buttons: (@Composable FlowRowScope.() -> Unit)? = null, + shape: Shape = MaterialTheme.shapes.medium, + backgroundColor: Color = MaterialTheme.colors.surface, + contentColor: Color = contentColorFor(backgroundColor), +) { + Surface( + modifier = modifier, + shape = shape, + color = backgroundColor, + contentColor = contentColor, + ) { + Column(Modifier.padding(top = 24.dp)) { + if (title != null) { + CompositionLocalProvider( + LocalContentAlpha provides ContentAlpha.high, + LocalTextStyle provides MaterialTheme.typography.subtitle1 + ) { + Column(Modifier.padding(start = 24.dp, bottom = 16.dp, end = 24.dp)) { + title() + } + } + } + if (content != null) { + CompositionLocalProvider( + LocalContentAlpha provides ContentAlpha.medium, + LocalTextStyle provides MaterialTheme.typography.body2 + ) { + content() + } + } + if (buttons != null) { + FlowRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { buttons() } + } + } + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ListPickerDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ListPickerDialog.kt index 18c51b6d54..d4e142a092 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ListPickerDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ListPickerDialog.kt @@ -2,6 +2,7 @@ package de.westnordost.streetcomplete.ui.common.dialogs import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -37,6 +38,7 @@ import de.westnordost.streetcomplete.ui.theme.AppTheme /** List picker dialog with OK and cancel button that expands to its maximum possible size in both * directions, scrollable. * (See explanation in ScrollableAlertDialog why it expands to the maximum possible size)*/ +@OptIn(ExperimentalLayoutApi::class) @Composable fun ListPickerDialog( onDismissRequest: () -> Unit, @@ -122,15 +124,13 @@ fun ListPickerDialog( @Composable private fun PreviewListPickerDialog() { val items = remember { (0..<5).toList() } - AppTheme { - ListPickerDialog( - onDismissRequest = {}, - items = items, - onItemSelected = {}, - title = { Text("Select something") }, - selectedItem = 2, - getItemName = { "Item $it" }, - width = 260.dp - ) - } + ListPickerDialog( + onDismissRequest = {}, + items = items, + onItemSelected = {}, + title = { Text("Select something") }, + selectedItem = 2, + getItemName = { "Item $it" }, + width = 260.dp + ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ScrollableAlertDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ScrollableAlertDialog.kt index 28a4de7181..3c96fde75e 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ScrollableAlertDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/ScrollableAlertDialog.kt @@ -1,28 +1,21 @@ package de.westnordost.streetcomplete.ui.common.dialogs -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ContentAlpha import androidx.compose.material.Divider -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @@ -33,7 +26,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import de.westnordost.streetcomplete.ui.ktx.conditional -import de.westnordost.streetcomplete.ui.theme.AppTheme // TODO Compose // AlertDialog does not support scrollable content (yet) https://issuetracker.google.com/issues/217151230 @@ -53,7 +45,7 @@ fun ScrollableAlertDialog( modifier: Modifier = Modifier, title: (@Composable () -> Unit)? = null, content: (@Composable ColumnScope.() -> Unit)? = null, - buttons: (@Composable () -> Unit)? = null, + buttons: (@Composable FlowRowScope.() -> Unit)? = null, width: Dp? = null, height: Dp? = null, shape: Shape = MaterialTheme.shapes.medium, @@ -65,70 +57,45 @@ fun ScrollableAlertDialog( onDismissRequest = onDismissRequest, properties = properties ) { - Surface( + AlertDialogLayout( modifier = modifier .conditional(width != null) { width(width!!) } .conditional(height != null) { height(height!!) }, + title = title, + content = if (content != null) {{ + Divider() + Column(Modifier.weight(1f)) { content() } + Divider() + }} else null, + buttons = buttons, shape = shape, - color = backgroundColor, + backgroundColor = backgroundColor, contentColor = contentColor - ) { - Column(Modifier.padding(top = 24.dp)) { - if (title != null) { - CompositionLocalProvider( - LocalContentAlpha provides ContentAlpha.high, - LocalTextStyle provides MaterialTheme.typography.subtitle1 - ) { - Column(Modifier.padding(start = 24.dp, bottom = 16.dp, end = 24.dp)) { - title() - } - } - } - if (content != null) { - CompositionLocalProvider( - LocalContentAlpha provides ContentAlpha.medium, - LocalTextStyle provides MaterialTheme.typography.body2 - ) { - Divider() - Column(Modifier.weight(1f)) { content() } - Divider() - } - } - if (buttons != null) { - FlowRow( - modifier = Modifier - .padding(horizontal = 8.dp) - .align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { buttons() } - } - } - } + ) } } +@OptIn(ExperimentalLayoutApi::class) @Preview @Composable private fun PreviewScrollableAlertDialog() { val loremIpsum = remember { LoremIpsum(200).values.first() } val scrollState = rememberScrollState() - AppTheme { - ScrollableAlertDialog( - onDismissRequest = {}, - title = { Text("Title") }, - content = { - Text( - text = loremIpsum, - modifier = Modifier - .padding(horizontal = 24.dp) - .verticalScroll(scrollState) - ) - }, - buttons = { - TextButton(onClick = {}) { Text("Cancel") } - TextButton(onClick = {}) { Text("OK") } - }, - width = 260.dp - ) - } + ScrollableAlertDialog( + onDismissRequest = {}, + title = { Text("Title") }, + content = { + Text( + text = loremIpsum, + modifier = Modifier + .padding(horizontal = 24.dp) + .verticalScroll(scrollState) + ) + }, + buttons = { + TextButton(onClick = {}) { Text("Cancel") } + TextButton(onClick = {}) { Text("OK") } + }, + width = 260.dp + ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt index f5cfa87e7e..9b81d9faa5 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/common/dialogs/SimpleListPickerDialog.kt @@ -128,15 +128,13 @@ fun SimpleListPickerDialog( @Composable private fun PreviewSimpleListPickerDialog() { val items = remember { (0..<5).toList() } - AppTheme { - SimpleListPickerDialog( - onDismissRequest = {}, - items = items, - onItemSelected = {}, - title = { Text("Select something") }, - selectedItem = 2, - getItemName = { "Item $it" }, - width = 200.dp - ) - } + SimpleListPickerDialog( + onDismissRequest = {}, + items = items, + onItemSelected = {}, + title = { Text("Select something") }, + selectedItem = 2, + getItemName = { "Item $it" }, + width = 200.dp + ) } diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Dp.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Dp.kt index 5d8f048064..b250096bbc 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Dp.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Dp.kt @@ -12,6 +12,13 @@ fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } +@Composable +@ReadOnlyComposable +fun Float.pxToDp() = with(LocalDensity.current) { + this@pxToDp.toDp() +} + + @Composable @ReadOnlyComposable fun Int.pxToSp() = with(LocalDensity.current) { @@ -29,3 +36,9 @@ fun TextUnit.toDp() = with(LocalDensity.current) { fun Dp.dpToSp() = with(LocalDensity.current) { this@dpToSp.toSp() } + +@Composable +@ReadOnlyComposable +fun Dp.toPx() = with(LocalDensity.current) { + this@toPx.toPx() +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/LazyListState.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/LazyListState.kt index 9f574c6cd7..134558bd72 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/LazyListState.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/LazyListState.kt @@ -6,3 +6,10 @@ val LazyListState.isScrolledToEnd: Boolean get() { val lastItem = layoutInfo.visibleItemsInfo.lastOrNull() return lastItem == null || lastItem.offset + lastItem.size <= layoutInfo.viewportEndOffset } + +fun LazyListState.isItemAtIndexFullyVisible(index: Int): Boolean { + val item = layoutInfo.visibleItemsInfo.find { it.index == index } + return item != null && + item.offset >= 0 && + item.offset + item.size <= layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Modifier.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Modifier.kt index 8db5b02ea5..10f5bb43ae 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Modifier.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/ktx/Modifier.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.ui.ktx +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -11,9 +13,66 @@ import androidx.compose.ui.graphics.drawscope.inset import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp -fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier = +@Composable +fun Modifier.conditional( + condition: Boolean, + modifier: @Composable Modifier.() -> Modifier +): Modifier = if (condition) then(modifier(Modifier)) else this +/** set padding proportional to the composable's size */ +fun Modifier.proportionalPadding( + start: Float = 0f, + top: Float = 0f, + end: Float = 0f, + bottom: Float = 0f +) = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + val height = placeable.height + val startPad = (start * width).toInt() + val endPad = (end * width).toInt() + val topPad = (top * height).toInt() + val bottomPad = (bottom * height).toInt() + layout( + width = width + startPad + endPad, + height = height + topPad + bottomPad, + ) { + placeable.placeRelative(x = startPad, y = topPad) + } +} + +/** set padding proportional to the composable's size */ +fun Modifier.proportionalPadding( + horizontal: Float = 0f, + vertical: Float = 0f +) = proportionalPadding( + start = horizontal, + end = horizontal, + top = vertical, + bottom = vertical +) + +/** set padding proportional to the composable's size */ +fun Modifier.proportionalPadding(all: Float = 0f) = + proportionalPadding(all, all, all, all) + +/** set absolute offset proportional to the composable's size */ +fun Modifier.proportionalAbsoluteOffset( + x: Float = 0f, + y: Float = 0f +) = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + val height = placeable.height + layout(width, height) { + placeable.place( + x = (x * width).toInt(), + y = (y * height).toInt() + ) + } +} + /** Like border, but stroke is inside the element */ fun Modifier.innerBorder( width: Dp, diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt index 84d32828c4..1cafbb2378 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Color.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.ui.theme import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.Composable @@ -19,18 +20,20 @@ val TrafficGrayA = Color(0xff8e9291) val TrafficGrayB = Color(0xff4f5250) /* Colors for the teams in team mode. */ -val Team0 = Color(0xfff44336) -val Team1 = Color(0xff529add) -val Team2 = Color(0xffffdd55) -val Team3 = Color(0xffca72e2) -val Team4 = Color(0xff9bbe55) -val Team5 = Color(0xfff4900c) -val Team6 = Color(0xff9aa0ad) -val Team7 = Color(0xff6390a0) -val Team8 = Color(0xffa07a43) -val Team9 = Color(0xff494EAD) -val Team10 = Color(0xffAA335D) -val Team11 = Color(0xff655555) +val TeamColors = arrayOf( + Color(0xfff44336), + Color(0xff529add), + Color(0xFFFBC02D), + Color(0xffca72e2), + Color(0xff9bbe55), + Color(0xfff4900c), + Color(0xff9aa0ad), + Color(0xff6390a0), + Color(0xffa07a43), + Color(0xff494EAD), + Color(0xffAA335D), + Color(0xff655555), +) val White = Color(0xffffffff) @@ -56,6 +59,9 @@ val DarkColors = darkColors( onSecondary = Color.White ) +val Colors.selectionBackground @ReadOnlyComposable @Composable get() = + MaterialTheme.colors.secondary.copy(alpha = 0.5f) + val Colors.surfaceContainer @ReadOnlyComposable @Composable get() = if (isLight) Color(0xffdddddd) else Color(0xff222222) diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Dimensions.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Dimensions.kt new file mode 100644 index 0000000000..b450e05f42 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/theme/Dimensions.kt @@ -0,0 +1,13 @@ +package de.westnordost.streetcomplete.ui.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun getMaxQuestFormWidth(totalWidth: Dp): Dp = + if (totalWidth >= 820.dp) 480.dp + else if (totalWidth >= 600.dp) 360.dp + else 480.dp + +fun getQuestFormPeekHeight(isLandscape: Boolean): Dp = + if (isLandscape) 442.dp + else 352.dp diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/Color.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Color.kt new file mode 100644 index 0000000000..69f26613cd --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/Color.kt @@ -0,0 +1,10 @@ +package de.westnordost.streetcomplete.ui.util + +import androidx.compose.ui.graphics.Color + +fun interpolateColors(color1: Color, color2: Color, progress: Float) = Color( + red = color1.red * (1 - progress) + color2.red * progress, + green = color1.green * (1 - progress) + color2.green * progress, + blue = color1.blue * (1 - progress) + color2.blue * progress, + alpha = color1.alpha * (1 - progress) + color2.alpha * progress, +) diff --git a/app/src/main/java/de/westnordost/streetcomplete/ui/util/CompositionLocals.kt b/app/src/main/java/de/westnordost/streetcomplete/ui/util/CompositionLocals.kt new file mode 100644 index 0000000000..2189686560 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/ui/util/CompositionLocals.kt @@ -0,0 +1,12 @@ +package de.westnordost.streetcomplete.ui.util + +import androidx.compose.runtime.staticCompositionLocalOf +import de.westnordost.streetcomplete.util.SoundFx + +val LocalSoundFx = staticCompositionLocalOf { + noLocalProvidedFor("LocalSoundFx") +} + +private fun noLocalProvidedFor(name: String): Nothing { + error("CompositionLocal $name not present") +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt b/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt index d155ada627..629fc02e1a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/CrashReportExceptionHandler.kt @@ -1,43 +1,28 @@ package de.westnordost.streetcomplete.util -import android.app.Activity import android.content.Context import android.os.Build -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import de.westnordost.streetcomplete.ApplicationConstants import de.westnordost.streetcomplete.BuildConfig -import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.data.logs.LogsController import de.westnordost.streetcomplete.data.logs.format import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds -import de.westnordost.streetcomplete.util.ktx.sendEmail -import de.westnordost.streetcomplete.util.ktx.toast -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.IOException -import java.io.PrintWriter -import java.io.StringWriter +import kotlinx.io.IOException import java.util.Locale -/** Exception handler that takes care of asking the user to send the report of the last crash - * to the email address [mailReportTo]. +/** Exception handler that takes care of storing the last crash as a file. * When a crash occurs, the stack trace is saved to [crashReportFile] so that it can be accessed * on next startup */ class CrashReportExceptionHandler( - private val appCtx: Context, + private val context: Context, private val logsController: LogsController, - private val mailReportTo: String, private val crashReportFile: String ) : Thread.UncaughtExceptionHandler { private var defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null fun install(): Boolean { - val installerPackageName = appCtx.packageManager.getInstallerPackageName(appCtx.packageName) + val installerPackageName = context.packageManager.getInstallerPackageName(context.packageName) // developer. Don't need this functionality (it might even interfere with unit tests) if (installerPackageName == null) return false // don't need this for google play users: they have their own crash reports @@ -49,92 +34,67 @@ class CrashReportExceptionHandler( return true } - fun askUserToSendCrashReportIfExists(activityCtx: Activity) { - if (hasCrashReport()) { - val reportText = readCrashReportFromFile() - deleteCrashReport() - askUserToSendErrorReport(activityCtx, R.string.crash_title, reportText) - } - } - - fun askUserToSendErrorReport(activityCtx: AppCompatActivity, @StringRes titleResourceId: Int, e: Exception) { - activityCtx.lifecycleScope.launch { - val reportText = withContext(Dispatchers.IO) { createErrorReport(e, null) } - askUserToSendErrorReport(activityCtx, titleResourceId, reportText) - } - } - - private fun askUserToSendErrorReport(activityCtx: Activity, @StringRes titleResourceId: Int, reportText: String?) { - val mailText = "Describe how to reproduce it here:\n\n\n\n$reportText" - - AlertDialog.Builder(activityCtx) - .setTitle(titleResourceId) - .setMessage(R.string.crash_message) - .setPositiveButton(R.string.crash_compose_email) { _, _ -> - activityCtx.sendEmail(mailReportTo, "Error Report", mailText) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - activityCtx.toast("\uD83D\uDE22") - } - .setCancelable(false) - .show() - } - override fun uncaughtException(thread: Thread, error: Throwable) { val report = createErrorReport(error, thread) - writeCrashReportToFile(report) - defaultUncaughtExceptionHandler!!.uncaughtException(thread, error) + saveCrashReport(report) + defaultUncaughtExceptionHandler?.uncaughtException(thread, error) } - private fun createErrorReport(error: Throwable, thread: Thread?): String { - val stackTrace = StringWriter() - error.printStackTrace(PrintWriter(stackTrace)) - - val logText = readLogFromDatabase() + fun popCrashReport(): String? { + if (hasCrashReport()) { + val errorReport = loadCrashReport() + deleteCrashReport() + return errorReport + } else { + return null + } + } - var report = "" + fun createErrorReport(error: Throwable, thread: Thread? = null): String { + val report = StringBuilder("") if (thread != null) { - report += "Thread: ${thread.name}" + report.append("Thread: ${thread.name}") } - report += """ - App version: ${BuildConfig.VERSION_NAME} - Device: ${Build.BRAND} ${Build.DEVICE}, Android ${Build.VERSION.RELEASE} - Locale: ${Locale.getDefault()} + report.append(""" + App version: ${BuildConfig.VERSION_NAME} + Device: ${Build.BRAND} ${Build.DEVICE}, Android ${Build.VERSION.RELEASE} + Locale: ${Locale.getDefault()} - Stack trace: + Stack trace: - """.trimIndent() + """.trimIndent() + ) - report += stackTrace + report.append(error.stackTraceToString()) - report += "\nLog:\n" - report += logText + report.append("\nLog:\n") + report.append(readLogFromDatabase()) - return report + return report.toString() } - private fun writeCrashReportToFile(text: String) { + private fun saveCrashReport(text: String) { try { - appCtx.openFileOutput(crashReportFile, Context.MODE_PRIVATE).bufferedWriter().use { it.write(text) } + context.openFileOutput(crashReportFile, Context.MODE_PRIVATE).bufferedWriter().use { it.write(text) } } catch (ignored: IOException) { } } - private fun hasCrashReport(): Boolean = appCtx.fileList().contains(crashReportFile) + private fun hasCrashReport(): Boolean = context.fileList().contains(crashReportFile) - private fun readCrashReportFromFile(): String? { + private fun loadCrashReport(): String? { try { - return appCtx.openFileInput(crashReportFile).bufferedReader().use { it.readText() } + return context.openFileInput(crashReportFile).bufferedReader().use { it.readText() } } catch (ignore: IOException) { } return null } private fun deleteCrashReport() { - appCtx.deleteFile(crashReportFile) + context.deleteFile(crashReportFile) } private fun readLogFromDatabase(): String { diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/GeoUri.kt b/app/src/main/java/de/westnordost/streetcomplete/util/GeoUri.kt index 5176af4819..6f0ca2642a 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/GeoUri.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/GeoUri.kt @@ -1,14 +1,12 @@ package de.westnordost.streetcomplete.util -import android.net.Uri -import androidx.core.net.toUri import de.westnordost.streetcomplete.util.ktx.format -fun parseGeoUri(uri: Uri): GeoLocation? { - if (uri.scheme != "geo") return null +fun parseGeoUri(uri: String): GeoLocation? { + if (!uri.startsWith("geo:")) return null val geoUriRegex = Regex("(-?[0-9]*\\.?[0-9]+),(-?[0-9]*\\.?[0-9]+).*?(?:\\?z=([0-9]*\\.?[0-9]+))?") - val match = geoUriRegex.matchEntire(uri.schemeSpecificPart) ?: return null + val match = geoUriRegex.matchEntire(uri.substringAfter("geo:")) ?: return null val latitude = match.groupValues[1].toDoubleOrNull() ?: return null if (latitude < -90 || latitude > +90) return null @@ -21,12 +19,11 @@ fun parseGeoUri(uri: Uri): GeoLocation? { return GeoLocation(latitude, longitude, zoom) } -fun buildGeoUri(latitude: Double, longitude: Double, zoom: Double? = null): Uri { +fun buildGeoUri(latitude: Double, longitude: Double, zoom: Double? = null): String { val z = if (zoom != null) "?z=$zoom" else "" val lat = latitude.format(5) val lon = longitude.format(5) - val geoUri = "geo:$lat,$lon$z" - return geoUri.toUri() + return "geo:$lat,$lon$z" } data class GeoLocation( diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/html/HtmlParser.kt b/app/src/main/java/de/westnordost/streetcomplete/util/html/HtmlParser.kt index 0b5d09a4b0..c42f30f538 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/html/HtmlParser.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/html/HtmlParser.kt @@ -127,7 +127,7 @@ private fun StringWithCursor.fail(message: String): Nothing = private fun Char.isAlphanumeric(): Boolean = this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' -private fun String.replaceHtmlEntities(): String = +fun String.replaceHtmlEntities(): String = replace(entityRegex) { entities[it.value]?.toString() ?: it.value } // https://developer.mozilla.org/en-US/docs/Glossary/Void_element diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt index f8f1e3decb..a3acac5e39 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/ktx/Context.kt @@ -43,11 +43,11 @@ val Context.currentDisplay: Display get() = getSystemService()!!.defaultDisplay } -fun Context.sendEmail(email: String, subject: String, text: String? = null) { +fun Context.sendEmail(to: String, subject: String, text: String? = null) { val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) - putExtra(Intent.EXTRA_SUBJECT, ApplicationConstants.USER_AGENT + " " + subject) + putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + putExtra(Intent.EXTRA_SUBJECT, subject) if (text != null) { putExtra(Intent.EXTRA_TEXT, text) } @@ -60,6 +60,12 @@ fun Context.sendEmail(email: String, subject: String, text: String? = null) { } } +fun Context.sendErrorReportEmail(errorReport: String) = sendEmail( + to = ApplicationConstants.ERROR_REPORTS_EMAIL, + subject = ApplicationConstants.USER_AGENT + " " + "Error Report", + text = "Describe how to reproduce it here:\n\n\n\n$errorReport" +) + fun Context.openUri(uri: String): Boolean = try { startActivity(Intent(Intent.ACTION_VIEW, uri.toUri())) diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt b/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt index 3bedd97c20..1b4fbdf86d 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/util/location/FineLocationManager.kt @@ -14,8 +14,6 @@ import androidx.core.content.getSystemService import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.util.Consumer -import de.westnordost.streetcomplete.util.ktx.elapsedDuration -import kotlin.time.Duration.Companion.minutes /** Convenience wrapper around the location manager with easier API, making use of both the GPS * and Network provider */ @@ -85,43 +83,3 @@ class FineLocationManager(context: Context, locationUpdateCallback: (Location) - networkCancellationSignal.cancel() } } - -// Based on https://web.archive.org/web/20180424190538/https://developer.android.com/guide/topics/location/strategies.html#BestEstimate - -/** Determines whether this Location reading is better than the previous Location fix */ -private fun Location.isBetterThan(previous: Location?): Boolean { - // Check whether this is a valid location at all. - // Happened once that lat/lon is NaN, maybe issue of that particular device - if (longitude.isNaN() || latitude.isNaN()) return false - - // A new location is always better than no location - if (previous == null) return true - - // Check whether the new location fix is newer or older - // we use elapsedRealtimeNanos instead of epoch time because some devices have issues - // that may lead to incorrect GPS location.time (e.g. GPS week rollover, but also others) - val locationTimeDiff = elapsedDuration - previous.elapsedDuration - val isMuchNewer = locationTimeDiff > 2.minutes - val isMuchOlder = locationTimeDiff < (-2).minutes - val isNewer = locationTimeDiff.isPositive() - - // Check whether the new location fix is more or less accurate - val accuracyDelta = accuracy - previous.accuracy - val isLessAccurate = accuracyDelta > 0f - val isMoreAccurate = accuracyDelta < 0f - val isMuchLessAccurate = accuracyDelta > 200f - - val isFromSameProvider = provider == previous.provider - - // Determine location quality using a combination of timeliness and accuracy - return when { - // the user has likely moved - isMuchNewer -> true - // If the new location is more than two minutes older, it must be worse - isMuchOlder -> false - isMoreAccurate -> true - isNewer && !isLessAccurate -> true - isNewer && !isMuchLessAccurate && isFromSameProvider -> true - else -> false - } -} diff --git a/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt new file mode 100644 index 0000000000..bb71d62deb --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/util/location/LocationUtils.kt @@ -0,0 +1,45 @@ +package de.westnordost.streetcomplete.util.location + +import android.location.Location +import de.westnordost.streetcomplete.util.ktx.elapsedDuration +import kotlin.time.Duration.Companion.minutes + +// Based on https://web.archive.org/web/20180424190538/https://developer.android.com/guide/topics/location/strategies.html#BestEstimate + +/** Determines whether this Location reading is better than the previous Location fix */ +fun Location.isBetterThan(previous: Location?): Boolean { + // Check whether this is a valid location at all. + // Happened once that lat/lon is NaN, maybe issue of that particular device + if (longitude.isNaN() || latitude.isNaN()) return false + + // A new location is always better than no location + if (previous == null) return true + + // Check whether the new location fix is newer or older + // we use elapsedRealtimeNanos instead of epoch time because some devices have issues + // that may lead to incorrect GPS location.time (e.g. GPS week rollover, but also others) + val locationTimeDiff = elapsedDuration - previous.elapsedDuration + val isMuchNewer = locationTimeDiff > 2.minutes + val isMuchOlder = locationTimeDiff < (-2).minutes + val isNewer = locationTimeDiff.isPositive() + + // Check whether the new location fix is more or less accurate + val accuracyDelta = accuracy - previous.accuracy + val isLessAccurate = accuracyDelta > 0f + val isMoreAccurate = accuracyDelta < 0f + val isMuchLessAccurate = accuracyDelta > 200f + + val isFromSameProvider = provider == previous.provider + + // Determine location quality using a combination of timeliness and accuracy + return when { + // the user has likely moved + isMuchNewer -> true + // If the new location is more than two minutes older, it must be worse + isMuchOlder -> false + isMoreAccurate -> true + isNewer && !isLessAccurate -> true + isNewer && !isMuchLessAccurate && isFromSameProvider -> true + else -> false + } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestLoginDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestLoginDialog.kt deleted file mode 100644 index 517703fadc..0000000000 --- a/app/src/main/java/de/westnordost/streetcomplete/view/dialogs/RequestLoginDialog.kt +++ /dev/null @@ -1,24 +0,0 @@ -package de.westnordost.streetcomplete.view.dialogs - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import de.westnordost.streetcomplete.R -import de.westnordost.streetcomplete.screens.user.UserActivity - -/** Shows a dialog that asks the user to login */ -@SuppressLint("InflateParams") -class RequestLoginDialog(context: Context) : AlertDialog(context) { - init { - val view = LayoutInflater.from(context).inflate(R.layout.dialog_authorize_now, null, false) - setView(view) - setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> - val intent = Intent(context, UserActivity::class.java) - intent.putExtra(UserActivity.EXTRA_LAUNCH_AUTH, true) - context.startActivity(intent) - } - setButton(BUTTON_NEGATIVE, context.getString(R.string.later)) { _, _ -> } - } -} diff --git a/app/src/main/res/animator/edit_history_sidebar_appear.xml b/app/src/main/res/animator/edit_history_sidebar_appear.xml deleted file mode 100644 index 18af8c03f2..0000000000 --- a/app/src/main/res/animator/edit_history_sidebar_appear.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/animator/edit_history_sidebar_disappear.xml b/app/src/main/res/animator/edit_history_sidebar_disappear.xml deleted file mode 100644 index 48071fc15c..0000000000 --- a/app/src/main/res/animator/edit_history_sidebar_disappear.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/animator/progress_wobble.xml b/app/src/main/res/animator/progress_wobble.xml deleted file mode 100644 index 61853b4623..0000000000 --- a/app/src/main/res/animator/progress_wobble.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/app/src/main/res/color/activated_tint_on_white.xml b/app/src/main/res/color/activated_tint_on_white.xml deleted file mode 100644 index 7b8a348789..0000000000 --- a/app/src/main/res/color/activated_tint_on_white.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-hdpi/ic_star_white_shadow_32dp.png b/app/src/main/res/drawable-hdpi/ic_star_white_shadow_32dp.png deleted file mode 100644 index fa81d12f5d..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_star_white_shadow_32dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_star_white_shadow_32dp.png b/app/src/main/res/drawable-mdpi/ic_star_white_shadow_32dp.png deleted file mode 100644 index fc88468f46..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_star_white_shadow_32dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_star_white_shadow_32dp.png b/app/src/main/res/drawable-xhdpi/ic_star_white_shadow_32dp.png deleted file mode 100644 index 7d0d23002d..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_star_white_shadow_32dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_white_shadow_32dp.png b/app/src/main/res/drawable-xxhdpi/ic_star_white_shadow_32dp.png deleted file mode 100644 index 36f22b362a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_star_white_shadow_32dp.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_add_48dp.xml b/app/src/main/res/drawable/ic_add_48dp.xml deleted file mode 100644 index 066fe865c1..0000000000 --- a/app/src/main/res/drawable/ic_add_48dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_animated_open_mail.xml b/app/src/main/res/drawable/ic_animated_open_mail.xml deleted file mode 100644 index 99c589c79f..0000000000 --- a/app/src/main/res/drawable/ic_animated_open_mail.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_crosshair_32dp.xml b/app/src/main/res/drawable/ic_crosshair_32dp.xml new file mode 100644 index 0000000000..8317ec9f67 --- /dev/null +++ b/app/src/main/res/drawable/ic_crosshair_32dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_24dp.xml b/app/src/main/res/drawable/ic_file_download_24dp.xml new file mode 100644 index 0000000000..3ca70c63c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_location_navigation_searching_24dp.xml b/app/src/main/res/drawable/ic_location_navigation_searching_24dp.xml deleted file mode 100644 index 09e031b30d..0000000000 --- a/app/src/main/res/drawable/ic_location_navigation_searching_24dp.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_location_searching_24dp.xml b/app/src/main/res/drawable/ic_location_searching_24dp.xml deleted file mode 100644 index c72826d5ae..0000000000 --- a/app/src/main/res/drawable/ic_location_searching_24dp.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_location_state_24dp.xml b/app/src/main/res/drawable/ic_location_state_24dp.xml deleted file mode 100644 index fa457d1268..0000000000 --- a/app/src/main/res/drawable/ic_location_state_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml deleted file mode 100644 index ba030afff0..0000000000 --- a/app/src/main/res/drawable/ic_mail.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_star_32dp.xml b/app/src/main/res/drawable/ic_star_32dp.xml new file mode 100644 index 0000000000..2b86db0fda --- /dev/null +++ b/app/src/main/res/drawable/ic_star_32dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_halo_32dp.xml b/app/src/main/res/drawable/ic_star_halo_32dp.xml new file mode 100644 index 0000000000..32d79d1f4a --- /dev/null +++ b/app/src/main/res/drawable/ic_star_halo_32dp.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/location_dot_small.xml b/app/src/main/res/drawable/location_dot_small.xml index 81aeabf739..8623221267 100644 --- a/app/src/main/res/drawable/location_dot_small.xml +++ b/app/src/main/res/drawable/location_dot_small.xml @@ -1,33 +1,12 @@ - - - - - - - - - - - - - - - - + + + diff --git a/app/src/main/res/drawable/mail_back.xml b/app/src/main/res/drawable/mail_back.xml new file mode 100644 index 0000000000..c5a70df168 --- /dev/null +++ b/app/src/main/res/drawable/mail_back.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_mail_front.xml b/app/src/main/res/drawable/mail_front.xml similarity index 66% rename from app/src/main/res/drawable/ic_mail_front.xml rename to app/src/main/res/drawable/mail_front.xml index 18fbaa5569..54135278c7 100644 --- a/app/src/main/res/drawable/ic_mail_front.xml +++ b/app/src/main/res/drawable/mail_front.xml @@ -5,14 +5,16 @@ android:viewportHeight="288"> + android:strokeWidth="6" + android:strokeLineJoin="round" + android:strokeLineCap="round" + android:fillColor="#FDF2CB" + android:strokeColor="#D3BF95"/> + android:fillColor="#F8E9B6" + android:strokeColor="#D3BF95"/> diff --git a/app/src/main/res/drawable/notification_background.xml b/app/src/main/res/drawable/notification_background.xml deleted file mode 100644 index d840c718b9..0000000000 --- a/app/src/main/res/drawable/notification_background.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/pin_selection_ring.xml b/app/src/main/res/drawable/pin_selection_ring.xml deleted file mode 100644 index 3eecacf917..0000000000 --- a/app/src/main/res/drawable/pin_selection_ring.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/quest_pin_pointer.xml b/app/src/main/res/drawable/quest_pin_pointer.xml deleted file mode 100644 index 7b83748a98..0000000000 --- a/app/src/main/res/drawable/quest_pin_pointer.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/space_24dp.xml b/app/src/main/res/drawable/space_24dp.xml deleted file mode 100644 index 261afd38d1..0000000000 --- a/app/src/main/res/drawable/space_24dp.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/team_size_1.xml b/app/src/main/res/drawable/team_size_1.xml new file mode 100644 index 0000000000..e63d866885 --- /dev/null +++ b/app/src/main/res/drawable/team_size_1.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_10.xml b/app/src/main/res/drawable/team_size_10.xml new file mode 100644 index 0000000000..22516435cb --- /dev/null +++ b/app/src/main/res/drawable/team_size_10.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_11.xml b/app/src/main/res/drawable/team_size_11.xml new file mode 100644 index 0000000000..1992a68504 --- /dev/null +++ b/app/src/main/res/drawable/team_size_11.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_12.xml b/app/src/main/res/drawable/team_size_12.xml new file mode 100644 index 0000000000..34e5c036ae --- /dev/null +++ b/app/src/main/res/drawable/team_size_12.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_2.xml b/app/src/main/res/drawable/team_size_2.xml new file mode 100644 index 0000000000..22dcc4ebe6 --- /dev/null +++ b/app/src/main/res/drawable/team_size_2.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_3.xml b/app/src/main/res/drawable/team_size_3.xml new file mode 100644 index 0000000000..0ee01f3d6f --- /dev/null +++ b/app/src/main/res/drawable/team_size_3.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_4.xml b/app/src/main/res/drawable/team_size_4.xml new file mode 100644 index 0000000000..3d9e9058ff --- /dev/null +++ b/app/src/main/res/drawable/team_size_4.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_5.xml b/app/src/main/res/drawable/team_size_5.xml new file mode 100644 index 0000000000..a0b104af12 --- /dev/null +++ b/app/src/main/res/drawable/team_size_5.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_6.xml b/app/src/main/res/drawable/team_size_6.xml new file mode 100644 index 0000000000..5ac646fa9a --- /dev/null +++ b/app/src/main/res/drawable/team_size_6.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_7.xml b/app/src/main/res/drawable/team_size_7.xml new file mode 100644 index 0000000000..6c4cdfb3d1 --- /dev/null +++ b/app/src/main/res/drawable/team_size_7.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_8.xml b/app/src/main/res/drawable/team_size_8.xml new file mode 100644 index 0000000000..c9b51697d1 --- /dev/null +++ b/app/src/main/res/drawable/team_size_8.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/team_size_9.xml b/app/src/main/res/drawable/team_size_9.xml new file mode 100644 index 0000000000..39f1911d2a --- /dev/null +++ b/app/src/main/res/drawable/team_size_9.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 61d45832bb..db5435a8ed 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -3,24 +3,32 @@ android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".screens.MainActivity"> + tools:context=".screens.main.MainActivity"> + android:layout_height="match_parent" + tools:layout="@layout/fragment_map" + /> - + android:layout_height="match_parent" /> - + + + + android:clipChildren="false" /> diff --git a/app/src/main/res/layout/cell_team_mode_color_circle_select.xml b/app/src/main/res/layout/cell_team_mode_color_circle_select.xml deleted file mode 100644 index bacdb68c01..0000000000 --- a/app/src/main/res/layout/cell_team_mode_color_circle_select.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/dialog_authorize_now.xml b/app/src/main/res/layout/dialog_authorize_now.xml deleted file mode 100644 index f53edc4bc6..0000000000 --- a/app/src/main/res/layout/dialog_authorize_now.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/dialog_main_menu.xml b/app/src/main/res/layout/dialog_main_menu.xml deleted file mode 100644 index d30f12765a..0000000000 --- a/app/src/main/res/layout/dialog_main_menu.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_team_mode.xml b/app/src/main/res/layout/dialog_team_mode.xml deleted file mode 100644 index e682b13757..0000000000 --- a/app/src/main/res/layout/dialog_team_mode.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_undo.xml b/app/src/main/res/layout/dialog_undo.xml deleted file mode 100644 index 9b29dfb45e..0000000000 --- a/app/src/main/res/layout/dialog_undo.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_edit_history_list.xml b/app/src/main/res/layout/fragment_edit_history_list.xml deleted file mode 100644 index 9d6bb80754..0000000000 --- a/app/src/main/res/layout/fragment_edit_history_list.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml deleted file mode 100644 index 61c3cc59ce..0000000000 --- a/app/src/main/res/layout/fragment_main.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 3cbcea5922..5d7b452766 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -1,46 +1,6 @@ - - - - - - - - - - - - - + android:layout_height="match_parent" /> diff --git a/app/src/main/res/layout/fragment_unread_osm_message.xml b/app/src/main/res/layout/fragment_unread_osm_message.xml deleted file mode 100644 index f1485ad2e7..0000000000 --- a/app/src/main/res/layout/fragment_unread_osm_message.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - -