|
|
|
@@ -8,6 +8,9 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
|
|
|
import androidx.compose.animation.animateContentSize
|
|
|
|
|
import androidx.compose.animation.core.spring
|
|
|
|
|
import androidx.compose.animation.core.tween
|
|
|
|
|
import androidx.compose.animation.core.VisibilityThreshold
|
|
|
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
|
|
|
import androidx.compose.foundation.background
|
|
|
|
|
import androidx.compose.foundation.border
|
|
|
|
|
import androidx.compose.foundation.clickable
|
|
|
|
@@ -36,6 +39,7 @@ import androidx.compose.material.icons.outlined.NotificationsActive
|
|
|
|
|
import androidx.compose.material.icons.outlined.PauseCircle
|
|
|
|
|
import androidx.compose.material.icons.outlined.Vibration
|
|
|
|
|
import androidx.compose.material.icons.rounded.Check
|
|
|
|
|
import androidx.compose.material.icons.rounded.GraphicEq
|
|
|
|
|
import androidx.compose.material.icons.rounded.ExpandLess
|
|
|
|
|
import androidx.compose.material.icons.rounded.ExpandMore
|
|
|
|
|
import androidx.compose.material3.AlertDialog
|
|
|
|
@@ -93,6 +97,12 @@ import java.time.LocalDate
|
|
|
|
|
import java.time.ZoneId
|
|
|
|
|
import java.time.ZoneOffset
|
|
|
|
|
|
|
|
|
|
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
|
|
|
|
|
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
|
|
|
|
|
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
|
|
|
|
|
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
|
|
|
|
|
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
|
|
|
|
|
|
|
|
|
|
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
|
|
|
|
|
interface AlarmActions {
|
|
|
|
|
fun save(alarm: Alarm)
|
|
|
|
@@ -131,12 +141,12 @@ fun AlarmScreen(
|
|
|
|
|
) {
|
|
|
|
|
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
|
|
|
|
items(bedtime, key = { it.alarm.id }) { a ->
|
|
|
|
|
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
|
|
|
|
|
AlarmCard(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
|
|
|
|
|
}
|
|
|
|
|
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
|
|
|
|
|
items(others, key = { it.alarm.id }) { a ->
|
|
|
|
|
AlarmCard(
|
|
|
|
|
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
|
|
|
|
|
Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
|
|
|
|
|
onDelete = {
|
|
|
|
|
actions.delete(a.alarm)
|
|
|
|
|
expanded = null
|
|
|
|
@@ -157,6 +167,7 @@ private fun SectionHeader(text: String) =
|
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
|
|
|
@Composable
|
|
|
|
|
private fun AlarmCard(
|
|
|
|
|
modifier: Modifier,
|
|
|
|
|
item: AlarmWithInstance,
|
|
|
|
|
settings: Settings,
|
|
|
|
|
expanded: Boolean,
|
|
|
|
@@ -181,21 +192,23 @@ private fun AlarmCard(
|
|
|
|
|
else -> ""
|
|
|
|
|
}
|
|
|
|
|
Column(
|
|
|
|
|
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
|
|
|
|
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
|
|
|
|
|
modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
|
|
|
|
.clickable(onClick = onToggleExpand).padding(WSpace.s20)
|
|
|
|
|
.semantics(mergeDescendants = !expanded) {
|
|
|
|
|
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
|
|
|
|
|
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
if (expanded) {
|
|
|
|
|
LabelField(a.label) { actions.save(a.copy(label = it)) }
|
|
|
|
|
} else {
|
|
|
|
|
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
|
|
|
|
val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
|
|
|
|
|
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
|
|
|
|
|
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
if (open) LabelField(a.label) { actions.save(a.copy(label = it)) }
|
|
|
|
|
else Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
|
|
|
|
|
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface)
|
|
|
|
|
Icon(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, tint = scheme.onSurface)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Spacer(Modifier.height(WSpace.s8))
|
|
|
|
@@ -208,7 +221,11 @@ private fun AlarmCard(
|
|
|
|
|
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
|
|
|
|
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
|
|
|
|
|
}
|
|
|
|
|
AnimatedVisibility(expanded) {
|
|
|
|
|
AnimatedVisibility(
|
|
|
|
|
expanded,
|
|
|
|
|
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
|
|
|
|
|
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
|
|
|
|
|
) {
|
|
|
|
|
Column {
|
|
|
|
|
Spacer(Modifier.height(WSpace.s12))
|
|
|
|
|
DayChips(a, settings.weekStart) { actions.save(it) }
|
|
|
|
@@ -247,17 +264,23 @@ private fun AlarmCard(
|
|
|
|
|
@Composable
|
|
|
|
|
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
|
|
|
|
var text by remember(value) { mutableStateOf(value) }
|
|
|
|
|
TextField(
|
|
|
|
|
androidx.compose.foundation.text.BasicTextField(
|
|
|
|
|
text, { text = it }, Modifier.weight(1f),
|
|
|
|
|
placeholder = { Text(stringResource(R.string.add_label)) },
|
|
|
|
|
leadingIcon = { Icon(Icons.Outlined.Label, null) },
|
|
|
|
|
singleLine = true, textStyle = WType.body,
|
|
|
|
|
textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
|
|
|
|
|
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
|
|
|
|
|
singleLine = true,
|
|
|
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
|
|
|
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
|
|
|
|
colors = TextFieldDefaults.colors(
|
|
|
|
|
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
|
|
|
|
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
|
|
|
|
),
|
|
|
|
|
decorationBox = { inner ->
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
Spacer(Modifier.width(WSpace.s12))
|
|
|
|
|
Box {
|
|
|
|
|
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
inner()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
|
|
|
|
|
}
|
|
|
|
@@ -366,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
|
|
|
|
|
}
|
|
|
|
|
list
|
|
|
|
|
}
|
|
|
|
|
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
|
|
|
|
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
|
|
|
|
|
// demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
|
|
|
|
|
val preview = remember { SoundPreview(context) }
|
|
|
|
|
var playing by remember { mutableStateOf<String?>("none") }
|
|
|
|
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
|
|
|
|
|
fun play(uri: String?) {
|
|
|
|
|
preview?.stop()
|
|
|
|
|
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
|
|
|
|
|
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
|
|
|
|
|
if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
|
|
|
|
|
playing = uri
|
|
|
|
|
preview.play(uri)
|
|
|
|
|
}
|
|
|
|
|
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
|
|
|
|
if (uri != null) {
|
|
|
|
@@ -394,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
|
|
|
|
|
) {
|
|
|
|
|
RadioButton(selected == uri, onClick = null)
|
|
|
|
|
Spacer(Modifier.width(WSpace.s12))
|
|
|
|
|
Text(name, style = WType.body)
|
|
|
|
|
Text(name, style = WType.body, modifier = Modifier.weight(1f))
|
|
|
|
|
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
|
|
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
|
|
|
|
confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
|
|
|
|
dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
|
|
|
|
|
class SoundPreview(private val context: android.content.Context) {
|
|
|
|
|
private var player: android.media.MediaPlayer? = null
|
|
|
|
|
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
|
|
|
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
|
|
|
|
|
|
|
|
|
|
fun play(uri: String?) {
|
|
|
|
|
stop()
|
|
|
|
|
val u = when (uri) {
|
|
|
|
|
Alarm.SILENT -> return
|
|
|
|
|
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
|
|
|
|
|
else -> Uri.parse(uri)
|
|
|
|
|
}
|
|
|
|
|
player = runCatching {
|
|
|
|
|
android.media.MediaPlayer().apply {
|
|
|
|
|
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
|
|
|
|
|
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
|
|
|
|
setDataSource(context, u)
|
|
|
|
|
isLooping = true
|
|
|
|
|
prepare()
|
|
|
|
|
start()
|
|
|
|
|
}
|
|
|
|
|
}.getOrNull()
|
|
|
|
|
// a demo, not an alarm: stop after a few seconds
|
|
|
|
|
handler.postDelayed({ stop() }, DEMO_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun stop() {
|
|
|
|
|
handler.removeCallbacksAndMessages(null)
|
|
|
|
|
player?.runCatching { stop(); release() }
|
|
|
|
|
player = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private companion object { const val DEMO_MS = 8_000L }
|
|
|
|
|
}
|
|
|
|
|