feat: будильник с радиостанцией + выбор битрейта по умолчанию

Будильник (Settings → Будильник): несколько будильников, время, станция, дни недели,
fade-in пробуждения. AlarmManager.setAlarmClock (вне doze) + фолбэк, BootReceiver
перепланирует после перезагрузки, AlarmReceiver→PlayerService (foreground) →
PlayerController.startAlarmPlayback (нарастание громкости). Room: AlarmEntity/Dao, БД v7.
Выбор битрейта по умолчанию в Settings (Авто/Эконом/Стандарт/Высокое) → preferredBitrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 15:25:42 +03:00
parent 4411d53a6c
commit 861b0e2b8f
17 changed files with 1014 additions and 3 deletions

View File

@@ -0,0 +1,386 @@
package com.radiola.ui.alarms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Trash2
import com.radiola.data.local.entity.AlarmEntity
import com.radiola.domain.model.Station
import com.radiola.ui.theme.RadiolaTheme
/** Экран управления будильниками. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlarmsScreen(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AlarmsViewModel = hiltViewModel()
) {
val colors = RadiolaTheme.colors
val alarms by viewModel.alarms.collectAsState()
val stations by viewModel.stations.collectAsState()
// Состояние диалога добавления/редактирования
var editingAlarm by remember { mutableStateOf<AlarmEntity?>(null) }
var showEditor by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
.background(colors.bgBase)
) {
// Шапка с кнопкой «Назад»
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onNavigateBack) {
Icon(Lucide.ArrowLeft, contentDescription = "Назад", tint = colors.textPrimary)
}
Text(
text = "Будильник",
style = MaterialTheme.typography.headlineMedium,
color = colors.textPrimary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
IconButton(onClick = {
editingAlarm = null
showEditor = true
}) {
Icon(Lucide.Plus, contentDescription = "Добавить будильник", tint = colors.accent)
}
}
if (alarms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Lucide.AlarmClock, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(48.dp))
Text("Нет будильников", color = colors.textMuted, style = MaterialTheme.typography.bodyLarge)
Text(
"Нажмите «+» чтобы добавить",
color = colors.textMuted,
style = MaterialTheme.typography.labelMedium
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(alarms, key = { it.id }) { alarm ->
AlarmCard(
alarm = alarm,
onToggle = { viewModel.toggle(alarm) },
onEdit = {
editingAlarm = alarm
showEditor = true
},
onDelete = { viewModel.delete(alarm) }
)
}
}
}
}
// Диалог добавления / редактирования
if (showEditor) {
AlarmEditorSheet(
initial = editingAlarm,
stations = stations,
onSave = { alarm ->
viewModel.addOrUpdate(alarm)
showEditor = false
},
onDismiss = { showEditor = false }
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
@Composable
private fun AlarmCard(
alarm: AlarmEntity,
onToggle: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
val colors = RadiolaTheme.colors
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Время
Text(
text = "%02d:%02d".format(alarm.hour, alarm.minute),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = if (alarm.enabled) colors.textPrimary else colors.textMuted
)
Spacer(Modifier.width(16.dp))
// Станция + дни
Column(modifier = Modifier.weight(1f)) {
Text(
text = alarm.stationName,
style = MaterialTheme.typography.titleMedium,
color = if (alarm.enabled) colors.textPrimary else colors.textMuted,
maxLines = 1
)
Text(
text = daysSummary(alarm.daysMask),
style = MaterialTheme.typography.labelMedium,
color = colors.textSecondary
)
}
// Удалить
IconButton(onClick = onDelete) {
Icon(Lucide.Trash2, contentDescription = "Удалить", tint = colors.textMuted, modifier = Modifier.size(18.dp))
}
// Вкл/выкл
Switch(
checked = alarm.enabled,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = colors.bgBase,
checkedTrackColor = colors.accent,
uncheckedThumbColor = colors.textMuted,
uncheckedTrackColor = colors.surface2
)
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
private val DAY_LABELS = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
private fun daysSummary(mask: Int): String {
if (mask == 0) return "Один раз"
val all = (1 shl 7) - 1
if (mask == all) return "Каждый день"
val weekdays = 0b0011111 // Пн-Пт
if (mask == weekdays) return "По будням"
val weekend = 0b1100000 // Сб-Вс
if (mask == weekend) return "По выходным"
return DAY_LABELS.filterIndexed { i, _ -> mask and (1 shl i) != 0 }.joinToString(" ")
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* Нижний лист редактирования / создания будильника.
* Использует Material3 TimePicker + выбор станции + чипы дней.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlarmEditorSheet(
initial: AlarmEntity?,
stations: List<Station>,
onSave: (AlarmEntity) -> Unit,
onDismiss: () -> Unit
) {
val colors = RadiolaTheme.colors
// Начальные значения
val initHour = initial?.hour ?: 7
val initMinute = initial?.minute ?: 0
var selectedHour by remember { mutableStateOf(initHour) }
var selectedMinute by remember { mutableStateOf(initMinute) }
var daysMask by remember { mutableStateOf(initial?.daysMask ?: 0) }
var selectedStation by remember {
mutableStateOf(stations.firstOrNull { it.id == initial?.stationId } ?: stations.firstOrNull())
}
var fadeInSec by remember { mutableStateOf(initial?.fadeInSec ?: 60) }
var stationDropdownExpanded by remember { mutableStateOf(false) }
// Обновим станцию если список подгрузился после открытия
LaunchedEffect(stations) {
if (selectedStation == null) selectedStation = stations.firstOrNull()
}
val timePickerState = rememberTimePickerState(
initialHour = initHour,
initialMinute = initMinute,
is24Hour = true
)
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = colors.elevated
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(
text = if (initial == null) "Новый будильник" else "Изменить будильник",
style = MaterialTheme.typography.titleLarge,
color = colors.textPrimary,
fontWeight = FontWeight.SemiBold
)
// Выбор времени
TimePicker(
state = timePickerState,
colors = TimePickerDefaults.colors(
clockDialColor = colors.surface2,
selectorColor = colors.accent,
timeSelectorSelectedContainerColor = colors.accent,
timeSelectorUnselectedContainerColor = colors.surface,
timeSelectorSelectedContentColor = colors.bgBase,
timeSelectorUnselectedContentColor = colors.textPrimary,
periodSelectorBorderColor = colors.border,
clockDialSelectedContentColor = colors.bgBase,
clockDialUnselectedContentColor = colors.textPrimary
),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
// Выбор станции
Column {
Text("Станция", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
Spacer(Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colors.surface)
.border(1.dp, colors.border, RoundedCornerShape(12.dp))
.clickable { stationDropdownExpanded = true }
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = selectedStation?.name ?: "Выберите станцию",
color = if (selectedStation != null) colors.textPrimary else colors.textMuted,
style = MaterialTheme.typography.bodyLarge
)
}
DropdownMenu(
expanded = stationDropdownExpanded,
onDismissRequest = { stationDropdownExpanded = false },
modifier = Modifier.background(colors.elevated).heightIn(max = 300.dp)
) {
stations.forEach { station ->
DropdownMenuItem(
text = { Text(station.name, color = colors.textPrimary) },
onClick = {
selectedStation = station
stationDropdownExpanded = false
}
)
}
}
}
// Дни недели
Column {
Text("Повтор", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
DAY_LABELS.forEachIndexed { i, label ->
val selected = daysMask and (1 shl i) != 0
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) colors.accent else colors.surface)
.border(1.dp, if (selected) colors.accent else colors.border, RoundedCornerShape(8.dp))
.clickable {
daysMask = daysMask xor (1 shl i)
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
Spacer(Modifier.height(4.dp))
Text(
text = if (daysMask == 0) "Один раз (ближайшее совпадение)" else daysSummary(daysMask),
style = MaterialTheme.typography.labelSmall,
color = colors.textSecondary
)
}
// Кнопки
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
) {
Text("Отмена")
}
Button(
onClick = {
val station = selectedStation ?: return@Button
onSave(
AlarmEntity(
id = initial?.id ?: 0,
hour = timePickerState.hour,
minute = timePickerState.minute,
daysMask = daysMask,
stationId = station.id,
stationName = station.name,
enabled = initial?.enabled ?: true,
fadeInSec = fadeInSec
)
)
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
),
shape = RoundedCornerShape(10.dp)
) {
Text("Сохранить", fontWeight = FontWeight.SemiBold)
}
}
}
}
}