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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
Expand All @@ -55,6 +56,7 @@ import org.scottishtecharmy.soundscape.audio.BeaconPreviewController
import org.scottishtecharmy.soundscape.audio.EARCON_MODE_ENTER
import org.scottishtecharmy.soundscape.audio.EARCON_MODE_EXIT
import org.scottishtecharmy.soundscape.audio.NativeAudioEngine
import org.scottishtecharmy.soundscape.audio.TourButton
import org.scottishtecharmy.soundscape.bluetooth.AudioHeadsetBatteryMonitor
import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabaseProvider
import org.scottishtecharmy.soundscape.database.local.model.MarkerEntity
Expand Down Expand Up @@ -178,6 +180,13 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
// Guard to prevent duplicate user-triggered callouts
private var calloutJob: Job? = null

// Which "hear my surroundings" button is currently animating. Set by
// startCallout on launch and cleared when the callout body finishes or is
// superseded (via compareAndSet so a fresh callout doesn't clobber its
// own value in the previous coroutine's finally block).
private val _activeCalloutFlow = MutableStateFlow<TourButton?>(null)
override val activeCalloutFlow: StateFlow<TourButton?> = _activeCalloutFlow.asStateFlow()

// Wake lock — keeps CPU running while screen is off so audio callbacks continue
private var wakeLock: PowerManager.WakeLock? = null

Expand Down Expand Up @@ -897,7 +906,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
* and [body] is skipped. Otherwise the TTS queue is cleared and then [body] runs, preserving the
* clear-before-speak ordering that callouts rely on.
*/
private fun startCallout(body: suspend CoroutineScope.() -> Unit) {
private fun startCallout(source: TourButton, body: suspend CoroutineScope.() -> Unit) {
val previousJob = calloutJob
calloutJob = coroutineScope.launch {
val wasActive = previousJob?.isActive == true
Expand All @@ -908,14 +917,24 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
audioEngine.clearTextToSpeechQueue()

// If a callout was already in progress, the user action just cancels it.
if (wasActive) return@launch
if (wasActive) {
_activeCalloutFlow.value = null
return@launch
}

body()
_activeCalloutFlow.value = source
try {
body()
} finally {
// Only clear if it's still us — a fresh callout that cancelled
// this one already set the flow to its own source.
_activeCalloutFlow.compareAndSet(source, null)
}
}
}

override fun myLocation() {
startCallout {
startCallout(TourButton.MY_LOCATION) {
if (requestAudioFocus()) {
// The call to myLocation can take a second or so as it might be doing network
// based reverse geocoding. Ensure that the user has feedback that the action is
Expand All @@ -936,7 +955,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
}

override fun whatsAroundMe() {
startCallout {
startCallout(TourButton.AROUND_ME) {
val results = geoEngine.whatsAroundMe()
ensureActive()
var lastHandle = 0L
Expand All @@ -948,7 +967,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
}

override fun aheadOfMe() {
startCallout {
startCallout(TourButton.AHEAD_OF_ME) {
val results = geoEngine.aheadOfMe()
ensureActive()
var lastHandle = 0L
Expand All @@ -960,7 +979,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl
}

override fun nearbyMarkers() {
startCallout {
startCallout(TourButton.NEARBY_MARKERS) {
val results = geoEngine.nearbyMarkers()
ensureActive()
val lastHandle = speakCallout(results, true)
Expand Down
17 changes: 6 additions & 11 deletions iosApp/iosApp/SplashView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AVFoundation
import Shared
import SwiftUI

// Mirrors Android's MainActivity splash flow (app/src/main/.../MainActivity.kt):
Expand Down Expand Up @@ -54,15 +55,12 @@ final class SplashCoordinator: ObservableObject {
dismissIfReady()
return
}
// Delegate AVAudioSession setup to IosAudioEngine so the splash player
// and the shared engine don't race each other on setCategory/setActive.
// The engine owns the session lifecycle from here on — we never call
// setActive(false) below, since the engine is about to start using it.
IosSoundscapeService.companion.getInstance().audioEngine.configureAudioSession()
do {
// .playback so the splash audio is heard regardless of the silent
// switch (MediaPlayer on Android plays through the media stream).
// .mixWithOthers so we don't hijack any music the user is already
// playing. AVAudioSession is iOS-only, hence the os guard.
#if os(iOS)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
#endif
let p = try AVAudioPlayer(contentsOf: url)
p.volume = 0.7
let delegate = SplashAudioDelegate { [weak self] in
Expand Down Expand Up @@ -98,9 +96,6 @@ final class SplashCoordinator: ObservableObject {
}
player = nil
audioDelegate = nil
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
#endif
}

private func currentMinorVersion() -> String {
Expand Down
6 changes: 6 additions & 0 deletions iosApp/iosApp/iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ struct iOSApp: App {
@StateObject private var splashCoordinator = SplashCoordinator()

init() {
// Must be first: installs Kotlin/Native's setUnhandledExceptionHook so
// any uncaught exception thrown during LegacyMigrator, FirebaseBootstrap,
// or IosSoundscapeService construction (or any coroutine they spawn) is
// written to stderr before the process dies, instead of vanishing.
UnhandledExceptionLoggerKt.installUnhandledExceptionLogger()

// Run the legacy → multiplatform data migration before the Compose
// UI mounts. Synchronous so the new app's preferences and Room
// database are populated before MainViewController reads them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
Expand All @@ -77,6 +82,7 @@ fun MainSearchBar(
onItemClick: (LocationDescription) -> Unit,
userLocation: LngLatAlt?,
isSearching: Boolean = false,
onExpandedChange: (Boolean) -> Unit = {},
) {
val shape = RoundedCornerShape(spacing.small)
val colors = MaterialTheme.colorScheme
Expand All @@ -86,6 +92,8 @@ fun MainSearchBar(
val focusRequester = remember { FocusRequester() }
val searchLocation = remember { mutableStateOf(userLocation) }

LaunchedEffect(expanded) { onExpandedChange(expanded) }

// Collapsed search bar
Surface(
modifier = modifier
Expand Down Expand Up @@ -119,9 +127,21 @@ fun MainSearchBar(
}
}

// Fullscreen search overlay
// Fullscreen search overlay. Pin to (0, 0) in window coordinates so it covers
// the top bar on Android instead of docking at the collapsed search bar's anchor.
val fullscreenPositionProvider = remember {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = IntOffset.Zero
}
}
if (expanded) {
Popup(
popupPositionProvider = fullscreenPositionProvider,
onDismissRequest = { expanded = false },
properties = PopupProperties(focusable = true)
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.scottishtecharmy.soundscape.screens.home

import org.scottishtecharmy.soundscape.audio.TourButton
import org.scottishtecharmy.soundscape.geoengine.StreetPreviewState
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription
Expand All @@ -18,4 +19,8 @@ data class HomeState(
val routesTabSelected: Boolean = true,
val permissionsRequired: Boolean = false,
val voiceCommandListening: Boolean = false,
/** Which "hear my surroundings" button has an in-flight callout, or null
* when no callout is playing. Drives the pulse animation on the active
* button in the home-screen bottom bar. */
val activeCallout: TourButton? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ open class HomeViewModel(
}
}
}
scope.launch {
service.activeCalloutFlow.collectLatest { active ->
_state.update { it.copy(activeCallout = active) }
}
}
}

private fun stopMonitoringLocation() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package org.scottishtecharmy.soundscape.screens.home.home

import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
Expand All @@ -14,15 +20,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.heading
Expand All @@ -31,6 +40,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.scottishtecharmy.soundscape.audio.TourButton
import org.scottishtecharmy.soundscape.resources.Res
import org.scottishtecharmy.soundscape.resources.ahead_of_me_24px
import org.scottishtecharmy.soundscape.resources.around_me_24px
Expand Down Expand Up @@ -75,6 +85,7 @@ data class StreetPreviewFunctions(
fun SharedHomeBottomAppBar(
bottomButtonFunctions: BottomButtonFunctions,
modifier: Modifier = Modifier,
activeCallout: TourButton? = null,
) {
val myLocationHint = stringResource(Res.string.ui_action_button_my_location_acc_hint)
val nearbyMarkersHint = stringResource(Res.string.ui_action_button_nearby_markers_acc_hint)
Expand Down Expand Up @@ -118,6 +129,7 @@ fun SharedHomeBottomAppBar(
icon = painterResource(Res.drawable.my_location_24px),
text = stringResource(Res.string.ui_action_button_my_location),
onClick = { bottomButtonFunctions.myLocation() },
isActive = activeCallout == TourButton.MY_LOCATION,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
Expand All @@ -129,6 +141,7 @@ fun SharedHomeBottomAppBar(
icon = painterResource(Res.drawable.around_me_24px),
text = stringResource(Res.string.ui_action_button_around_me),
onClick = { bottomButtonFunctions.aroundMe() },
isActive = activeCallout == TourButton.AROUND_ME,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
Expand All @@ -140,6 +153,7 @@ fun SharedHomeBottomAppBar(
icon = painterResource(Res.drawable.ahead_of_me_24px),
text = stringResource(Res.string.ui_action_button_ahead_of_me),
onClick = { bottomButtonFunctions.aheadOfMe() },
isActive = activeCallout == TourButton.AHEAD_OF_ME,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
Expand All @@ -151,6 +165,7 @@ fun SharedHomeBottomAppBar(
icon = painterResource(Res.drawable.nearby_markers_24px),
text = stringResource(Res.string.ui_action_button_nearby_markers),
onClick = { bottomButtonFunctions.nearbyMarkers() },
isActive = activeCallout == TourButton.NEARBY_MARKERS,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
Expand All @@ -168,13 +183,43 @@ private fun HomeBottomAppBarButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isActive: Boolean = false,
) {
// Legacy iOS pulsed a `LineScaleParty` NVActivityIndicator inside the
// button while the callout audio played. Equivalent here: the button
// flips to a solid high-contrast highlight (theme primary) for the
// duration of the callout, and the icon pulses in scale for a motion
// cue. Stops the moment the callout finishes or the user cancels.
val infiniteTransition = rememberInfiniteTransition(label = "calloutPulse")
val iconScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "iconScale",
)

val colorScheme = MaterialTheme.colorScheme
val restingColors = currentAppButtonColors
val activeColors = if (isActive) {
ButtonDefaults.buttonColors(
containerColor = colorScheme.primary,
contentColor = colorScheme.onPrimary,
disabledContainerColor = restingColors.disabledContainerColor,
disabledContentColor = restingColors.disabledContentColor,
)
} else {
restingColors
}

Button(
onClick = onClick,
shape = RectangleShape,
modifier = modifier,
contentPadding = PaddingValues(spacing.extraSmall),
colors = currentAppButtonColors,
colors = activeColors,
) {
Column(
verticalArrangement = Arrangement.Top,
Expand All @@ -186,7 +231,12 @@ private fun HomeBottomAppBarButton(
contentDescription = null,
modifier = Modifier
.size(spacing.icon)
.align(Alignment.CenterHorizontally),
.align(Alignment.CenterHorizontally)
.graphicsLayer {
val s = if (isActive) iconScale else 1f
scaleX = s
scaleY = s
},
)
Spacer(modifier = Modifier.height(spacing.small))
Text(
Expand Down
Loading