diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index c078ebe..d7bc763 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,7 +59,7 @@ import app.waveflow.ui.navigation.TopLevelDestination import app.waveflow.ui.navigation.longArgOf import app.waveflow.ui.navigation.WaveFlowBottomBar import app.waveflow.ui.permission.AudioPermissionGate -import app.waveflow.ui.player.MiniPlayer +import app.waveflow.ui.player.MiniPlayerHost import app.waveflow.ui.player.NowPlayingScreen import app.waveflow.ui.player.PlayerViewModel import app.waveflow.ui.playlists.AddToPlaylistSheet @@ -93,9 +92,6 @@ class MainActivity : ComponentActivity() { } } -/** Hauteur réservée sous les listes pour que le mini-player ne masque rien. */ -private val MiniPlayerSpace = 76.dp - private const val PLAYER_TRANSITION_MS = 300 /** Routes affichant une flèche de retour plutôt que le titre de section. */ @@ -170,7 +166,6 @@ private fun WaveFlowRoot() { BackHandler(enabled = playerExpanded) { playerExpanded = false } - val listBottomPadding = if (hasTrack) MiniPlayerSpace else 0.dp val isDetailRoute = currentRoute in DETAIL_ROUTES val openPlaylist = backStackEntry ?.takeIf { currentRoute == Routes.PLAYLIST_DETAIL } @@ -310,11 +305,18 @@ private fun WaveFlowRoot() { } } - Box( + MiniPlayerHost( + state = playerState, + // Inutile de le composer sous le lecteur plein écran, qui le + // recouvre entièrement. + showMiniPlayer = !playerExpanded, + onExpand = { playerExpanded = true }, + onTogglePlayPause = playerViewModel::togglePlayPause, + onSkipNext = playerViewModel::skipNext, modifier = Modifier .fillMaxSize() .padding(innerPadding), - ) { + ) { listBottomPadding -> NavHost( navController = navController, startDestination = Routes.SONGS, @@ -580,17 +582,6 @@ private fun WaveFlowRoot() { } } - // Inutile de le composer sous le lecteur plein écran, qui - // le recouvre entièrement. - if (!playerExpanded) { - MiniPlayer( - state = playerState, - onExpand = { playerExpanded = true }, - onTogglePlayPause = playerViewModel::togglePlayPause, - onSkipNext = playerViewModel::skipNext, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } } } diff --git a/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt b/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt new file mode 100644 index 0000000..76b1fab --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt @@ -0,0 +1,70 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Box +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Le contenu de l'application, surmonté du mini-lecteur flottant. + * + * La réserve laissée sous les listes est **mesurée sur le mini-lecteur**, jamais + * devinée. Une constante ne pouvait pas être juste : la carte occupe 82 dp aux + * réglages par défaut et 103,5 dp à polices doublées, alors que les 76 dp + * réservés jusqu'ici masquaient le bas de la dernière ligne — de 6 dp en temps + * ordinaire, de 27 pour qui agrandit les caractères. Une mesure suit aussi la + * moindre retouche du mini-lecteur, ce qu'un nombre écrit ailleurs ne fait pas. + * + * @param showMiniPlayer `false` sous le lecteur plein écran, qui le recouvre + * entièrement : inutile de le composer, et la liste n'a alors rien à réserver. + * @param content reçoit la réserve à passer en `contentPadding` de ses listes. + */ +@Composable +fun MiniPlayerHost( + state: PlayerUiState, + showMiniPlayer: Boolean, + onExpand: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (bottomPadding: Dp) -> Unit, +) { + val density = LocalDensity.current + var mesure by remember { mutableStateOf(MiniPlayerSpaceEstimate) } + + // Le mini-lecteur ne s'affiche pas sans morceau : la liste n'a alors rien à + // réserver, et la mesure retenue attendra le prochain. + val visible = showMiniPlayer && state.track != null + + Box(modifier = modifier) { + content(if (visible) mesure else 0.dp) + + if (visible) { + MiniPlayer( + state = state, + onExpand = onExpand, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { mesure = with(density) { it.height.toDp() } }, + ) + } + } +} + +/** + * Ce que vaut la réserve avant la première mesure. + * + * La carte fait 82 dp aux réglages par défaut : cette valeur n'a qu'à éviter que + * la liste ne saute entre la première image et la suivante. + */ +private val MiniPlayerSpaceEstimate = 82.dp diff --git a/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt b/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt new file mode 100644 index 0000000..8fe041e --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt @@ -0,0 +1,172 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.TrackSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * La place que les listes laissent au mini-lecteur. + * + * Elle était écrite en dur : 76 dp, pour une carte qui en occupe 82 aux + * réglages par défaut et 103,5 à polices doublées. Le bas de la dernière ligne + * disparaissait donc sous la carte — un peu pour tout le monde, beaucoup pour + * qui agrandit les caractères. + * + * Ces tests ne fixent aucun nombre : ils comparent la réserve annoncée à la + * hauteur que la carte occupe vraiment, dans le même environnement. Un nombre + * écrit en dur ne peut satisfaire les deux réglages de police à la fois. + */ +@RunWith(RobolectricTestRunner::class) +class MiniPlayerHostTest { + + @get:Rule + val compose = createComposeRule() + + private val track = PlayingTrack( + mediaId = "remote:a", + title = "Résonance", + artist = "Bruit de Fond", + album = "Écho", + artworkUri = null, + localSongId = null, + source = TrackSource.Remote, + ) + + private val state = PlayerUiState(track = track, isPlaying = true) + + /** Ce que l'hôte annonce aux listes, et ce que la carte occupe réellement. */ + private class Mesures { + var reserve: Dp = Dp.Unspecified + var carte: Dp = Dp.Unspecified + } + + private fun poser(): Mesures { + val mesures = Mesures() + + compose.setContent { + val density = LocalDensity.current + + Box(Modifier.fillMaxSize()) { + MiniPlayerHost( + state = state, + showMiniPlayer = true, + onExpand = {}, + onTogglePlayPause = {}, + onSkipNext = {}, + modifier = Modifier.fillMaxSize(), + ) { reserve -> + mesures.reserve = reserve + ListeTemoin(reserve) + } + + // La même carte, mesurée à part : c'est elle l'étalon, et non un + // nombre que ce test se donnerait à lui-même. Sa hauteur ne + // dépend pas de la largeur, les deux lignes de texte restant + // sur une ligne chacune. + MiniPlayer( + state = state, + onExpand = {}, + onTogglePlayPause = {}, + onSkipNext = {}, + modifier = Modifier + .align(Alignment.TopCenter) + .onSizeChanged { mesures.carte = with(density) { it.height.toDp() } }, + ) + } + } + + compose.waitForIdle() + return mesures + } + + @Composable + private fun ListeTemoin(reserve: Dp) { + LazyColumn( + contentPadding = PaddingValues(bottom = reserve), + modifier = Modifier + .fillMaxSize() + .testTag(LISTE), + ) { + items((0 until LIGNES).toList()) { index -> + Text( + text = "Ligne $index", + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag("ligne-$index"), + ) + } + } + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi") + fun `la reserve sous les listes vaut la hauteur de la carte`() { + val mesures = poser() + + assertEquals(mesures.carte, mesures.reserve) + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi", fontScale = 2.0f) + fun `a polices doublees la carte grandit et la reserve avec elle`() { + // Le cas qu'aucune constante ne peut couvrir : la carte grandit avec les + // réglages d'accessibilité du système, la réserve doit suivre. + val mesures = poser() + + assertEquals(mesures.carte, mesures.reserve) + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi") + fun `la derniere ligne reste entierement visible sous la carte`() { + // La propriété que l'utilisateur constate : arrivé au bas de la liste, + // rien ne se cache derrière le mini-lecteur. + val mesures = poser() + + compose.onNodeWithTag(LISTE).performScrollToIndex(LIGNES - 1) + compose.waitForIdle() + + val racine = compose.onRoot().getUnclippedBoundsInRoot() + val derniere = compose.onNodeWithTag("ligne-${LIGNES - 1}").getUnclippedBoundsInRoot() + val hautDeLaCarte = racine.height - mesures.carte + + assertTrue( + "La dernière ligne finit à ${derniere.bottom}, la carte commence à $hautDeLaCarte", + derniere.bottom <= hautDeLaCarte, + ) + } + + private companion object { + const val LISTE = "liste-temoin" + const val LIGNES = 30 + } +}