Coverage Summary for Class: MetronomeScreenViewKt (com.vsevolodganin.clicktrack.ui.screen)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| MetronomeScreenViewKt |
0%
(0/12)
|
0%
(0/62)
|
0%
(0/96)
|
0%
(0/1352)
|
| MetronomeScreenViewKt$bottomSheetState$1$1 |
0%
(0/1)
|
25%
(1/4)
|
0%
(0/3)
|
0%
(0/38)
|
| MetronomeScreenViewKt$bottomSheetState$2$1 |
0%
(0/1)
|
0%
(0/3)
|
0%
(0/3)
|
18.2%
(4/22)
|
| MetronomeScreenViewKt$bottomSheetState$2$1$WhenMappings |
|
| MetronomeScreenViewKt$Content$1$4$1 |
0%
(0/2)
|
|
0%
(0/9)
|
0%
(0/85)
|
| MetronomeScreenViewKt$MetronomeScreenPreview$1$1 |
0%
(0/9)
|
|
0%
(0/17)
|
0%
(0/46)
|
| Total |
0%
(0/25)
|
1.4%
(1/69)
|
0%
(0/128)
|
0.3%
(4/1543)
|
package com.vsevolodganin.clicktrack.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import clicktrack.multiplatform.generated.resources.Res
import clicktrack.multiplatform.generated.resources.general_metronome_click_track_title
import clicktrack.multiplatform.generated.resources.metronome_bpm_meter_tap
import clicktrack.multiplatform.generated.resources.metronome_screen_title
import com.vsevolodganin.clicktrack.metronome.MetronomeState
import com.vsevolodganin.clicktrack.metronome.MetronomeViewModel
import com.vsevolodganin.clicktrack.metronome.metronomeClickTrack
import com.vsevolodganin.clicktrack.model.BeatsPerMinuteOffset
import com.vsevolodganin.clicktrack.model.NotePattern
import com.vsevolodganin.clicktrack.model.PlayProgress
import com.vsevolodganin.clicktrack.model.TimeSignature
import com.vsevolodganin.clicktrack.model.bpm
import com.vsevolodganin.clicktrack.ui.piece.BpmWheel
import com.vsevolodganin.clicktrack.ui.piece.ClickTrackView
import com.vsevolodganin.clicktrack.ui.piece.DarkTopAppBarWithBack
import com.vsevolodganin.clicktrack.ui.piece.PlayStopButton
import com.vsevolodganin.clicktrack.ui.piece.SubdivisionsChooser
import com.vsevolodganin.clicktrack.ui.piece.TimeSignatureView
import com.vsevolodganin.clicktrack.ui.theme.ClickTrackTheme
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MetronomeScreenView(viewModel: MetronomeViewModel, modifier: Modifier = Modifier) {
Scaffold(
topBar = {
DarkTopAppBarWithBack(
onBackClick = viewModel::onBackClick,
title = { Text(text = stringResource(Res.string.metronome_screen_title)) },
actions = {
IconButton(onClick = viewModel::onToggleOptions) {
Icon(imageVector = Icons.Default.Tune, contentDescription = null)
}
},
)
},
modifier = modifier,
) { paddingValues ->
Content(
viewModel = viewModel,
state = viewModel.state.collectAsState().value ?: return@Scaffold,
modifier = Modifier.padding(paddingValues),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Content(
viewModel: MetronomeViewModel,
state: MetronomeState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.height(200.dp),
) {
val metronomeClickTrackName = stringResource(Res.string.general_metronome_click_track_title)
val metronomeClickTrack = remember(state.bpm, state.pattern, state.timeSignature) {
metronomeClickTrack(
name = metronomeClickTrackName,
bpm = state.bpm,
pattern = state.pattern,
timeSignature = state.timeSignature,
)
}
ClickTrackView(
clickTrack = metronomeClickTrack,
drawAllBeatsMarks = true,
drawTextMarks = false,
progress = state.progress,
defaultLineWidth = with(LocalDensity.current) { 1f.dp.toPx() },
)
}
Text(
text = state.bpm.value.toString(),
style = MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Medium,
letterSpacing = 8.sp,
),
)
TimeSignatureView(
value = state.timeSignature,
onValueChange = viewModel::onTimeSignatureChange,
)
Layout(
content = {
Box(contentAlignment = Alignment.Center) {
BpmWheel(
value = state.bpm,
onValueChange = viewModel::onBpmChange,
modifier = Modifier.size(200.dp),
)
PlayStopButton(
isPlaying = state.isPlaying,
onToggle = viewModel::onTogglePlay,
)
}
FloatingActionButton(
onClick = viewModel::onBpmMeterClick,
modifier = Modifier.size(64.dp),
shape = CircleShape,
) {
Text(
text = stringResource(Res.string.metronome_bpm_meter_tap),
style = LocalTextStyle.current.copy(
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
),
)
}
},
modifier = Modifier.fillMaxWidth(),
) { measurables, constraints ->
val wheel = measurables[0].measure(Constraints())
val fab = measurables[1].measure(Constraints())
val width = constraints.maxWidth
val height = maxOf(wheel.height, fab.height)
layout(width, height) {
// Wheel is centered horizontally, nothing special
wheel.placeRelative(x = (width - wheel.width) / 2, y = 0)
// FAB is placed in the middle between wheel's and parent's right borders
fab.placeRelative(
x = (width * 3 + wheel.width - fab.width * 2) / 4,
y = (height - fab.height) / 2,
)
}
}
}
val bottomSheetState = bottomSheetState(viewModel)
// FIXME: Checking both states to give animation an opportunity to finish
if (state.areOptionsExpanded || bottomSheetState.isVisible) {
ModalBottomSheet(
onDismissRequest = { viewModel.onOptionsExpandedChange(false) },
sheetState = bottomSheetState,
) {
Options(viewModel)
}
}
}
@Composable
private fun Options(viewModel: MetronomeViewModel) {
val state = viewModel.state.collectAsState().value ?: return
SubdivisionsChooser(
pattern = state.pattern,
timeSignature = state.timeSignature,
onSubdivisionChoose = viewModel::onPatternChoose,
modifier = Modifier.padding(8.dp).navigationBarsPadding(),
alwaysExpanded = true,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun bottomSheetState(viewModel: MetronomeViewModel): SheetState {
val externalAreOptionsExpanded = viewModel.state.collectAsState().value?.areOptionsExpanded ?: false
val localBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Sync the external state with the local state
LaunchedEffect(externalAreOptionsExpanded) {
when (externalAreOptionsExpanded) {
true -> localBottomSheetState.expand()
false -> localBottomSheetState.hide()
}
}
LaunchedEffect(localBottomSheetState.currentValue) {
when (localBottomSheetState.currentValue) {
SheetValue.PartiallyExpanded,
SheetValue.Hidden,
-> viewModel.onOptionsExpandedChange(false)
SheetValue.Expanded -> viewModel.onOptionsExpandedChange(true)
}
}
return localBottomSheetState
}
@Preview
@Composable
internal fun MetronomeScreenPreview(expanded: Boolean = false) = ClickTrackTheme {
MetronomeScreenView(
viewModel = object : MetronomeViewModel {
override val state: StateFlow<MetronomeState?> = MutableStateFlow(
MetronomeState(
bpm = 90.bpm,
pattern = NotePattern.QUINTUPLET_X2,
timeSignature = TimeSignature(4, 4),
progress = PlayProgress(100.milliseconds),
isPlaying = false,
areOptionsExpanded = expanded,
),
)
override fun onBackClick() = Unit
override fun onToggleOptions() = Unit
override fun onOptionsExpandedChange(isOpened: Boolean) = Unit
override fun onPatternChoose(pattern: NotePattern) = Unit
override fun onTimeSignatureChange(timeSignature: TimeSignature) = Unit
override fun onBpmChange(bpmDiff: BeatsPerMinuteOffset) = Unit
override fun onTogglePlay() = Unit
override fun onBpmMeterClick() = Unit
},
)
}
@Preview
@Composable
private fun ExpandedMetronomeScreenPreview() = MetronomeScreenPreview(expanded = true)