Files
radiola-android/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
nk 861b0e2b8f 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>
2026-06-06 15:25:42 +03:00

387 lines
16 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}
}
}