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:
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal file
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user