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() }