diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/AndroidLocationProvider.kt b/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/AndroidLocationProvider.kt
index 32fea2e6a..70a4eff10 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/AndroidLocationProvider.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/AndroidLocationProvider.kt
@@ -12,7 +12,6 @@ import android.os.Looper
import androidx.core.app.ActivityCompat
import org.scottishtecharmy.soundscape.geoengine.filters.KalmanLocationFilter
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
-import kotlin.time.Duration.Companion.seconds
class AndroidLocationProvider(context: Context) : LocationProvider() {
@@ -88,30 +87,31 @@ class AndroidLocationProvider(context: Context) : LocationProvider() {
}
@SuppressLint("MissingPermission")
- fun start(context: Context) {
- if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
- locationManager.requestLocationUpdates(
- LocationManager.GPS_PROVIDER,
- LOCATION_UPDATES_INTERVAL_MS,
- MIN_DISTANCE_METERS,
- locationListener,
- Looper.getMainLooper()
- )
- }
+ override fun start(accuracy: Accuracy) {
+ when (accuracy) {
+ Accuracy.High -> {
+ if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ locationManager.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ accuracy.updateInterval.inWholeMilliseconds,
+ accuracy.minimumDistanceM,
+ locationListener,
+ Looper.getMainLooper()
+ )
+ }
+ }
- if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
- locationManager.requestLocationUpdates(
- LocationManager.NETWORK_PROVIDER,
- LOCATION_UPDATES_INTERVAL_MS,
- MIN_DISTANCE_METERS,
- locationListener,
- Looper.getMainLooper()
- )
+ Accuracy.Balanced -> {
+ if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ locationManager.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER,
+ accuracy.updateInterval.inWholeMilliseconds,
+ accuracy.minimumDistanceM,
+ locationListener,
+ Looper.getMainLooper()
+ )
+ }
+ }
}
}
-
- companion object {
- private val LOCATION_UPDATES_INTERVAL_MS = 1.seconds.inWholeMilliseconds
- private const val MIN_DISTANCE_METERS = 1f
- }
}
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/GooglePlayLocationProvider.kt b/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/GooglePlayLocationProvider.kt
index 5b11aea3a..789d37548 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/GooglePlayLocationProvider.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/GooglePlayLocationProvider.kt
@@ -16,7 +16,6 @@ import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import org.scottishtecharmy.soundscape.geoengine.filters.KalmanLocationFilter
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
-import kotlin.time.Duration.Companion.seconds
class GooglePlayLocationProvider(context: Context) :
LocationProvider() {
@@ -72,14 +71,17 @@ class GooglePlayLocationProvider(context: Context) :
}
@SuppressLint("MissingPermission")
- fun start(context: Context) {
+ override fun start(accuracy: Accuracy) {
fusedLocationClient.requestLocationUpdates(
LocationRequest.Builder(
- Priority.PRIORITY_HIGH_ACCURACY,
- LOCATION_UPDATES_INTERVAL_MS
+ when (accuracy) {
+ Accuracy.Balanced -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
+ Accuracy.High -> Priority.PRIORITY_HIGH_ACCURACY
+ },
+ accuracy.updateInterval.inWholeMilliseconds
).apply {
- setMinUpdateDistanceMeters(1f)
+ setMinUpdateDistanceMeters(accuracy.minimumDistanceM)
setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
setWaitForAccurateLocation(true)
}.build(),
@@ -87,8 +89,4 @@ class GooglePlayLocationProvider(context: Context) :
Looper.getMainLooper(),
)
}
-
- companion object {
- private val LOCATION_UPDATES_INTERVAL_MS = 1.seconds.inWholeMilliseconds
- }
}
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt
index 68942c4a8..1def11ac6 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt
@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -222,6 +221,16 @@ fun HomeScreen(
activity.setServiceState(false)
},
onGetLanguageMismatch = { getLanguageMismatch() },
+ provideLocationProvider = {
+ if (org.scottishtecharmy.soundscape.hasPlayServices(context)) {
+ org.scottishtecharmy.soundscape.locationprovider.GooglePlayLocationProvider(
+ context
+ ).apply { start() }
+ } else {
+ org.scottishtecharmy.soundscape.locationprovider.AndroidLocationProvider(context)
+ .apply { start() }
+ }
+ },
getOpenSourceLicensesJson = {
context.assets.open("open_source_licenses.json")
.bufferedReader()
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
index 91437ac29..66bc25231 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
@@ -407,8 +407,8 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
private fun startProviders() {
when (val lp = locationProvider) {
- is GooglePlayLocationProvider -> lp.start(this)
- is AndroidLocationProvider -> lp.start(this)
+ is GooglePlayLocationProvider -> lp.start()
+ is AndroidLocationProvider -> lp.start()
is StaticLocationProvider -> lp.start()
}
when (val dp = directionProvider) {
diff --git a/app/src/screenshotTest/kotlin/org/scottishtecharmy/soundscape/PreviewTest.kt b/app/src/screenshotTest/kotlin/org/scottishtecharmy/soundscape/PreviewTest.kt
index 33f13b86c..1585b306a 100644
--- a/app/src/screenshotTest/kotlin/org/scottishtecharmy/soundscape/PreviewTest.kt
+++ b/app/src/screenshotTest/kotlin/org/scottishtecharmy/soundscape/PreviewTest.kt
@@ -33,6 +33,7 @@ import org.scottishtecharmy.soundscape.screens.home.home.SharedLanguageMismatchD
import org.scottishtecharmy.soundscape.screens.home.home.SharedNewReleaseDialog
import org.scottishtecharmy.soundscape.screens.home.home.SharedOpenSourceLicensesScreen
import org.scottishtecharmy.soundscape.screens.home.home.SharedSleepScreen
+import org.scottishtecharmy.soundscape.screens.home.home.SleepScreenState
import org.scottishtecharmy.soundscape.screens.home.home.StreetPreviewFunctions
import org.scottishtecharmy.soundscape.screens.home.locationDetails.SharedLocationDetailsScreen
import org.scottishtecharmy.soundscape.screens.home.locationDetails.SharedSaveAndEditMarkerScreen
@@ -416,7 +417,12 @@ fun HomeRoutePreview() {
@Preview(showBackground = true)
@Composable
fun SleepScreenPreview() {
- SharedSleepScreen(onWakeUp = {}, onExit = {}, modifier = Modifier)
+ SharedSleepScreen(
+ modifier = Modifier,
+ onWakeUpNowClicked = {},
+ onWakeOnLeaveClicked = {},
+ state = SleepScreenState.Sleeping
+ )
}
@Preview(showBackground = true)
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index 16e35f95f..9a55b0ecd 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.gradle.kotlin.dsl.implementation
+
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
@@ -58,8 +60,11 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
androidMain.dependencies {
+ implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.jts.core)
implementation(libs.ktor.client.okhttp)
+ implementation(libs.ui.tooling.preview)
+ implementation(libs.ui.tooling)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
@@ -77,6 +82,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+ buildFeatures {
+ compose = true
+ }
// Reuse shared resources for the Android target so JSON data files live
// in a single canonical location consumed by both platforms.
sourceSets["main"].resources.srcDir("src/commonMain/resources")
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/LocationConversions.kt b/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationConversions.kt
similarity index 100%
rename from app/src/main/java/org/scottishtecharmy/soundscape/locationprovider/LocationConversions.kt
rename to shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationConversions.kt
diff --git a/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreenPreview.kt b/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreenPreview.kt
new file mode 100644
index 000000000..6f22060d4
--- /dev/null
+++ b/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreenPreview.kt
@@ -0,0 +1,60 @@
+package org.scottishtecharmy.soundscape.screens.home.home
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
+
+@Preview(showBackground = true)
+@Composable
+fun SharedSleepScreenPreview() {
+ SharedSleepScreen(
+ onWakeUpNowClicked = {},
+ onWakeOnLeaveClicked = { },
+ state = SleepScreenState.Sleeping,
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun SharedSleepScreenWakeOnLeaveEnabledPreview() {
+ SharedSleepScreen(
+ onWakeUpNowClicked = {},
+ onWakeOnLeaveClicked = {},
+ state = SleepScreenState.Snoozing(
+ userLocation = LngLatAlt(),
+ startLocation = LngLatAlt()
+ )
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WakeButtonPreview() {
+ WakeButton(
+ text = "Wake On Leave",
+ onClick = { },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WakeButtonsWakeOnLeaveVisiblePreview() {
+ WakeButtons(
+ wakeUpNowOnClick = {},
+ wakeOnLeaveOnClick = {},
+ sleepScreenState = SleepScreenState.Sleeping,
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WakeButtonsWakeOnLeaveNotVisiblePreview() {
+ WakeButtons(
+ wakeUpNowOnClick = {},
+ wakeOnLeaveOnClick = {},
+ sleepScreenState = SleepScreenState.Snoozing(
+ userLocation = LngLatAlt(),
+ startLocation = LngLatAlt()
+ ),
+ )
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 7a4f61c47..4b1239f1d 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -336,12 +336,18 @@
Sleep
Sleeping
+
+ Snoozing
Soundscape is currently sleeping. This prevents Soundscape from using Location Services or downloading data. This conserves battery when you are not using Soundscape. Tap the button below to wake up Soundscape.
+
+ Soundscape is currently snoozing. When you leave your current location, Soundscape will automatically wake up. This uses Location Services in a low power mode to conserve your phone's battery. Tap the button below to wake up now.
put Soundscape to sleep
Wake Up Now
+
+ Wake On Leave
Approaching intersection
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt
index 59fa8685f..03a326d72 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt
@@ -16,6 +16,7 @@ import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
import org.scottishtecharmy.soundscape.intents.IncomingIntent
import org.scottishtecharmy.soundscape.locationprovider.DeviceDirection
+import org.scottishtecharmy.soundscape.locationprovider.LocationProvider
import org.scottishtecharmy.soundscape.locationprovider.SoundscapeLocation
import org.scottishtecharmy.soundscape.navigation.NavigationStateHolder
import org.scottishtecharmy.soundscape.navigation.SharedNavHost
@@ -98,6 +99,7 @@ data class AppCallbacks(
},
val onSetApplicationLocale: (String?) -> Unit = {},
val onGetLanguageMismatch: () -> Language? = { null },
+ val provideLocationProvider: (() -> LocationProvider)? = null,
val getOpenSourceLicensesJson: (() -> String)? = null,
/**
* Wipes preferences and immediately restarts the app. When non-null,
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geojsonparser/geojson/LngLatAlt.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geojsonparser/geojson/LngLatAlt.kt
index 40a07c92c..83a343773 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geojsonparser/geojson/LngLatAlt.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geojsonparser/geojson/LngLatAlt.kt
@@ -1,5 +1,7 @@
package org.scottishtecharmy.soundscape.geojsonparser.geojson
+import org.scottishtecharmy.soundscape.locationprovider.SoundscapeLocation
+
open class LngLatAlt(
var longitude: Double = 0.toDouble(),
var latitude: Double = 0.toDouble(),
@@ -31,3 +33,10 @@ open class LngLatAlt(
return "$longitude,$latitude"
}
}
+
+fun SoundscapeLocation.asLngLatAlt(): LngLatAlt {
+ return LngLatAlt(
+ longitude = longitude,
+ latitude = latitude,
+ )
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationProvider.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationProvider.kt
index c5d8bdc7b..e81244cc7 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationProvider.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/LocationProvider.kt
@@ -2,8 +2,27 @@ package org.scottishtecharmy.soundscape.locationprovider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+sealed class Accuracy {
+ abstract val updateInterval: Duration
+ abstract val minimumDistanceM: Float
+
+ object High : Accuracy() {
+ override val updateInterval: Duration = 1.seconds
+ override val minimumDistanceM: Float = 1.0f
+
+ }
+
+ object Balanced : Accuracy() {
+ override val updateInterval: Duration = 30.seconds
+ override val minimumDistanceM: Float = 5.0f
+ }
+}
abstract class LocationProvider {
+ abstract fun start(accuracy: Accuracy = Accuracy.High)
abstract fun destroy()
open fun updateLocation(newLocation: SoundscapeLocation) {}
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/StaticLocationProvider.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/StaticLocationProvider.kt
index 1845f10ca..60c13040f 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/StaticLocationProvider.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/StaticLocationProvider.kt
@@ -6,7 +6,7 @@ class StaticLocationProvider(private var location: LngLatAlt) : LocationProvider
override fun destroy() {}
- fun start() {
+ override fun start(accuracy: Accuracy) {
val loc = SoundscapeLocation(
latitude = location.latitude,
longitude = location.longitude,
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt
index 1f1ae8eea..cb223da88 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt
@@ -23,6 +23,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.savedstate.read
+import kotlinx.coroutines.flow.receiveAsFlow
import org.jetbrains.compose.resources.stringResource
import org.scottishtecharmy.soundscape.AppCallbacks
import org.scottishtecharmy.soundscape.AppFlows
@@ -30,6 +31,7 @@ import org.scottishtecharmy.soundscape.audio.AudioEngine
import org.scottishtecharmy.soundscape.audio.AudioTour
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
import org.scottishtecharmy.soundscape.intents.IncomingIntent
+import org.scottishtecharmy.soundscape.locationprovider.StaticLocationProvider
import org.scottishtecharmy.soundscape.network.DownloadStateCommon
import org.scottishtecharmy.soundscape.preferences.PreferenceKeys
import org.scottishtecharmy.soundscape.preferences.PreferencesProvider
@@ -45,6 +47,8 @@ import org.scottishtecharmy.soundscape.screens.home.home.SharedHelpScreen
import org.scottishtecharmy.soundscape.screens.home.home.SharedHomeScreen
import org.scottishtecharmy.soundscape.screens.home.home.SharedOpenSourceLicensesScreen
import org.scottishtecharmy.soundscape.screens.home.home.SharedSleepScreen
+import org.scottishtecharmy.soundscape.screens.home.home.SleepScreenEffect
+import org.scottishtecharmy.soundscape.screens.home.home.SleepScreenViewModel
import org.scottishtecharmy.soundscape.screens.home.locationDetails.SharedLocationDetailsScreen
import org.scottishtecharmy.soundscape.screens.home.locationDetails.SharedSaveAndEditMarkerScreen
import org.scottishtecharmy.soundscape.screens.home.offlinemaps.NearbyExtractsState
@@ -93,7 +97,7 @@ fun SharedNavHost(
navStateHolder.navigateWithLocation(
navController,
SharedRoutes.LOCATION_DETAILS,
- org.scottishtecharmy.soundscape.screens.home.data.LocationDescription(
+ LocationDescription(
name = displayName,
location = LngLatAlt(intent.longitude, intent.latitude),
),
@@ -570,11 +574,30 @@ fun SharedNavHost(
}
composable(SharedRoutes.SLEEP) {
+ val locationProvider = remember {
+ callbacks.provideLocationProvider?.invoke()
+ ?: StaticLocationProvider(LngLatAlt())
+ }
+ val viewModel = viewModel {
+ SleepScreenViewModel(locationProvider, callbacks.onWakeUp)
+ }
+
+ val state = viewModel.state.collectAsState()
+
+ LaunchedEffect(viewModel) {
+ viewModel.effects.receiveAsFlow().collect {
+ when (it) {
+ SleepScreenEffect.WakeUpNow -> {
+ navController.popBackStack(SharedRoutes.HOME, inclusive = false)
+ }
+ }
+ }
+ }
+
SharedSleepScreen(
- onWakeUp = callbacks.onWakeUp,
- onExit = {
- navController.popBackStack(SharedRoutes.HOME, inclusive = false)
- },
+ onWakeUpNowClicked = { viewModel.onWakeUpNowClicked() },
+ onWakeOnLeaveClicked = { viewModel.onWakeOnLeaveClicked() },
+ state = state.value,
)
}
diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreen.kt
index 12fdc595d..465488ad0 100644
--- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreen.kt
+++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedSleepScreen.kt
@@ -13,31 +13,220 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectIndexed
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.scottishtecharmy.soundscape.geoengine.utils.rulers.CheapRuler
+import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
+import org.scottishtecharmy.soundscape.geojsonparser.geojson.asLngLatAlt
+import org.scottishtecharmy.soundscape.locationprovider.Accuracy
+import org.scottishtecharmy.soundscape.locationprovider.LocationProvider
import org.scottishtecharmy.soundscape.resources.Res
import org.scottishtecharmy.soundscape.resources.sleep_sleeping
import org.scottishtecharmy.soundscape.resources.sleep_sleeping_message
+import org.scottishtecharmy.soundscape.resources.sleep_sleeping_wake_on_leave_message
+import org.scottishtecharmy.soundscape.resources.sleep_snoozing
+import org.scottishtecharmy.soundscape.resources.sleep_wake_on_leave
import org.scottishtecharmy.soundscape.resources.sleep_wake_up_now
import org.scottishtecharmy.soundscape.ui.theme.currentAppButtonColors
import org.scottishtecharmy.soundscape.ui.theme.largePadding
import org.scottishtecharmy.soundscape.ui.theme.spacing
+sealed class SleepScreenState {
+ object Sleeping : SleepScreenState()
+ data class Snoozing(
+ val userLocation: LngLatAlt,
+ val startLocation: LngLatAlt? = null,
+ val geofenceDistance: Double = 0.0,
+ ) : SleepScreenState()
+}
+
+sealed class SleepScreenEvent {
+ object WakeUpNowClick : SleepScreenEvent()
+ object WakeOnLeaveClick : SleepScreenEvent()
+}
+
+sealed class SleepScreenEffect {
+ object WakeUpNow : SleepScreenEffect()
+}
+
+class SleepScreenViewModel(
+ private val locationProvider: LocationProvider,
+ private val onWakeUp: () -> Unit,
+) : ViewModel() {
+ private val _state: MutableStateFlow =
+ MutableStateFlow(SleepScreenState.Sleeping)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _location: MutableStateFlow = MutableStateFlow(LngLatAlt())
+ private val _events: MutableSharedFlow = MutableSharedFlow()
+
+ // Rendezvous Channel to handle navigation
+ val effects: Channel = Channel(
+ capacity = 1,
+ onBufferOverflow = BufferOverflow.SUSPEND
+ )
+
+ private lateinit var _locationJob: Job
+ private val _eventsJob: Job
+ private lateinit var _geofenceJob: Job
+ private var _isDestroyed = false
+
+ init {
+ _eventsJob = viewModelScope.launch {
+ _events.collect {
+ when (it) {
+ SleepScreenEvent.WakeUpNowClick -> {
+ onWakeUpNowClicked()
+ }
+
+ SleepScreenEvent.WakeOnLeaveClick -> {
+ onWakeOnLeaveClicked()
+ }
+ }
+ }
+ }
+ }
+
+ fun onWakeUpNowClicked() {
+ destroy()
+ }
+
+ fun onWakeOnLeaveClicked() {
+ when (_state.value) {
+ is SleepScreenState.Sleeping -> {
+ subscribeToLocation()
+ _state.update {
+ SleepScreenState.Snoozing(
+ userLocation = _location.value,
+ )
+ }
+ }
+
+ is SleepScreenState.Snoozing -> {
+ unsubscribeFromLocation()
+ _state.update { SleepScreenState.Sleeping }
+ }
+ }
+ }
+
+ private fun destroy() {
+ if (_isDestroyed) return
+ _isDestroyed = true
+
+ if (_eventsJob.isActive) {
+ _eventsJob.cancel()
+ }
+ unsubscribeFromLocation()
+ onWakeUp()
+ viewModelScope.launch {
+ effects.send(SleepScreenEffect.WakeUpNow)
+ }
+ }
+
+ override fun onCleared() {
+ destroy()
+ }
+
+ private fun unsubscribeFromLocation() {
+ if (this::_locationJob.isInitialized && _locationJob.isActive) {
+ _locationJob.cancel()
+ }
+ if (this::_geofenceJob.isInitialized && _geofenceJob.isActive) {
+ _geofenceJob.cancel()
+ }
+ locationProvider.destroy()
+ }
+
+ private fun subscribeToLocation() {
+ // Start location job to update internal location sharedflow
+ if (!this::_locationJob.isInitialized) {
+ _locationJob = viewModelScope.launch {
+ locationProvider.start(Accuracy.Balanced)
+ locationProvider.locationFlow.collect { loc ->
+ if (isActive) {
+ val lngLat = loc?.asLngLatAlt() ?: LngLatAlt()
+ _location.update { lngLat }
+ }
+ }
+ }
+ }
+
+ // Start geofence job to collect from location job. On collection of value, update the state with:
+ // if first emission: the start location
+ // subsequent emissions update the distance from the start location
+ if (!this::_geofenceJob.isInitialized) {
+ _geofenceJob = viewModelScope.launch {
+ println("Geofencing job started: $state")
+ val ruler = CheapRuler(0.0)
+ _location.collectIndexed { index, value ->
+ var state = _state.value
+
+ println("Geofencing job new location. Current State: $state")
+
+ when (state) {
+ is SleepScreenState.Snoozing -> {
+ state = if (index == 0) {
+ state.copy(
+ userLocation = value,
+ startLocation = value,
+ geofenceDistance = ruler.distance(value, value)
+ )
+ } else {
+ state.copy(
+ userLocation = value,
+ geofenceDistance = ruler.distance(
+ state.userLocation,
+ state.startLocation ?: value
+ )
+ )
+ }
+
+ // Check if the geofence distance is greater than 12 metres and if so, end the
+ // session
+ println("Geofencing job. Distance: ${state.geofenceDistance}")
+ if (state.geofenceDistance > 12.0) {
+ destroy()
+ }
+
+ _state.value = state
+
+ println("Geofencing job new location. Updated State: ${_state.value}")
+ }
+
+ SleepScreenState.Sleeping -> {
+ // no-op
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
@Composable
fun SharedSleepScreen(
- onWakeUp: () -> Unit,
- onExit: () -> Unit,
+ onWakeUpNowClicked: () -> Unit,
+ onWakeOnLeaveClicked: () -> Unit,
+ state: SleepScreenState,
modifier: Modifier = Modifier,
) {
- DisposableEffect(Unit) {
- onDispose { onWakeUp() }
- }
-
Column(
modifier = modifier
.fillMaxSize()
@@ -47,7 +236,12 @@ fun SharedSleepScreen(
) {
Row {
Text(
- text = stringResource(Res.string.sleep_sleeping),
+ text = stringResource(
+ when (state) {
+ is SleepScreenState.Sleeping -> Res.string.sleep_sleeping
+ is SleepScreenState.Snoozing -> Res.string.sleep_snoozing
+ }
+ ),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.largePadding(),
color = MaterialTheme.colorScheme.onSurface,
@@ -55,28 +249,74 @@ fun SharedSleepScreen(
}
Row {
Text(
- text = stringResource(Res.string.sleep_sleeping_message),
+ text = stringResource(
+ when (state) {
+ SleepScreenState.Sleeping -> Res.string.sleep_sleeping_message
+ is SleepScreenState.Snoozing -> Res.string.sleep_sleeping_wake_on_leave_message
+ }
+ ),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.largePadding(),
)
}
- Row {
- Button(
- onClick = onExit,
- modifier = Modifier
- .fillMaxWidth()
- .height(spacing.targetSize * 4)
- .testTag("sleepWakeUpNow"),
- shape = RoundedCornerShape(spacing.tiny),
- colors = if (!LocalInspectionMode.current) currentAppButtonColors else ButtonDefaults.buttonColors(),
- ) {
- Text(
- text = stringResource(Res.string.sleep_wake_up_now),
- textAlign = TextAlign.Start,
- style = MaterialTheme.typography.displaySmall,
- )
- }
+ WakeButtons(
+ wakeUpNowOnClick = {
+ onWakeUpNowClicked()
+ },
+ wakeOnLeaveOnClick = onWakeOnLeaveClicked,
+ sleepScreenState = state,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+fun WakeButtons(
+ wakeUpNowOnClick: () -> Unit,
+ wakeOnLeaveOnClick: () -> Unit,
+ sleepScreenState: SleepScreenState,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier) {
+ WakeButton(
+ text = stringResource(Res.string.sleep_wake_up_now),
+ onClick = wakeUpNowOnClick,
+ modifier = Modifier.fillMaxWidth(
+ when (sleepScreenState) {
+ SleepScreenState.Sleeping -> 0.5f
+ is SleepScreenState.Snoozing -> 1.0f
+ }
+ )
+ )
+ if (sleepScreenState is SleepScreenState.Sleeping) {
+ WakeButton(
+ text = stringResource(Res.string.sleep_wake_on_leave),
+ onClick = wakeOnLeaveOnClick,
+ modifier = Modifier,
+ )
}
}
}
+
+@Composable
+fun WakeButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier
+ .height(spacing.targetSize * 4)
+ .testTag("sleepWakeUpNow"),
+ shape = RoundedCornerShape(spacing.tiny),
+ colors = if (!LocalInspectionMode.current) currentAppButtonColors else ButtonDefaults.buttonColors(),
+ ) {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.displaySmall,
+ )
+ }
+}
diff --git a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SleepScreenViewModelTest.kt b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SleepScreenViewModelTest.kt
new file mode 100644
index 000000000..12271cf85
--- /dev/null
+++ b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SleepScreenViewModelTest.kt
@@ -0,0 +1,91 @@
+package org.scottishtecharmy.soundscape.screens.home.home
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.toCollection
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.scottishtecharmy.soundscape.locationprovider.Accuracy
+import org.scottishtecharmy.soundscape.locationprovider.LocationProvider
+import org.scottishtecharmy.soundscape.locationprovider.SoundscapeLocation
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SleepScreenViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val _mockLocationProvider = object : LocationProvider() {
+ override fun start(accuracy: Accuracy) {
+ mutableLocationFlow.value = SoundscapeLocation(47.872, 50.123)
+ }
+
+ override fun destroy() {
+ // no-op
+ }
+ }
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun onWakeOnLeaveClicked_stateUpdatesToSnoozing() = runTest {
+ val vm = SleepScreenViewModel(_mockLocationProvider) {}
+
+ // Initial state should be Sleeping
+ assertIs(vm.state.value)
+
+ // Trigger Snoozing
+ vm.onWakeOnLeaveClicked()
+
+ // Wait for coroutines in viewModelScope (subscribeToLocation) to process
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ // State should now be Snoozing
+ assertIs(vm.state.value)
+ }
+
+ @Test
+ fun onWakeUpNowClicked_callsOnWakeUp() = runTest {
+ var wakeUpCalled = false
+ val vm = SleepScreenViewModel(_mockLocationProvider) {
+ wakeUpCalled = true
+ }
+
+ vm.onWakeUpNowClicked()
+
+ assertTrue(wakeUpCalled)
+ }
+
+ @Test
+ fun onWakeUpNowClicked_wakeUpNowEffectSent() = runTest {
+ val vm = SleepScreenViewModel(_mockLocationProvider) {}
+
+ val effects = vm.effects.receiveAsFlow()
+ val effectsList: MutableList = mutableListOf()
+ val collectJob = launch { effects.toCollection(effectsList) }
+
+ vm.onWakeUpNowClicked()
+
+ // Wait for coroutines in viewModelScope (destroy) to process
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertIs(effectsList[0])
+ collectJob.cancel()
+ }
+}
diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt
index ba1132824..056d5b9b1 100644
--- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt
+++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt
@@ -302,6 +302,7 @@ fun MainViewController() = ComposeUIViewController {
onGetLanguageMismatch = {
org.scottishtecharmy.soundscape.screens.onboarding.language.getLanguageMismatch()
},
+ provideLocationProvider = { service.iosLocationProvider },
getOpenSourceLicensesJson = { readResourceText("open_source_licenses.json") },
// No onResetSettings hook — SharedNavGraph clears the
// PreferencesProvider and navigates to the onboarding flow when
diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/IosLocationProvider.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/IosLocationProvider.kt
index 4fdd26ad6..512c2cf1f 100644
--- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/IosLocationProvider.kt
+++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/locationprovider/IosLocationProvider.kt
@@ -7,6 +7,7 @@ import platform.CoreLocation.CLLocationManager
import platform.CoreLocation.CLLocationManagerDelegateProtocol
import platform.CoreLocation.kCLDistanceFilterNone
import platform.CoreLocation.kCLLocationAccuracyBest
+import platform.CoreLocation.kCLLocationAccuracyNearestTenMeters
import platform.Foundation.NSError
import platform.darwin.NSObject
@@ -16,8 +17,6 @@ class IosLocationProvider : LocationProvider() {
private val delegate = LocationDelegate(this)
init {
- locationManager.desiredAccuracy = kCLLocationAccuracyBest
- locationManager.distanceFilter = kCLDistanceFilterNone
locationManager.allowsBackgroundLocationUpdates = true
locationManager.pausesLocationUpdatesAutomatically = false
start()
@@ -27,7 +26,14 @@ class IosLocationProvider : LocationProvider() {
locationManager.requestAlwaysAuthorization()
}
- fun start() {
+ override fun start(accuracy: Accuracy) {
+ locationManager.desiredAccuracy = when(accuracy) {
+ Accuracy.High -> kCLLocationAccuracyBest
+ Accuracy.Balanced -> kCLLocationAccuracyNearestTenMeters
+ }
+
+ locationManager.distanceFilter = accuracy.minimumDistanceM.toDouble()
+
locationManager.delegate = delegate
locationManager.startUpdatingLocation()
}