From 79f14333a77f91bcdec7c64d7154640d78bd856c Mon Sep 17 00:00:00 2001 From: Ahmed El-Helw Date: Tue, 23 Jan 2024 22:43:55 +0400 Subject: [PATCH] Add initial AudioBar Composables This sets up some initial work to use Compose for AudioBar rendering, though does not yet use any of this work yet. --- .../ui/helpers/QuranPageAdapter.kt | 1 - .../labs/androidquran/common/ui/core/Theme.kt | 3 + feature/audiobar/build.gradle.kts | 27 +++ .../feature/audiobar/AudioBarPresenter.kt | 15 ++ .../mobile/feature/audiobar/AudioBarState.kt | 120 ++++++++++++ .../mobile/feature/audiobar/ui/AudioBar.kt | 78 ++++++++ .../feature/audiobar/ui/ErrorAudioBar.kt | 56 ++++++ .../feature/audiobar/ui/LoadingAudioBar.kt | 79 ++++++++ .../feature/audiobar/ui/PlaybackAudioBar.kt | 126 ++++++++++++ .../feature/audiobar/ui/PromptingAudioBar.kt | 62 ++++++ .../feature/audiobar/ui/RecitationAudioBar.kt | 184 ++++++++++++++++++ .../feature/audiobar/ui/RepeatableButton.kt | 63 ++++++ .../feature/audiobar/ui/StoppedAudioBar.kt | 93 +++++++++ gradle/libs.versions.toml | 3 + settings.gradle.kts | 1 + 15 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 feature/audiobar/build.gradle.kts create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarPresenter.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarState.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/AudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/ErrorAudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/LoadingAudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PlaybackAudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PromptingAudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RecitationAudioBar.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RepeatableButton.kt create mode 100644 feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/StoppedAudioBar.kt diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.kt b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.kt index ff1f74b7a8..cdb728fb91 100644 --- a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.kt +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.kt @@ -4,7 +4,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.quran.data.core.QuranInfo -import com.quran.labs.androidquran.data.Constants import com.quran.labs.androidquran.ui.fragment.QuranPageFragment import com.quran.labs.androidquran.ui.fragment.TabletFragment import com.quran.labs.androidquran.ui.fragment.TranslationFragment diff --git a/common/ui/core/src/main/java/com/quran/labs/androidquran/common/ui/core/Theme.kt b/common/ui/core/src/main/java/com/quran/labs/androidquran/common/ui/core/Theme.kt index 9caa304f36..79d039d8e2 100644 --- a/common/ui/core/src/main/java/com/quran/labs/androidquran/common/ui/core/Theme.kt +++ b/common/ui/core/src/main/java/com/quran/labs/androidquran/common/ui/core/Theme.kt @@ -2,6 +2,7 @@ package com.quran.labs.androidquran.common.ui.core import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.icons.Icons import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -72,6 +73,8 @@ private val DarkColors = darkColorScheme( private val forceLtr = listOf("huawei", "lenovo", "tecno") +val QuranIcons = Icons.Filled + @Composable fun QuranTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), diff --git a/feature/audiobar/build.gradle.kts b/feature/audiobar/build.gradle.kts new file mode 100644 index 0000000000..07a37b1c10 --- /dev/null +++ b/feature/audiobar/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("quran.android.library.compose") +} + +android.namespace = "com.quran.mobile.feature.audiobar" + +dependencies { + implementation(project(":common:audio")) + implementation(project(":common:ui:core")) + + // compose + implementation(libs.compose.animation) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + // circuit + implementation(libs.circuit.foundation) + + // coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarPresenter.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarPresenter.kt new file mode 100644 index 0000000000..4e91c72b50 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarPresenter.kt @@ -0,0 +1,15 @@ +package com.quran.mobile.feature.audiobar + +import androidx.compose.runtime.Composable +import com.quran.labs.androidquran.common.audio.repository.AudioStatusRepository +import com.slack.circuit.runtime.presenter.Presenter + +class AudioBarPresenter( + private val audioStatusRepository: AudioStatusRepository +) : Presenter { + + @Composable + override fun present(): AudioBarState { + TODO("Not yet implemented") + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarState.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarState.kt new file mode 100644 index 0000000000..de6bd3f898 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/AudioBarState.kt @@ -0,0 +1,120 @@ +package com.quran.mobile.feature.audiobar + +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState + +sealed class AudioBarState : CircuitUiState { + sealed class ActivePlayback : AudioBarState() { + abstract val repeat: Int + abstract val speed: Float + abstract val eventSink: (AudioBarEvent.CommonPlaybackEvent) -> Unit + } + + data class Playing( + override val repeat: Int, + override val speed: Float, + override val eventSink: (AudioBarEvent.CommonPlaybackEvent) -> Unit, + val playbackEventSink: (AudioBarEvent.PlayingPlaybackEvent) -> Unit + ) : ActivePlayback() + + data class Paused( + override val repeat: Int, + override val speed: Float, + override val eventSink: (AudioBarEvent.CommonPlaybackEvent) -> Unit, + val pausedEventSink: (AudioBarEvent.PausedPlaybackEvent) -> Unit + ) : ActivePlayback() + + data class Stopped( + val qariName: String, + val enableRecording: Boolean, + val eventSink: (AudioBarEvent.StoppedPlaybackEvent) -> Unit + ) : AudioBarState() + + data class Loading( + val progress: Int, + val message: String, + val eventSink: (AudioBarEvent.CancelablePlaybackEvent) -> Unit + ) : AudioBarState() + + data class Error( + val message: String, + val eventSink: (AudioBarEvent.CancelablePlaybackEvent) -> Unit + ) : AudioBarState() + + data class Prompt( + val message: String, + val eventSink: (AudioBarEvent.PromptEvent) -> Unit + ) : AudioBarState() + + sealed class RecitationState(val isRecitationActive: Boolean) : AudioBarState() { + abstract val eventSink: (AudioBarEvent.CommonRecordingEvent) -> Unit + } + + data class RecitationListening( + override val eventSink: (AudioBarEvent.CommonRecordingEvent) -> Unit, + val listeningEventSink: (AudioBarEvent.RecitationListeningEvent) -> Unit + ) : RecitationState(true) + + data class RecitationPlaying( + override val eventSink: (AudioBarEvent.CommonRecordingEvent) -> Unit, + val playingEventSink: (AudioBarEvent.RecitationPlayingEvent) -> Unit + ) : RecitationState(false) + + data class RecitationStopped( + override val eventSink: (AudioBarEvent.CommonRecordingEvent) -> Unit, + val stoppedEventSink: (AudioBarEvent.RecitationStoppedEvent) -> Unit + ) : RecitationState(false) +} + +sealed class AudioBarEvent : CircuitUiEvent { + sealed class CommonPlaybackEvent : AudioBarEvent() { + data object Stop : CommonPlaybackEvent() + data object Rewind : CommonPlaybackEvent() + data object FastForward : CommonPlaybackEvent() + data class SetSpeed(val speed: Float) : CommonPlaybackEvent() + data class SetRepeat(val repeat: Int) : CommonPlaybackEvent() + } + + sealed class PlayingPlaybackEvent : AudioBarEvent() { + data object Pause : PlayingPlaybackEvent() + } + + sealed class PausedPlaybackEvent : AudioBarEvent() { + data object Play : PausedPlaybackEvent() + } + + sealed class CancelablePlaybackEvent : AudioBarEvent() { + data object Cancel : CancelablePlaybackEvent() + } + + sealed class StoppedPlaybackEvent : AudioBarEvent() { + data object ChangeQari : StoppedPlaybackEvent() + data object Play : StoppedPlaybackEvent() + data object Record : StoppedPlaybackEvent() + } + + sealed class PromptEvent : AudioBarEvent() { + data object Cancel : PromptEvent() + data object Acknowledge : PromptEvent() + } + + sealed class CommonRecordingEvent : AudioBarEvent() { + data object Recitation : CommonRecordingEvent() + data object RecitationLongPress : CommonRecordingEvent() + data object Transcript : CommonRecordingEvent() + } + + sealed class RecitationListeningEvent : AudioBarEvent() { + data object HideVerses : RecitationListeningEvent() + } + + sealed class RecitationPlayingEvent : AudioBarEvent() { + data object EndSession : RecitationPlayingEvent() + data object PauseRecitation : RecitationPlayingEvent() + } + + sealed class RecitationStoppedEvent : AudioBarEvent() { + data object EndSession : RecitationStoppedEvent() + data object PlayRecitation : RecitationStoppedEvent() + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/AudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/AudioBar.kt new file mode 100644 index 0000000000..58a887867d --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/AudioBar.kt @@ -0,0 +1,78 @@ +package com.quran.mobile.feature.audiobar.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun AudioBar(audioBarState: AudioBarState) { + val modifier = Modifier + .requiredWidthIn(max = 360.dp) + .fillMaxWidth() + .height(56.dp) + + when (audioBarState) { + is AudioBarState.Paused -> PausedAudioBar(state = audioBarState, modifier = modifier) + is AudioBarState.Playing -> PlayingAudioBar(state = audioBarState, modifier = modifier) + is AudioBarState.Error -> ErrorAudioBar(state = audioBarState, modifier = modifier) + is AudioBarState.Loading -> LoadingAudioBar(state = audioBarState, modifier = modifier) + is AudioBarState.Prompt -> PromptingAudioBar(state = audioBarState, modifier = modifier) + is AudioBarState.RecitationListening -> RecitationListeningAudioBar( + state = audioBarState, + modifier = modifier + ) + + is AudioBarState.RecitationPlaying -> RecitationPlayingAudioBar( + state = audioBarState, + modifier = modifier + ) + + is AudioBarState.RecitationStopped -> RecitationStoppedAudioBar( + state = audioBarState, + modifier = modifier + ) + + is AudioBarState.Stopped -> StoppedAudioBar(state = audioBarState, modifier = modifier) + } +} + +@Preview +@Preview("arabic", locale = "ar") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AudioBarStoppedPreview() { + QuranTheme { + Surface { + AudioBar(audioBarState = AudioBarState.Stopped( + qariName = "Abdul Basit", + enableRecording = false, + eventSink = {} + )) + } + } +} + +@Preview +@Preview("arabic", locale = "ar") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AudioBarPlayingPreview() { + QuranTheme { + Surface { + AudioBar(audioBarState = AudioBarState.Playing( + repeat = 1, + speed = 1.5f, + eventSink = {}, + playbackEventSink = {} + )) + } + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/ErrorAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/ErrorAudioBar.kt new file mode 100644 index 0000000000..b8fb7e6404 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/ErrorAudioBar.kt @@ -0,0 +1,56 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun ErrorAudioBar(state: AudioBarState.Error, modifier: Modifier = Modifier) { + val sink = state.eventSink + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(IntrinsicSize.Min) + ) { + IconButton(onClick = { sink(AudioBarEvent.CancelablePlaybackEvent.Cancel) }) { + Icon(QuranIcons.Close, contentDescription = stringResource(id = android.R.string.cancel)) + } + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + Text(text = state.message) + } +} + +@Preview +@Composable +fun ErrorAudioBarPreview() { + QuranTheme { + ErrorAudioBar( + state = AudioBarState.Error( + message = "Error message", + eventSink = {} + ) + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/LoadingAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/LoadingAudioBar.kt new file mode 100644 index 0000000000..8bd8d2b9af --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/LoadingAudioBar.kt @@ -0,0 +1,79 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun LoadingAudioBar(state: AudioBarState.Loading, modifier: Modifier = Modifier) { + val sink = state.eventSink + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(IntrinsicSize.Min) + ) { + IconButton(onClick = { sink(AudioBarEvent.CancelablePlaybackEvent.Cancel) }) { + Icon(QuranIcons.Close, contentDescription = stringResource(id = android.R.string.cancel)) + } + + Divider(modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline)) + + Column { + if (state.progress == -1) { + LinearProgressIndicator() + } else { + LinearProgressIndicator(progress = state.progress.toFloat() / 100f) + } + + Text(text = state.message) + } + } +} + +@Preview +@Composable +fun LoadingAudioBarPreview() { + QuranTheme { + LoadingAudioBar( + state = AudioBarState.Loading( + progress = 50, + message = "Downloading...", + eventSink = {} + ) + ) + } +} + +@Preview +@Composable +fun LoadingAudioBarIndeterminatePreview() { + QuranTheme { + LoadingAudioBar( + state = AudioBarState.Loading( + progress = -1, + message = "Loading...", + eventSink = {} + ) + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PlaybackAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PlaybackAudioBar.kt new file mode 100644 index 0000000000..35472f0706 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PlaybackAudioBar.kt @@ -0,0 +1,126 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun PlayingAudioBar(state: AudioBarState.Playing, modifier: Modifier = Modifier) { + val sink = state.playbackEventSink + + AudioBar(state, modifier) { + IconButton(onClick = { sink(AudioBarEvent.PlayingPlaybackEvent.Pause) }) { + Icon(QuranIcons.Pause, contentDescription = "") + } + } +} + +@Composable +fun PausedAudioBar(state: AudioBarState.Paused, modifier: Modifier = Modifier) { + val sink = state.pausedEventSink + + AudioBar(state = state, modifier = modifier) { + IconButton(onClick = { sink(AudioBarEvent.PausedPlaybackEvent.Play) }) { + Icon(QuranIcons.PlayArrow, contentDescription = "") + } + } +} + +@Composable +fun AudioBar( + state: AudioBarState.ActivePlayback, + modifier: Modifier = Modifier, + actionButton: @Composable () -> Unit +) { + val sink = state.eventSink + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + IconButton(onClick = { sink(AudioBarEvent.CommonPlaybackEvent.Stop) }) { + Icon(QuranIcons.Stop, contentDescription = "") + } + + IconButton(onClick = { sink(AudioBarEvent.CommonPlaybackEvent.Rewind) }) { + Icon(QuranIcons.FastRewind, contentDescription = "") + } + + actionButton() + + IconButton(onClick = { sink(AudioBarEvent.CommonPlaybackEvent.FastForward) }) { + Icon(QuranIcons.FastForward, contentDescription = "") + } + + RepeatableButton( + icon = QuranIcons.Repeat, + contentDescription = "", + values = REPEAT_VALUES, + value = state.repeat, + defaultValue = 0, + format = { it.toString() } + ) { + sink(AudioBarEvent.CommonPlaybackEvent.SetRepeat(it)) + } + + RepeatableButton( + icon = QuranIcons.Speed, + contentDescription = "", + values = SPEED_VALUES, + value = state.speed, + defaultValue = 1.0f, + format = { it.toString() } + ) { + sink(AudioBarEvent.CommonPlaybackEvent.SetSpeed(it)) + } + } +} + +private val REPEAT_VALUES = listOf(0, 1, 2, 3, -1) +private val SPEED_VALUES = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f) + +@Preview +@Composable +fun PlayingAudioBarPreview() { + QuranTheme { + PlayingAudioBar( + state = AudioBarState.Playing( + repeat = 0, + speed = 1.0f, + eventSink = {}, + playbackEventSink = {} + ) + ) + } +} + +@Preview +@Composable +fun PausedAudioBarPreview() { + QuranTheme { + PausedAudioBar( + state = AudioBarState.Paused( + repeat = 1, + speed = 0.5f, + eventSink = {}, + pausedEventSink = {} + ) + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PromptingAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PromptingAudioBar.kt new file mode 100644 index 0000000000..0879dbc42a --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/PromptingAudioBar.kt @@ -0,0 +1,62 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun PromptingAudioBar(state: AudioBarState.Prompt, modifier: Modifier = Modifier) { + val sink = state.eventSink + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(IntrinsicSize.Min) + ) { + IconButton(onClick = { sink(AudioBarEvent.PromptEvent.Acknowledge) }) { + Icon(QuranIcons.Close, contentDescription = stringResource(id = android.R.string.ok)) + } + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + Text(text = state.message) + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = { sink(AudioBarEvent.PromptEvent.Cancel) }) { + Icon(QuranIcons.Close, contentDescription = stringResource(id = android.R.string.cancel)) + } + } +} + +@Preview +@Composable +fun PromptingAudioBarPreview() { + QuranTheme { + PromptingAudioBar( + state = AudioBarState.Prompt( + message = "Error message", + eventSink = {} + ) + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RecitationAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RecitationAudioBar.kt new file mode 100644 index 0000000000..da8a1601b9 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RecitationAudioBar.kt @@ -0,0 +1,184 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun RecitationListeningAudioBar( + state: AudioBarState.RecitationListening, + modifier: Modifier = Modifier +) { + val sink = state.listeningEventSink + RecitationAudioBar(state = state, modifier = modifier) { + IconButton(onClick = { sink(AudioBarEvent.RecitationListeningEvent.HideVerses) }) { + Icon(QuranIcons.MenuBook, contentDescription = "") + } + } +} + +@Composable +fun RecitationPlayingAudioBar( + state: AudioBarState.RecitationPlaying, + modifier: Modifier = Modifier +) { + val sink = state.playingEventSink + RecitationAudioBar(state = state, modifier = modifier) { + IconButton(onClick = { sink(AudioBarEvent.RecitationPlayingEvent.EndSession) }) { + Icon(QuranIcons.Close, contentDescription = "") + } + + IconButton(onClick = { sink(AudioBarEvent.RecitationPlayingEvent.PauseRecitation) }) { + Icon(QuranIcons.Pause, contentDescription = "") + } + } +} + +@Composable +fun RecitationStoppedAudioBar( + state: AudioBarState.RecitationStopped, + modifier: Modifier = Modifier +) { + val sink = state.stoppedEventSink + RecitationAudioBar(state = state, modifier = modifier) { + IconButton(onClick = { sink(AudioBarEvent.RecitationStoppedEvent.EndSession) }) { + Icon(QuranIcons.Close, contentDescription = "") + } + + IconButton(onClick = { sink(AudioBarEvent.RecitationStoppedEvent.PlayRecitation) }) { + Icon(QuranIcons.PlayArrow, contentDescription = "") + } + } +} + +@Composable +fun RecitationAudioBar( + state: AudioBarState.RecitationState, + modifier: Modifier = Modifier, + actions: @Composable () -> Unit +) { + val sink = state.eventSink + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(IntrinsicSize.Min) + ) { + actions() + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + IconButton(onClick = { sink(AudioBarEvent.CommonRecordingEvent.Transcript) }) { + Icon(QuranIcons.Chat, contentDescription = "") + } + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .size(40.dp) + .clip(CircleShape) + .background(color = Color.Transparent) + .combinedClickable( + role = Role.Button, + onClick = { sink(AudioBarEvent.CommonRecordingEvent.Recitation) }, + onLongClick = { sink(AudioBarEvent.CommonRecordingEvent.RecitationLongPress) }, + ), + contentAlignment = Alignment.Center + ) { + val tint = if (state.isRecitationActive) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + } + Icon(QuranIcons.Mic, contentDescription = "", tint = tint) + } + } +} + +@Preview +@Composable +fun RecitationListeningAudioBarPreview() { + QuranTheme { + RecitationListeningAudioBar( + state = AudioBarState.RecitationListening( + eventSink = {}, + listeningEventSink = {}, + ) + ) + } +} + +@Preview +@Composable +fun RecitationPlayingAudioBarPreview() { + QuranTheme { + RecitationPlayingAudioBar( + state = AudioBarState.RecitationPlaying( + eventSink = {}, + playingEventSink = {}, + ) + ) + } +} + +@Preview +@Composable +fun RecitationStoppedAudioBarPreview() { + QuranTheme { + RecitationStoppedAudioBar( + state = AudioBarState.RecitationStopped( + eventSink = {}, + stoppedEventSink = {}, + ) + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RepeatableButton.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RepeatableButton.kt new file mode 100644 index 0000000000..ee602c033c --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/RepeatableButton.kt @@ -0,0 +1,63 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme + +@Composable +fun RepeatableButton( + icon: ImageVector, + contentDescription: String, + values: List, + value: T, + defaultValue: T, + format: (T) -> String, + onValueChanged: (T) -> Unit +) { + Box { + IconButton(onClick = { + val index = (values.indexOf(value) + 1) % values.size + onValueChanged(values[index]) + }) { + Icon(icon, contentDescription = contentDescription) + } + + if (value != defaultValue) { + Text( + text = format(value), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 8.dp) + ) + } + } +} + +@Preview +@Composable +fun RepeatableButtonPreview() { + QuranTheme { + RepeatableButton( + icon = QuranIcons.Repeat, + contentDescription = "", + values = listOf(0, 1, 2, 3, -1), + value = 1, + defaultValue = 0, + format = { it.toString() }, + onValueChanged = {} + ) + } +} diff --git a/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/StoppedAudioBar.kt b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/StoppedAudioBar.kt new file mode 100644 index 0000000000..ed9a4de290 --- /dev/null +++ b/feature/audiobar/src/main/kotlin/com/quran/mobile/feature/audiobar/ui/StoppedAudioBar.kt @@ -0,0 +1,93 @@ +package com.quran.mobile.feature.audiobar.ui + +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.quran.labs.androidquran.common.ui.core.QuranIcons +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.audiobar.AudioBarEvent +import com.quran.mobile.feature.audiobar.AudioBarState + +@Composable +fun StoppedAudioBar(state: AudioBarState.Stopped, modifier: Modifier = Modifier) { + val sink = state.eventSink + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.height(IntrinsicSize.Min) + ) { + IconButton(onClick = { sink(AudioBarEvent.StoppedPlaybackEvent.Play) }) { + Icon(QuranIcons.PlayArrow, contentDescription = "") + } + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + TextButton( + modifier = Modifier.weight(1f), + onClick = { sink(AudioBarEvent.StoppedPlaybackEvent.ChangeQari) } + ) { + Text(text = state.qariName) + Spacer(modifier = Modifier.weight(1f)) + Icon(QuranIcons.ExpandMore, contentDescription = "") + } + + if (state.enableRecording) { + Divider( + modifier = Modifier + .fillMaxHeight() + .width(Dp.Hairline) + ) + + IconButton(onClick = { sink(AudioBarEvent.StoppedPlaybackEvent.Record) }) { + Icon(QuranIcons.Mic, contentDescription = "") + } + } + } +} + +@Preview +@Composable +fun StoppedAudioBarPreview() { + QuranTheme { + StoppedAudioBar( + state = AudioBarState.Stopped( + qariName = "Qari Name", + enableRecording = false, + eventSink = {} + ) + ) + } +} + +@Preview +@Composable +fun StoppedAudioBarWithRecordingPreview() { + QuranTheme { + StoppedAudioBar( + state = AudioBarState.Stopped( + qariName = "Qari Name", + enableRecording = true, + eventSink = {} + ) + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fa2af33c3..4672e9ce41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,7 @@ insetterVersion = "0.6.1" materialComponentsVersion = "1.11.0" numberPickerVersion = "2.4.13" reorderableComposeVersion = "0.9.6" +circuit = "0.18.2" # recitations grpcOkhttpVersion = "1.61.0" @@ -101,6 +102,7 @@ compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-animation = { module = "androidx.compose.animation:animation" } compose-material = { module = "androidx.compose.material:material" } compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.0-rc01" } +compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -150,6 +152,7 @@ insetter = { module = "dev.chrisbanes.insetter:insetter", version.ref = "insette accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanistVersion" } tooltip-compose = { module = "com.github.skgmn:composetooltip", version.ref = "tooltipComposeVersion" } reorderable-compose = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "reorderableComposeVersion" } +circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" } # utils leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroidVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2dfbae96b7..45b46d7506 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ include(":common:upgrade") include(":common:ui:core") include(":feature:analytics-noop") include(":feature:audio") +include(":feature:audiobar") include(":feature:downloadmanager") include(":feature:qarilist") include(":feature:recitation")